deploy: move scripts/{libexec,sbin}/ into deploy/scripts/
Layout consistency: everything ckn-bw deploys to the host now lives under deploy/. ckn-bw's install_left4me_scripts copy-action goes away in lockstep with this commit and is replaced by target-side symlinks. Also updates all path references in docs, tests (conftest.py parents[] depth, test_overlay_helper.py HELPER_SOURCE), and deploy/README.md. Part of 2026-05-15-deployment-responsibility-design.md migration step 4.
This commit is contained in:
parent
55d5ab4017
commit
2834ad4911
19 changed files with 801 additions and 36 deletions
|
|
@ -6,12 +6,12 @@
|
||||||
> (attached via `groups/applications/left4me.py`); run `bw apply ovh.left4me`
|
> (attached via `groups/applications/left4me.py`); run `bw apply ovh.left4me`
|
||||||
> from the ckn-bw repo to deploy.
|
> from the ckn-bw repo to deploy.
|
||||||
>
|
>
|
||||||
> The privileged scripts the application installs live at the repo root
|
> The privileged scripts the application installs live under
|
||||||
> under [`scripts/libexec/`](../scripts/libexec/) and
|
> [`deploy/scripts/libexec/`](scripts/libexec/) and
|
||||||
> [`scripts/sbin/`](../scripts/sbin/) — application code, not deploy
|
> [`deploy/scripts/sbin/`](scripts/sbin/) — application code that also
|
||||||
> artifacts. ckn-bw's `install_left4me_scripts` action reads them from
|
> lives under `deploy/` for layout consistency. ckn-bw creates target-side
|
||||||
> `/opt/left4me/src/scripts/{libexec,sbin}/` after `git_deploy` and
|
> symlinks from `/usr/local/{libexec/left4me,sbin}/` into
|
||||||
> installs them into the standard FHS targets on the host.
|
> `/opt/left4me/src/deploy/scripts/{libexec,sbin}/` after `git_deploy`.
|
||||||
>
|
>
|
||||||
> What remains under `deploy/files/` and `deploy/templates/` is a set of
|
> What remains under `deploy/files/` and `deploy/templates/` is a set of
|
||||||
> readable **examples** — sudoers, sysctl, sandbox-resolv.conf, env
|
> readable **examples** — sudoers, sysctl, sandbox-resolv.conf, env
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
|
|
||||||
| Path | Role |
|
| Path | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `files/etc/sudoers.d/left4me` | Example sudoers grants. Lockdown test: `scripts/tests/test_sudoers_grants.py`. |
|
| `files/etc/sudoers.d/left4me` | Example sudoers grants. Lockdown test: `deploy/scripts/tests/test_sudoers_grants.py`. |
|
||||||
| `files/etc/sysctl.d/99-left4me.conf` | Example sysctl perf baseline (UDP buffers, fq_codel + BBR). |
|
| `files/etc/sysctl.d/99-left4me.conf` | Example sysctl perf baseline (UDP buffers, fq_codel + BBR). |
|
||||||
| `files/etc/left4me/sandbox-resolv.conf` | Example `/etc/resolv.conf` bound into the script-overlay sandbox. |
|
| `files/etc/left4me/sandbox-resolv.conf` | Example `/etc/resolv.conf` bound into the script-overlay sandbox. |
|
||||||
| `files/usr/local/lib/systemd/system/left4me-web.service` | Example of the web-app unit the reactor emits. |
|
| `files/usr/local/lib/systemd/system/left4me-web.service` | Example of the web-app unit the reactor emits. |
|
||||||
|
|
@ -39,9 +39,10 @@
|
||||||
|
|
||||||
The privileged scripts (`left4me-overlay`, `left4me-script-sandbox`,
|
The privileged scripts (`left4me-overlay`, `left4me-script-sandbox`,
|
||||||
`left4me-systemctl`, `left4me-journalctl`, `sbin/left4me`) used to live
|
`left4me-systemctl`, `left4me-journalctl`, `sbin/left4me`) used to live
|
||||||
under this tree at `files/usr/local/{libexec,sbin}/`; they moved to
|
under this tree at `files/usr/local/{libexec,sbin}/`; they moved first to
|
||||||
`scripts/{libexec,sbin}/` because they are application code, not deploy
|
`scripts/{libexec,sbin}/` and then to `deploy/scripts/{libexec,sbin}/` for
|
||||||
artifacts.
|
layout consistency (everything ckn-bw deploys to the host now lives under
|
||||||
|
`deploy/`).
|
||||||
|
|
||||||
## Target layout
|
## Target layout
|
||||||
|
|
||||||
|
|
@ -74,10 +75,10 @@ The deployment uses these on-host paths (FHS-aligned):
|
||||||
operations (incl. idmap staging binds).
|
operations (incl. idmap staging binds).
|
||||||
- `/usr/local/lib/systemd/system/` — global systemd unit files emitted
|
- `/usr/local/lib/systemd/system/` — global systemd unit files emitted
|
||||||
by ckn-bw's `systemd_units` reactor.
|
by ckn-bw's `systemd_units` reactor.
|
||||||
- `/usr/local/libexec/left4me/` — privileged helper commands installed
|
- `/usr/local/libexec/left4me/` — privileged helper commands, symlinked
|
||||||
from `scripts/libexec/`.
|
from `deploy/scripts/libexec/`.
|
||||||
- `/usr/local/sbin/left4me` — admin CLI wrapper installed from
|
- `/usr/local/sbin/left4me` — admin CLI wrapper, symlinked from
|
||||||
`scripts/sbin/left4me`.
|
`deploy/scripts/sbin/left4me`.
|
||||||
|
|
||||||
## Runtime users
|
## Runtime users
|
||||||
|
|
||||||
|
|
@ -87,7 +88,7 @@ One system user does everything:
|
||||||
web app, host library, gameserver runtime, and script-overlay
|
web app, host library, gameserver runtime, and script-overlay
|
||||||
sandbox. The sandbox unit drops privileges via `systemd-run` and
|
sandbox. The sandbox unit drops privileges via `systemd-run` and
|
||||||
runs the user-authored bash inside a fully hardened transient
|
runs the user-authored bash inside a fully hardened transient
|
||||||
service (see `scripts/libexec/left4me-script-sandbox`). Same-uid
|
service (see `deploy/scripts/libexec/left4me-script-sandbox`). Same-uid
|
||||||
attack surface — sandbox escape reaching `web.env`, the SQLite DB,
|
attack surface — sandbox escape reaching `web.env`, the SQLite DB,
|
||||||
or running gameservers — is closed by that hardening profile plus
|
or running gameservers — is closed by that hardening profile plus
|
||||||
system-wide `kernel.yama.ptrace_scope=2`, rather than by a uid
|
system-wide `kernel.yama.ptrace_scope=2`, rather than by a uid
|
||||||
|
|
|
||||||
53
deploy/scripts/libexec/left4me-journalctl
Executable file
53
deploy/scripts/libexec/left4me-journalctl
Executable file
|
|
@ -0,0 +1,53 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' "usage: left4me-journalctl <server-name> --lines <n> --follow|--no-follow" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_name() {
|
||||||
|
name=$1
|
||||||
|
[ -n "$name" ] || usage
|
||||||
|
case "$name" in
|
||||||
|
.*|*..*|*/*|*\\*) usage ;;
|
||||||
|
esac
|
||||||
|
case "$name" in
|
||||||
|
*[!A-Za-z0-9_.-]*) usage ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$#" -eq 4 ] || usage
|
||||||
|
name=$1
|
||||||
|
lines_flag=$2
|
||||||
|
lines=$3
|
||||||
|
follow_flag=$4
|
||||||
|
|
||||||
|
validate_name "$name"
|
||||||
|
[ "$lines_flag" = "--lines" ] || usage
|
||||||
|
case "$lines" in
|
||||||
|
''|*[!0-9]*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
follow_arg=
|
||||||
|
case "$follow_flag" in
|
||||||
|
--follow) follow_arg=-f ;;
|
||||||
|
--no-follow) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
unit="left4me-server@${name}.service"
|
||||||
|
if [ -x /bin/journalctl ]; then
|
||||||
|
journalctl=/bin/journalctl
|
||||||
|
elif [ -x /usr/bin/journalctl ]; then
|
||||||
|
journalctl=/usr/bin/journalctl
|
||||||
|
else
|
||||||
|
printf '%s\n' 'journalctl not found at /bin/journalctl or /usr/bin/journalctl' >&2
|
||||||
|
exit 69
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$follow_arg" ]; then
|
||||||
|
exec "$journalctl" -u "$unit" -n "$lines" -o cat "$follow_arg"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$journalctl" -u "$unit" -n "$lines" -o cat
|
||||||
244
deploy/scripts/libexec/left4me-overlay
Normal file
244
deploy/scripts/libexec/left4me-overlay
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
#!/usr/bin/python3
|
||||||
|
"""Privileged overlay mount helper for left4me.
|
||||||
|
|
||||||
|
Invoked from the systemd unit's ExecStartPre / ExecStopPost via
|
||||||
|
`+/usr/bin/nsenter --mount=/proc/1/ns/mnt -- …`. The unit-level
|
||||||
|
nsenter is what makes this work: it runs the helper Python interpreter
|
||||||
|
inside PID 1's mount namespace. Without it, the `+` Exec prefix
|
||||||
|
removes the sandbox/credentials but does NOT detach from the unit's
|
||||||
|
per-service mount namespace, and the helper process itself would pin
|
||||||
|
that namespace alive — turning every umount into a multi-second EBUSY
|
||||||
|
race with the kernel's deferred namespace cleanup. With the unit-level
|
||||||
|
nsenter the helper has no such reference and umount succeeds first try.
|
||||||
|
|
||||||
|
Validates inputs strictly, then performs `mount -t overlay` /
|
||||||
|
`umount` directly — no internal nsenter, since the helper is already
|
||||||
|
running where the syscalls need to take effect.
|
||||||
|
|
||||||
|
Verbs:
|
||||||
|
mount <name> Reads ${LEFT4ME_ROOT}/instances/<name>/instance.env
|
||||||
|
for L4D2_LOWERDIRS, validates every lowerdir is
|
||||||
|
under one of installation/overlays/workshop_cache/
|
||||||
|
global_overlay_cache, then mounts the kernel
|
||||||
|
overlay at runtime/<name>/merged.
|
||||||
|
umount <name> Unmounts runtime/<name>/merged and cleans up the
|
||||||
|
kernel-overlayfs `work/work` orphan.
|
||||||
|
|
||||||
|
Set LEFT4ME_OVERLAY_PRINT_ONLY=1 to print the would-be argv (one line,
|
||||||
|
shell-quoted) and exit 0 instead of execv. Used by tests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shlex
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
|
||||||
|
DEFAULT_ROOT = "/var/lib/left4me"
|
||||||
|
LOWERDIR_ALLOWLIST = (
|
||||||
|
"installation",
|
||||||
|
"overlays",
|
||||||
|
"global_overlay_cache",
|
||||||
|
"workshop_cache",
|
||||||
|
)
|
||||||
|
MAX_LOWERDIRS = 500
|
||||||
|
MOUNT_BIN = "/bin/mount"
|
||||||
|
UMOUNT_BIN = "/bin/umount"
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str) -> None:
|
||||||
|
sys.stderr.write(f"left4me-overlay: {msg}\n")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def root() -> Path:
|
||||||
|
return Path(os.environ.get("LEFT4ME_ROOT") or DEFAULT_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_name(name: str) -> str:
|
||||||
|
if not NAME_RE.fullmatch(name):
|
||||||
|
die(f"invalid instance name: {name!r}")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def parse_lowerdirs(env_path: Path) -> list[str]:
|
||||||
|
if not env_path.is_file():
|
||||||
|
die(f"instance.env not found: {env_path}")
|
||||||
|
raw = None
|
||||||
|
for line in env_path.read_text().splitlines():
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
if key.strip() == "L4D2_LOWERDIRS":
|
||||||
|
raw = value
|
||||||
|
break
|
||||||
|
if raw is None:
|
||||||
|
die(f"L4D2_LOWERDIRS not set in {env_path}")
|
||||||
|
if raw == "":
|
||||||
|
die(f"L4D2_LOWERDIRS is empty in {env_path}")
|
||||||
|
parts = raw.split(":")
|
||||||
|
if any(p == "" for p in parts):
|
||||||
|
die(f"L4D2_LOWERDIRS contains an empty entry: {raw!r}")
|
||||||
|
if len(parts) > MAX_LOWERDIRS:
|
||||||
|
die(f"L4D2_LOWERDIRS has {len(parts)} entries (cap {MAX_LOWERDIRS})")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_under(allowed_roots: list[Path], path: Path) -> Path:
|
||||||
|
try:
|
||||||
|
canonical = path.resolve(strict=True)
|
||||||
|
except (FileNotFoundError, RuntimeError):
|
||||||
|
die(f"path does not exist or has a symlink loop: {path}")
|
||||||
|
for r in allowed_roots:
|
||||||
|
if canonical == r or r in canonical.parents:
|
||||||
|
return canonical
|
||||||
|
die(f"path is outside the permitted roots: {path} (resolved: {canonical})")
|
||||||
|
|
||||||
|
|
||||||
|
_LISTXATTR = getattr(os, "listxattr", None)
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_has_fuse_xattr(path: str) -> str | None:
|
||||||
|
if _LISTXATTR is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
attrs = _LISTXATTR(path, follow_symlinks=False)
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
for a in attrs:
|
||||||
|
if a.startswith("user.fuseoverlayfs."):
|
||||||
|
return a
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def assert_no_fuse_xattrs(upper: Path) -> None:
|
||||||
|
if not upper.exists() or _LISTXATTR is None:
|
||||||
|
return
|
||||||
|
for dirpath, dirnames, filenames in os.walk(upper):
|
||||||
|
for entry in (dirpath, *(os.path.join(dirpath, n) for n in dirnames),
|
||||||
|
*(os.path.join(dirpath, n) for n in filenames)):
|
||||||
|
tainted = _entry_has_fuse_xattr(entry)
|
||||||
|
if tainted:
|
||||||
|
die(
|
||||||
|
f"upperdir contains fuse-overlayfs xattr {tainted!r} on {entry}; "
|
||||||
|
"wipe upper/ and work/ before mounting"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _print_argv(argv: list[str]) -> None:
|
||||||
|
"""Emit one shell-quoted argv line to stdout (PRINT_ONLY helper, no exit)."""
|
||||||
|
print(" ".join(shlex.quote(a) for a in argv))
|
||||||
|
|
||||||
|
|
||||||
|
def exec_or_print(argv: list[str]) -> None:
|
||||||
|
if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1":
|
||||||
|
_print_argv(argv)
|
||||||
|
sys.exit(0)
|
||||||
|
os.execv(argv[0], argv)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_mount(name: str) -> None:
|
||||||
|
name = validate_name(name)
|
||||||
|
r = root()
|
||||||
|
runtime_name_dir = (r / "runtime" / name).resolve(strict=True)
|
||||||
|
merged_for_check = (runtime_name_dir / "merged").resolve(strict=True)
|
||||||
|
|
||||||
|
# Idempotency for unit restart cycles: if a previous start mounted
|
||||||
|
# successfully but ExecStart failed afterwards (and Restart=on-failure
|
||||||
|
# fires another cycle), the second ExecStartPre would otherwise refuse
|
||||||
|
# to mount-on-top. Short-circuit here so the second cycle just gets
|
||||||
|
# straight to ExecStart. PRINT_ONLY (test mode) bypasses this so the
|
||||||
|
# tests can exercise the full nsenter argv regardless of mount state.
|
||||||
|
if (
|
||||||
|
os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") != "1"
|
||||||
|
and os.path.ismount(merged_for_check)
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
instance_env = r / "instances" / name / "instance.env"
|
||||||
|
raw_lowerdirs = parse_lowerdirs(instance_env)
|
||||||
|
|
||||||
|
allowed_roots = [(r / sub).resolve() for sub in LOWERDIR_ALLOWLIST]
|
||||||
|
canonical_lowerdirs = [str(canonical_under(allowed_roots, Path(p))) for p in raw_lowerdirs]
|
||||||
|
|
||||||
|
upper = (runtime_name_dir / "upper").resolve(strict=True)
|
||||||
|
work = (runtime_name_dir / "work").resolve(strict=True)
|
||||||
|
merged = merged_for_check
|
||||||
|
for label, path in (("upper", upper), ("work", work), ("merged", merged)):
|
||||||
|
if path.parent != runtime_name_dir:
|
||||||
|
die(f"{label} resolved outside runtime/{name}: {path}")
|
||||||
|
|
||||||
|
assert_no_fuse_xattrs(upper)
|
||||||
|
|
||||||
|
options = f"lowerdir={':'.join(canonical_lowerdirs)},upperdir={upper},workdir={work}"
|
||||||
|
argv = [
|
||||||
|
MOUNT_BIN,
|
||||||
|
"-t", "overlay",
|
||||||
|
"overlay",
|
||||||
|
"-o", options,
|
||||||
|
str(merged),
|
||||||
|
]
|
||||||
|
exec_or_print(argv)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_umount(name: str) -> None:
|
||||||
|
name = validate_name(name)
|
||||||
|
r = root()
|
||||||
|
runtime_name_dir = (r / "runtime" / name).resolve(strict=True)
|
||||||
|
merged_path = runtime_name_dir / "merged"
|
||||||
|
work_inner = runtime_name_dir / "work" / "work"
|
||||||
|
|
||||||
|
overlay_umount_argv = [
|
||||||
|
UMOUNT_BIN,
|
||||||
|
# Resolve only if it exists; PRINT_ONLY tests always pre-create it.
|
||||||
|
str(merged_path.resolve(strict=True) if merged_path.exists() else merged_path),
|
||||||
|
]
|
||||||
|
|
||||||
|
if os.environ.get("LEFT4ME_OVERLAY_PRINT_ONLY") == "1":
|
||||||
|
_print_argv(overlay_umount_argv)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if merged_path.exists():
|
||||||
|
merged = merged_path.resolve(strict=True)
|
||||||
|
if merged.parent != runtime_name_dir:
|
||||||
|
die(f"merged resolved outside runtime/{name}: {merged}")
|
||||||
|
# Idempotency: only umount if currently a mount point. Mirrors
|
||||||
|
# cmd_mount's symmetric check; a redundant cleanup pass — or a
|
||||||
|
# call after a partial _purge_instance — must be a no-op.
|
||||||
|
#
|
||||||
|
# No retry loop here: with the helper running in PID 1's mount
|
||||||
|
# namespace (via the unit-level `nsenter --mount=/proc/1/ns/mnt`
|
||||||
|
# in ExecStopPost), it holds no reference to the unit's
|
||||||
|
# per-service mount namespace, so the cgroup-empty → namespace
|
||||||
|
# reaped → umount-clears sequence happens without any race
|
||||||
|
# window for us to ride out. EBUSY here is a real error.
|
||||||
|
if os.path.ismount(merged):
|
||||||
|
subprocess.run(overlay_umount_argv, check=True)
|
||||||
|
|
||||||
|
# Kernel-overlayfs creates work_inner during mount with root:root mode
|
||||||
|
# 0/0. After unmount it's an orphan that the unit's User= (left4me)
|
||||||
|
# cannot traverse via shutil.rmtree, so reset/delete in instances.py
|
||||||
|
# blows up with EACCES on `runtime/<name>/work/work`. The helper is
|
||||||
|
# the only code path with root that knows about this directory, so
|
||||||
|
# the cleanup belongs here. Safe to nuke — the kernel re-creates it
|
||||||
|
# on the next mount. Run unconditionally — covers both "we just
|
||||||
|
# unmounted" and "previous teardown didn't finish" cases.
|
||||||
|
if work_inner.exists():
|
||||||
|
shutil.rmtree(work_inner)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> None:
|
||||||
|
if len(argv) != 3 or argv[1] not in ("mount", "umount"):
|
||||||
|
sys.stderr.write("usage: left4me-overlay mount|umount <name>\n")
|
||||||
|
sys.exit(2)
|
||||||
|
if argv[1] == "mount":
|
||||||
|
cmd_mount(argv[2])
|
||||||
|
else:
|
||||||
|
cmd_umount(argv[2])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main(sys.argv)
|
||||||
81
deploy/scripts/libexec/left4me-script-sandbox
Executable file
81
deploy/scripts/libexec/left4me-script-sandbox
Executable file
|
|
@ -0,0 +1,81 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Privileged sandbox launcher for left4me script overlays.
|
||||||
|
#
|
||||||
|
# Invoked via sudo by the web user with two arguments:
|
||||||
|
# <overlay_id> numeric overlay id; bind-mounts /var/lib/left4me/overlays/<id>
|
||||||
|
# read-write at /overlay inside the sandbox.
|
||||||
|
# <script_path> absolute path to a bash file already written by the web app;
|
||||||
|
# bind-mounted read-only at /script.sh inside the sandbox.
|
||||||
|
#
|
||||||
|
# The script runs as a transient systemd .service with the full hardening
|
||||||
|
# surface: cgroup limits + walltime kill, NoNewPrivileges, ProtectSystem,
|
||||||
|
# ProtectHome, kernel-tunable / -module / -log protection, namespace
|
||||||
|
# restriction, address-family restriction, capability bounding (empty),
|
||||||
|
# seccomp filter (@system-service @network-io), MemoryDenyWriteExecute,
|
||||||
|
# LockPersonality, RestrictSUIDSGID. Network namespace is *not* restricted —
|
||||||
|
# scripts must reach the public internet to download workshop / l4d2center
|
||||||
|
# / cedapug content. PID namespace is shared with the host (no
|
||||||
|
# PrivatePID= directive in systemd); host PIDs are visible via /proc.
|
||||||
|
# Same-uid attack surface (the sandbox runs as left4me, so do the
|
||||||
|
# gameservers and the web app) is covered by the hardening profile plus
|
||||||
|
# system-wide kernel.yama.ptrace_scope=2 — see
|
||||||
|
# docs/superpowers/specs/2026-05-15-hardening-threat-model.md.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Self-wrap into PID 1's mount namespace before doing anything mount-related.
|
||||||
|
# The web app's left4me-web.service has PrivateTmp=true, which gives it a
|
||||||
|
# private mount namespace. When the worker invokes us via sudo, we inherit
|
||||||
|
# that namespace; our `mount --bind` would land there. systemd-run below
|
||||||
|
# spawns transient units in PID 1's namespace (where they don't see the
|
||||||
|
# private bind), so the sandbox would bind onto an empty staging dir and
|
||||||
|
# permission-deny on every write. The sentinel env var avoids an exec loop.
|
||||||
|
if [[ "${L4D2_SANDBOX_IN_PID1_MNT_NS:-}" != "1" ]]; then
|
||||||
|
exec env L4D2_SANDBOX_IN_PID1_MNT_NS=1 \
|
||||||
|
/usr/bin/nsenter --mount=/proc/1/ns/mnt -- "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
[[ $# -eq 2 ]] || { echo "usage: $0 <overlay_id> <script>" >&2; exit 64; }
|
||||||
|
|
||||||
|
OVERLAY_ID=$1
|
||||||
|
SCRIPT=$2
|
||||||
|
|
||||||
|
[[ "$OVERLAY_ID" =~ ^[0-9]+$ ]] || { echo "bad overlay id" >&2; exit 64; }
|
||||||
|
OVERLAY_DIR=/var/lib/left4me/overlays/$OVERLAY_ID
|
||||||
|
[[ -d $OVERLAY_DIR ]] || { echo "no overlay dir at $OVERLAY_DIR" >&2; exit 65; }
|
||||||
|
[[ -f $SCRIPT ]] || { echo "no script at $SCRIPT" >&2; exit 65; }
|
||||||
|
|
||||||
|
if [[ "${LEFT4ME_SCRIPT_SANDBOX_DRY_RUN:-}" == "1" ]]; then
|
||||||
|
echo "DRY RUN: overlay_id=$OVERLAY_ID script=$SCRIPT overlay_dir=$OVERLAY_DIR"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_RC=0
|
||||||
|
systemd-run --quiet --collect --wait --pipe \
|
||||||
|
--unit="left4me-script-${OVERLAY_ID}-$$" \
|
||||||
|
--slice=l4d2-build.slice \
|
||||||
|
-p OOMScoreAdjust=500 \
|
||||||
|
-p User=left4me -p Group=left4me \
|
||||||
|
-p UMask=0022 \
|
||||||
|
-p NoNewPrivileges=yes \
|
||||||
|
-p ProtectSystem=strict -p ProtectHome=yes \
|
||||||
|
-p PrivateTmp=yes -p PrivateDevices=yes -p PrivateIPC=yes \
|
||||||
|
-p ProtectKernelTunables=yes -p ProtectKernelModules=yes \
|
||||||
|
-p ProtectKernelLogs=yes -p ProtectControlGroups=yes \
|
||||||
|
-p RestrictNamespaces=yes \
|
||||||
|
-p RestrictAddressFamilies="AF_INET AF_INET6 AF_UNIX" \
|
||||||
|
-p RestrictSUIDSGID=yes -p LockPersonality=yes \
|
||||||
|
-p MemoryDenyWriteExecute=yes \
|
||||||
|
-p SystemCallFilter="@system-service @network-io" \
|
||||||
|
-p SystemCallArchitectures=native \
|
||||||
|
-p CapabilityBoundingSet= -p AmbientCapabilities= \
|
||||||
|
-p IPAddressDeny="127.0.0.0/8 ::1/128 169.254.0.0/16 fe80::/10 224.0.0.0/4 ff00::/8 10.0.0.0/8 172.16.0.0/12 192.168.0.0/16 100.64.0.0/10 fc00::/7" \
|
||||||
|
-p TemporaryFileSystem="/etc /var/lib" \
|
||||||
|
-p BindReadOnlyPaths="/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf /etc/ssl /etc/ca-certificates /etc/nsswitch.conf /etc/alternatives ${SCRIPT}:/script.sh" \
|
||||||
|
-p BindPaths="${OVERLAY_DIR}:/overlay" \
|
||||||
|
-p WorkingDirectory=/overlay \
|
||||||
|
-p Environment="HOME=/tmp PATH=/usr/bin:/usr/sbin OVERLAY=/overlay" \
|
||||||
|
-p MemoryMax=4G -p MemorySwapMax=0 -p TasksMax=512 \
|
||||||
|
-p CPUQuota=200% -p RuntimeMaxSec=3600 \
|
||||||
|
-- /bin/bash /script.sh || SCRIPT_RC=$?
|
||||||
|
|
||||||
|
exit $SCRIPT_RC
|
||||||
44
deploy/scripts/libexec/left4me-systemctl
Executable file
44
deploy/scripts/libexec/left4me-systemctl
Executable file
|
|
@ -0,0 +1,44 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
printf '%s\n' "usage: left4me-systemctl enable|disable|show <server-name>" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
validate_name() {
|
||||||
|
name=$1
|
||||||
|
[ -n "$name" ] || usage
|
||||||
|
case "$name" in
|
||||||
|
.*|*..*|*/*|*\\*) usage ;;
|
||||||
|
esac
|
||||||
|
case "$name" in
|
||||||
|
*[!A-Za-z0-9_.-]*) usage ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$#" -eq 2 ] || usage
|
||||||
|
action=$1
|
||||||
|
name=$2
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
enable|disable|show) ;;
|
||||||
|
*) usage ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
validate_name "$name"
|
||||||
|
unit="left4me-server@${name}.service"
|
||||||
|
if [ -x /bin/systemctl ]; then
|
||||||
|
systemctl=/bin/systemctl
|
||||||
|
elif [ -x /usr/bin/systemctl ]; then
|
||||||
|
systemctl=/usr/bin/systemctl
|
||||||
|
else
|
||||||
|
printf '%s\n' 'systemctl not found at /bin/systemctl or /usr/bin/systemctl' >&2
|
||||||
|
exit 69
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$action" in
|
||||||
|
enable) exec "$systemctl" enable --now "$unit" ;;
|
||||||
|
disable) exec "$systemctl" disable --now "$unit" ;;
|
||||||
|
show) exec "$systemctl" show --property=ActiveState --property=SubState "$unit" ;;
|
||||||
|
esac
|
||||||
17
deploy/scripts/sbin/left4me
Executable file
17
deploy/scripts/sbin/left4me
Executable file
|
|
@ -0,0 +1,17 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Run l4d2web flask CLI commands as the left4me user with the deploy env loaded.
|
||||||
|
# Usage: left4me <flask-subcommand> [args...]
|
||||||
|
# Examples:
|
||||||
|
# left4me create-user alice --admin
|
||||||
|
# left4me seed-script-overlays /opt/left4me/src/examples/script-overlays
|
||||||
|
# left4me routes
|
||||||
|
set -eu
|
||||||
|
exec sudo -u left4me sh -c '
|
||||||
|
set -a
|
||||||
|
. /etc/left4me/host.env
|
||||||
|
. /etc/left4me/web.env
|
||||||
|
set +a
|
||||||
|
export JOB_WORKER_ENABLED=false
|
||||||
|
export PYTHONPATH=/opt/left4me/src
|
||||||
|
exec /var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app "$@"
|
||||||
|
' sh "$@"
|
||||||
36
deploy/scripts/tests/conftest.py
Normal file
36
deploy/scripts/tests/conftest.py
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
"""Shared fixtures and path constants for `deploy/scripts/tests/`."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[3]
|
||||||
|
SCRIPTS = ROOT / "deploy" / "scripts"
|
||||||
|
LIBEXEC = SCRIPTS / "libexec"
|
||||||
|
SBIN = SCRIPTS / "sbin"
|
||||||
|
|
||||||
|
# `deploy/` is also the parent of the scripts/ tree. The sudoers example
|
||||||
|
# lives at `deploy/files/etc/sudoers.d/left4me` and is the canonical
|
||||||
|
# statement of which paths sudo grants to the `left4me` uid.
|
||||||
|
# `deploy/scripts/tests/test_sudoers_grants.py` reads it from there.
|
||||||
|
DEPLOY = ROOT / "deploy"
|
||||||
|
|
||||||
|
|
||||||
|
def fake_command(tmp_path, command_name):
|
||||||
|
"""Drop a no-op stub of `command_name` into `tmp_path`. Returns the
|
||||||
|
marker file the stub writes its args to, so tests can assert that the
|
||||||
|
helper rejected bad input before invoking the real command.
|
||||||
|
"""
|
||||||
|
marker = tmp_path / f"{command_name}.args"
|
||||||
|
command = tmp_path / command_name
|
||||||
|
command.write_text(f"#!/bin/sh\nprintf '%s\\n' \"$*\" > '{marker}'\nexit 0\n")
|
||||||
|
command.chmod(0o755)
|
||||||
|
return marker
|
||||||
|
|
||||||
|
|
||||||
|
def env_with_fake_commands(tmp_path):
|
||||||
|
"""Build an environment that prepends `tmp_path` onto PATH so helpers
|
||||||
|
find the fake commands first.
|
||||||
|
"""
|
||||||
|
env = os.environ.copy()
|
||||||
|
env["PATH"] = f"{tmp_path}{os.pathsep}{env.get('PATH', '')}"
|
||||||
|
return env
|
||||||
15
deploy/scripts/tests/test_helpers_use_fixed_paths.py
Normal file
15
deploy/scripts/tests/test_helpers_use_fixed_paths.py
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
from conftest import LIBEXEC
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEMCTL_HELPER = LIBEXEC / "left4me-systemctl"
|
||||||
|
JOURNALCTL_HELPER = LIBEXEC / "left4me-journalctl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_helpers_use_fixed_system_tool_paths_not_sudo_path():
|
||||||
|
systemctl = SYSTEMCTL_HELPER.read_text()
|
||||||
|
journalctl = JOURNALCTL_HELPER.read_text()
|
||||||
|
|
||||||
|
assert "command -v systemctl" not in systemctl
|
||||||
|
assert "command -v journalctl" not in journalctl
|
||||||
|
assert "/bin/systemctl" in systemctl or "/usr/bin/systemctl" in systemctl
|
||||||
|
assert "/bin/journalctl" in journalctl or "/usr/bin/journalctl" in journalctl
|
||||||
31
deploy/scripts/tests/test_journalctl_helper.py
Normal file
31
deploy/scripts/tests/test_journalctl_helper.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from conftest import LIBEXEC, env_with_fake_commands, fake_command
|
||||||
|
|
||||||
|
|
||||||
|
JOURNALCTL_HELPER = LIBEXEC / "left4me-journalctl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_journalctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_path):
|
||||||
|
subprocess.run(["sh", "-n", str(JOURNALCTL_HELPER)], check=True)
|
||||||
|
marker = fake_command(tmp_path, "journalctl")
|
||||||
|
|
||||||
|
for args in [
|
||||||
|
["../evil", "--lines", "25", "--no-follow"],
|
||||||
|
["alpha", "--bad", "25", "--no-follow"],
|
||||||
|
["alpha", "--lines", "not-number", "--no-follow"],
|
||||||
|
["alpha", "--lines", "25", "--bad-follow"],
|
||||||
|
["bad/name", "--lines", "25", "--no-follow"],
|
||||||
|
]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sh", str(JOURNALCTL_HELPER), *args],
|
||||||
|
env=env_with_fake_commands(tmp_path),
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert not marker.exists()
|
||||||
|
|
||||||
|
script = JOURNALCTL_HELPER.read_text()
|
||||||
|
assert 'unit="left4me-server@${name}.service"' in script
|
||||||
|
assert 'exec "$journalctl" -u "$unit" -n "$lines" -o cat "$follow_arg"' in script
|
||||||
|
assert 'exec "$journalctl" -u "$unit" -n "$lines" -o cat' in script
|
||||||
32
deploy/scripts/tests/test_overlay.py
Normal file
32
deploy/scripts/tests/test_overlay.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
from conftest import LIBEXEC
|
||||||
|
|
||||||
|
|
||||||
|
OVERLAY_HELPER = LIBEXEC / "left4me-overlay"
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_helper_is_python_with_strict_validation():
|
||||||
|
text = OVERLAY_HELPER.read_text()
|
||||||
|
assert text.startswith("#!/usr/bin/python3")
|
||||||
|
# Validation surface
|
||||||
|
assert "NAME_RE = re.compile" in text
|
||||||
|
assert "LOWERDIR_ALLOWLIST" in text
|
||||||
|
assert "user.fuseoverlayfs." in text
|
||||||
|
assert "MAX_LOWERDIRS = 500" in text
|
||||||
|
# Mounts via PID 1's mount namespace
|
||||||
|
assert "/proc/1/ns/mnt" in text
|
||||||
|
assert "nsenter" in text
|
||||||
|
# Verbs are mount and umount (not unmount)
|
||||||
|
assert '"mount"' in text and '"umount"' in text
|
||||||
|
assert '"unmount"' not in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlay_helper_mount_is_idempotent_when_already_mounted():
|
||||||
|
"""ExecStartPre runs on every Restart=on-failure cycle. If a previous
|
||||||
|
start mounted successfully but ExecStart failed afterwards, the next
|
||||||
|
ExecStartPre would re-mount on top -- which fails. The helper must
|
||||||
|
short-circuit when merged is already a mount point.
|
||||||
|
"""
|
||||||
|
text = OVERLAY_HELPER.read_text()
|
||||||
|
# Two ismount checks now: one in cmd_mount (skip if mounted),
|
||||||
|
# one in cmd_umount (skip if not mounted).
|
||||||
|
assert text.count("os.path.ismount") >= 2
|
||||||
146
deploy/scripts/tests/test_script_sandbox.py
Normal file
146
deploy/scripts/tests/test_script_sandbox.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from conftest import LIBEXEC
|
||||||
|
|
||||||
|
|
||||||
|
SCRIPT_SANDBOX_HELPER = LIBEXEC / "left4me-script-sandbox"
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_sandbox_helper_present():
|
||||||
|
assert SCRIPT_SANDBOX_HELPER.is_file()
|
||||||
|
assert SCRIPT_SANDBOX_HELPER.read_text().startswith("#!/bin/bash")
|
||||||
|
mode = SCRIPT_SANDBOX_HELPER.stat().st_mode & 0o777
|
||||||
|
assert mode == 0o755, f"expected 0755, got {oct(mode)}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_sandbox_helper_passes_shell_syntax_check():
|
||||||
|
subprocess.run(["bash", "-n", str(SCRIPT_SANDBOX_HELPER)], check=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_sandbox_helper_invokes_systemd_run_with_hardening():
|
||||||
|
text = SCRIPT_SANDBOX_HELPER.read_text()
|
||||||
|
|
||||||
|
# systemd-run service mode (no --scope), with synchronous I/O to caller.
|
||||||
|
assert "systemd-run" in text
|
||||||
|
assert "--scope" not in text, "v2 uses transient service units, not scopes"
|
||||||
|
assert "--pipe" in text
|
||||||
|
assert "--wait" in text
|
||||||
|
assert "--collect" in text
|
||||||
|
assert "--unit=" in text
|
||||||
|
|
||||||
|
# No bwrap.
|
||||||
|
assert "bwrap" not in text
|
||||||
|
assert "bubblewrap" not in text
|
||||||
|
|
||||||
|
# UID drop via systemd directives.
|
||||||
|
assert "User=left4me" in text
|
||||||
|
assert "Group=left4me" in text
|
||||||
|
|
||||||
|
# Cgroup limits unchanged from v1.
|
||||||
|
assert "MemoryMax=4G" in text
|
||||||
|
assert "MemorySwapMax=0" in text
|
||||||
|
assert "TasksMax=512" in text
|
||||||
|
assert "CPUQuota=200%" in text
|
||||||
|
assert "RuntimeMaxSec=3600" in text
|
||||||
|
|
||||||
|
# Hardening directives that v1 (scope mode) couldn't carry.
|
||||||
|
assert "NoNewPrivileges=yes" in text
|
||||||
|
assert "ProtectSystem=strict" in text
|
||||||
|
assert "ProtectHome=yes" in text
|
||||||
|
assert "PrivateTmp=yes" in text
|
||||||
|
assert "PrivateDevices=yes" in text
|
||||||
|
assert "PrivateIPC=yes" in text
|
||||||
|
assert "ProtectKernelTunables=yes" in text
|
||||||
|
assert "ProtectKernelModules=yes" in text
|
||||||
|
assert "ProtectKernelLogs=yes" in text
|
||||||
|
assert "ProtectControlGroups=yes" in text
|
||||||
|
assert "RestrictNamespaces=yes" in text
|
||||||
|
assert "RestrictSUIDSGID=yes" in text
|
||||||
|
assert "LockPersonality=yes" in text
|
||||||
|
assert "MemoryDenyWriteExecute=yes" in text
|
||||||
|
assert "SystemCallFilter=" in text
|
||||||
|
assert "@system-service" in text
|
||||||
|
assert "@network-io" in text
|
||||||
|
assert "CapabilityBoundingSet=" in text
|
||||||
|
assert "AmbientCapabilities=" in text
|
||||||
|
assert 'RestrictAddressFamilies="AF_INET AF_INET6 AF_UNIX"' in text
|
||||||
|
|
||||||
|
# Network namespace stays shared with host.
|
||||||
|
assert "PrivateNetwork=" not in text
|
||||||
|
|
||||||
|
# Mount setup: /etc and /var/lib masked with tmpfs; selective binds back.
|
||||||
|
assert 'TemporaryFileSystem="/etc /var/lib"' in text
|
||||||
|
assert "BindReadOnlyPaths=" in text
|
||||||
|
# The resolv.conf bind points at the sandbox-only file (not the host's
|
||||||
|
# /etc/resolv.conf, which typically references a private-IP DNS server
|
||||||
|
# that IPAddressDeny= blocks).
|
||||||
|
assert "/etc/left4me/sandbox-resolv.conf:/etc/resolv.conf" in text
|
||||||
|
assert "/etc/ssl" in text
|
||||||
|
assert "/etc/ca-certificates" in text
|
||||||
|
assert "/etc/nsswitch.conf" in text
|
||||||
|
assert "/etc/alternatives" in text
|
||||||
|
assert "${SCRIPT}:/script.sh" in text
|
||||||
|
assert 'BindPaths="${OVERLAY_DIR}:/overlay"' in text
|
||||||
|
|
||||||
|
# IP egress filter: allow public, deny localhost / RFC1918 / link-local /
|
||||||
|
# multicast / CGNAT / ULA. systemd's "more specific rule wins" semantics
|
||||||
|
# mean public IPs hit the allow and listed ranges hit the deny.
|
||||||
|
# IPAddressDeny alone — no IPAddressAllow=any. Empirically, having both
|
||||||
|
# set causes the allow to win on this systemd/kernel combo regardless of
|
||||||
|
# the documented "more specific rule wins" behaviour. With only Deny,
|
||||||
|
# the kernel's default "allow all" applies to non-listed addresses.
|
||||||
|
assert "IPAddressDeny=" in text
|
||||||
|
assert "IPAddressAllow=any" not in text
|
||||||
|
# Explicit CIDRs — systemd-run's -p parser doesn't accept the
|
||||||
|
# `localhost` / `link-local` / `multicast` shorthand keywords that
|
||||||
|
# work in unit files (only the full strings parse).
|
||||||
|
for token in (
|
||||||
|
"127.0.0.0/8",
|
||||||
|
"::1/128",
|
||||||
|
"169.254.0.0/16",
|
||||||
|
"fe80::/10",
|
||||||
|
"224.0.0.0/4",
|
||||||
|
"ff00::/8",
|
||||||
|
"10.0.0.0/8",
|
||||||
|
"172.16.0.0/12",
|
||||||
|
"192.168.0.0/16",
|
||||||
|
"100.64.0.0/10",
|
||||||
|
"fc00::/7",
|
||||||
|
):
|
||||||
|
assert token in text, f"missing {token!r} in IPAddressDeny set"
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_sandbox_in_build_slice_with_oom_adjust():
|
||||||
|
text = SCRIPT_SANDBOX_HELPER.read_text()
|
||||||
|
|
||||||
|
# Put the transient unit in the low-weight build slice so it yields to
|
||||||
|
# game-server instances under CPU/IO contention.
|
||||||
|
assert "--slice=l4d2-build.slice" in text
|
||||||
|
|
||||||
|
# Sandbox dies first if the host hits memory pressure; servers
|
||||||
|
# (OOMScoreAdjust=-200) survive.
|
||||||
|
assert "-p OOMScoreAdjust=500" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_sandbox_helper_validates_overlay_id():
|
||||||
|
text = SCRIPT_SANDBOX_HELPER.read_text()
|
||||||
|
# Numeric-only overlay id
|
||||||
|
assert '[[ "$OVERLAY_ID" =~ ^[0-9]+$ ]]' in text
|
||||||
|
# Overlay dir must exist
|
||||||
|
assert "/var/lib/left4me/overlays/" in text
|
||||||
|
assert "[[ -d $OVERLAY_DIR ]]" in text
|
||||||
|
# Script path must exist
|
||||||
|
assert "[[ -f $SCRIPT ]]" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_script_sandbox_helper_dry_run_mode(tmp_path):
|
||||||
|
overlay_root = tmp_path / "var/lib/left4me/overlays/42"
|
||||||
|
overlay_root.mkdir(parents=True)
|
||||||
|
fake_script = tmp_path / "fake.sh"
|
||||||
|
fake_script.write_text("echo hi")
|
||||||
|
|
||||||
|
helper_text = SCRIPT_SANDBOX_HELPER.read_text()
|
||||||
|
# We can't actually exec this without root; just verify the dry-run
|
||||||
|
# guard short-circuits before systemd-run runs.
|
||||||
|
assert 'LEFT4ME_SCRIPT_SANDBOX_DRY_RUN' in helper_text
|
||||||
|
assert 'exit 0' in helper_text
|
||||||
37
deploy/scripts/tests/test_sudoers_grants.py
Normal file
37
deploy/scripts/tests/test_sudoers_grants.py
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
"""Audit the script→sudoers contract.
|
||||||
|
|
||||||
|
The sudoers file in `deploy/files/etc/sudoers.d/left4me` is a reference
|
||||||
|
example; ckn-bw ships its own verbatim copy under
|
||||||
|
`bundles/left4me/files/etc/sudoers.d/left4me`. The two are expected to
|
||||||
|
match. This test lives under `deploy/scripts/tests/` because the contract being
|
||||||
|
audited is about *scripts* (which paths the `left4me` uid can sudo into).
|
||||||
|
"""
|
||||||
|
from conftest import DEPLOY
|
||||||
|
|
||||||
|
|
||||||
|
SUDOERS = DEPLOY / "files/etc/sudoers.d/left4me"
|
||||||
|
|
||||||
|
|
||||||
|
def test_sudoers_allows_only_left4me_helpers_not_raw_system_tools():
|
||||||
|
sudoers = SUDOERS.read_text()
|
||||||
|
|
||||||
|
assert (
|
||||||
|
"left4me ALL=(root) NOPASSWD: "
|
||||||
|
"/usr/local/libexec/left4me/left4me-systemctl *"
|
||||||
|
) in sudoers
|
||||||
|
assert (
|
||||||
|
"left4me ALL=(root) NOPASSWD: "
|
||||||
|
"/usr/local/libexec/left4me/left4me-journalctl *"
|
||||||
|
) in sudoers
|
||||||
|
assert "/usr/local/libexec/left4me/left4me-overlay mount *" in sudoers
|
||||||
|
assert "/usr/local/libexec/left4me/left4me-overlay umount *" in sudoers
|
||||||
|
assert (
|
||||||
|
"left4me ALL=(root) NOPASSWD: "
|
||||||
|
"/usr/local/libexec/left4me/left4me-script-sandbox"
|
||||||
|
) in sudoers
|
||||||
|
assert "/bin/systemctl" not in sudoers
|
||||||
|
assert "/usr/bin/systemctl" not in sudoers
|
||||||
|
assert "/bin/journalctl" not in sudoers
|
||||||
|
assert "/usr/bin/journalctl" not in sudoers
|
||||||
|
assert "/bin/mount" not in sudoers
|
||||||
|
assert "/bin/umount" not in sudoers
|
||||||
39
deploy/scripts/tests/test_systemctl_helper.py
Normal file
39
deploy/scripts/tests/test_systemctl_helper.py
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
from conftest import LIBEXEC, env_with_fake_commands, fake_command
|
||||||
|
|
||||||
|
|
||||||
|
SYSTEMCTL_HELPER = LIBEXEC / "left4me-systemctl"
|
||||||
|
|
||||||
|
|
||||||
|
def test_systemctl_helper_passes_shell_syntax_check_and_rejects_bad_args(tmp_path):
|
||||||
|
subprocess.run(["sh", "-n", str(SYSTEMCTL_HELPER)], check=True)
|
||||||
|
marker = fake_command(tmp_path, "systemctl")
|
||||||
|
|
||||||
|
for args in [
|
||||||
|
["bad/action", "alpha"],
|
||||||
|
# `start` and `stop` are no longer accepted verbs — the lifecycle now
|
||||||
|
# uses `enable`/`disable` for reboot survival via WantedBy= symlinks.
|
||||||
|
["start", "alpha"],
|
||||||
|
["stop", "alpha"],
|
||||||
|
["enable", ""],
|
||||||
|
["enable", ".hidden"],
|
||||||
|
["enable", "bad..name"],
|
||||||
|
["enable", "bad/name"],
|
||||||
|
["enable", "bad\\name"],
|
||||||
|
["enable", "bad name"],
|
||||||
|
]:
|
||||||
|
result = subprocess.run(
|
||||||
|
["sh", str(SYSTEMCTL_HELPER), *args],
|
||||||
|
env=env_with_fake_commands(tmp_path),
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert result.returncode != 0
|
||||||
|
assert not marker.exists()
|
||||||
|
|
||||||
|
script = SYSTEMCTL_HELPER.read_text()
|
||||||
|
assert 'unit="left4me-server@${name}.service"' in script
|
||||||
|
assert 'enable) exec "$systemctl" enable --now "$unit"' in script
|
||||||
|
assert 'disable) exec "$systemctl" disable --now "$unit"' in script
|
||||||
|
assert "--property=ActiveState" in script
|
||||||
|
assert "--property=SubState" in script
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
> The v1 design below specifies `bubblewrap` + `systemd-run --scope` as the
|
> The v1 design below specifies `bubblewrap` + `systemd-run --scope` as the
|
||||||
> sandbox engine. The v2 design (approved 2026-05-08, same day) replaced that
|
> sandbox engine. The v2 design (approved 2026-05-08, same day) replaced that
|
||||||
> with `systemd-run` in service-unit mode and dropped `bubblewrap` entirely.
|
> with `systemd-run` in service-unit mode and dropped `bubblewrap` entirely.
|
||||||
> The current implementation in `scripts/libexec/left4me-script-sandbox`
|
> The current implementation in `deploy/scripts/libexec/left4me-script-sandbox`
|
||||||
> follows v2; this v1 design is preserved for archaeology. The rest of the
|
> follows v2; this v1 design is preserved for archaeology. The rest of the
|
||||||
> design (overlay-type unification, resource caps, helper auth model, etc.)
|
> design (overlay-type unification, resource caps, helper auth model, etc.)
|
||||||
> still applies — only the sandbox-engine choice changed.
|
> still applies — only the sandbox-engine choice changed.
|
||||||
|
|
|
||||||
|
|
@ -204,23 +204,11 @@ all left4me sysctl tuning lives in the one drop-in.
|
||||||
|
|
||||||
### Privileged scripts
|
### Privileged scripts
|
||||||
|
|
||||||
Today: `scripts/libexec/`, `scripts/sbin/` at left4me repo root.
|
Done (Task 4): `deploy/scripts/libexec/`, `deploy/scripts/sbin/` under
|
||||||
`install_left4me_scripts` action copies them to
|
`deploy/` for layout consistency.
|
||||||
`/usr/local/libexec/left4me/` and `/usr/local/sbin/` as root on every
|
`install_left4me_scripts` copy-action replaced by target-side symlinks
|
||||||
git_deploy update.
|
from `/usr/local/libexec/left4me/` and `/usr/local/sbin/` into the
|
||||||
|
checkout at `/opt/left4me/src/deploy/scripts/{libexec,sbin}/`.
|
||||||
After:
|
|
||||||
- Move `left4me/scripts/` → `left4me/deploy/scripts/`. Update the few
|
|
||||||
references that point at the old path (search for
|
|
||||||
`/opt/left4me/src/scripts/` and `scripts/{libexec,sbin}/` in both
|
|
||||||
repos).
|
|
||||||
- Replace `install_left4me_scripts` action with one bw `symlinks{}`
|
|
||||||
item per script. Trigger semantics: each symlink declares
|
|
||||||
`triggers: ['action:systemd_daemon_reload']` only if the script is
|
|
||||||
referenced by a systemd unit (e.g. `left4me-overlay` is in
|
|
||||||
`ExecStartPre=` of `left4me-server@.service`; daemon-reload not
|
|
||||||
needed for script changes since systemd reads the script content at
|
|
||||||
exec time, not at unit-load time).
|
|
||||||
|
|
||||||
Sudo follows symlinks. With `/opt/left4me/src` root-owned, the
|
Sudo follows symlinks. With `/opt/left4me/src` root-owned, the
|
||||||
symlink target is root-owned, and sudo's `Cmnd_Alias` path matching
|
symlink target is root-owned, and sudo's `Cmnd_Alias` path matching
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,7 @@ section.
|
||||||
### Sudo / setuid
|
### Sudo / setuid
|
||||||
|
|
||||||
Sudoers grants narrow what a unit's uid can do as root. For us, the
|
Sudoers grants narrow what a unit's uid can do as root. For us, the
|
||||||
helpers (`scripts/libexec/left4me-*`) already validate inputs tightly
|
helpers (`deploy/scripts/libexec/left4me-*`) already validate inputs tightly
|
||||||
(verified in audit). Two design options for the future:
|
(verified in audit). Two design options for the future:
|
||||||
|
|
||||||
- **Keep sudo path**, narrow the grants (per-uid via 3-user split, or
|
- **Keep sudo path**, narrow the grants (per-uid via 3-user split, or
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ relocation separately.
|
||||||
- `deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service`
|
- `deploy/files/usr/local/lib/systemd/system/left4me-workshop-refresh.service`
|
||||||
— reference unit: `WorkingDirectory=/opt/left4me/src` (was `/opt/left4me`),
|
— reference unit: `WorkingDirectory=/opt/left4me/src` (was `/opt/left4me`),
|
||||||
`PATH=` + `ExecStart=` use `/var/lib/left4me/.venv`
|
`PATH=` + `ExecStart=` use `/var/lib/left4me/.venv`
|
||||||
- `scripts/sbin/left4me` wrapper — flask path
|
- `deploy/scripts/sbin/left4me` wrapper — flask path
|
||||||
- `deploy/tests/test_example_units.py` — PATH + ExecStart assertions
|
- `deploy/tests/test_example_units.py` — PATH + ExecStart assertions
|
||||||
updated; the assertion previously read `"Environment=PATH=..."` which
|
updated; the assertion previously read `"Environment=PATH=..."` which
|
||||||
was already broken (the unit has `Environment=HOME=... PATH=...` on
|
was already broken (the unit has `Environment=HOME=... PATH=...` on
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ in plan-mode this session; not executed.
|
||||||
|
|
||||||
Scope (10 steps; see plan for detail):
|
Scope (10 steps; see plan for detail):
|
||||||
|
|
||||||
1. Strip the idmap block from `scripts/libexec/left4me-script-sandbox`
|
1. Strip the idmap block from `deploy/scripts/libexec/left4me-script-sandbox`
|
||||||
(~30 lines deleted), change `User=l4d2-sandbox` → `User=left4me`,
|
(~30 lines deleted), change `User=l4d2-sandbox` → `User=left4me`,
|
||||||
`BindPaths="${STAGING}:/overlay"` → `BindPaths="${OVERLAY_DIR}:/overlay"`.
|
`BindPaths="${STAGING}:/overlay"` → `BindPaths="${OVERLAY_DIR}:/overlay"`.
|
||||||
Keep the `nsenter` self-wrap (it's about namespace escape, not
|
Keep the `nsenter` self-wrap (it's about namespace escape, not
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import pytest
|
||||||
|
|
||||||
HELPER_SOURCE = (
|
HELPER_SOURCE = (
|
||||||
Path(__file__).resolve().parents[2]
|
Path(__file__).resolve().parents[2]
|
||||||
|
/ "deploy"
|
||||||
/ "scripts"
|
/ "scripts"
|
||||||
/ "libexec"
|
/ "libexec"
|
||||||
/ "left4me-overlay"
|
/ "left4me-overlay"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue