Sudoers drop-in lives in left4me/deploy/files/etc/sudoers.d/left4me (single source of truth). Deleted the verbatim mirror in this bundle's files/ tree. Added an idempotent chmod action so the in-checkout file is 0440 root:root — required for sudo to accept it through the symlink. Syntax check on the source file is now a left4me-side pytest (deploy/tests/test_sudoers.py) running visudo -cf. Part of 2026-05-15-deployment-responsibility-design.md migration step 3.
397 lines
16 KiB
Python
397 lines
16 KiB
Python
# Items for the left4me bundle.
|
|
# Systemd units come from metadata via bundles/systemd/ — there are no
|
|
# .service or .slice files in this bundle's files/ tree. Cpuset drop-ins
|
|
# for system.slice / user.slice are likewise emitted via systemd/units
|
|
# in metadata.py (key: '<parent>.d/<basename>.conf').
|
|
|
|
directories = {
|
|
'/opt/left4me': {
|
|
# Deploy-artifact root. Only /opt/left4me/src lives here; runtime
|
|
# state (.venv, steamcmd) lives under /var/lib/left4me/. Root-owned
|
|
# so left4me cannot drop new files alongside src/ (e.g. an attacker
|
|
# with web-compromise can't plant a 'scripts.d/' loaded by future
|
|
# deploy logic).
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'mode': '0755',
|
|
},
|
|
'/opt/left4me/src': {
|
|
# Source checkout. Root-owned because the production install model
|
|
# is non-editable: pip_install copies the source to a left4me-owned
|
|
# tempdir before building, so the source tree on disk is never
|
|
# mutated at runtime and left4me only needs read access (which
|
|
# world-readable bits provide). Keeps left4me from being able to
|
|
# rewrite its own future hardening drop-ins / unit files under
|
|
# /opt/left4me/src/deploy/ (target-side symlink model in the
|
|
# deployment-responsibility reshape).
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
},
|
|
'/etc/left4me': {
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'mode': '0755',
|
|
},
|
|
'/var/lib/left4me': {
|
|
# left4me's home dir — useradd creates with 0700; loosen to 0755 so
|
|
# the systemd-imposed FS view for transient script-sandbox units
|
|
# (running as left4me with TemporaryFileSystem=/var/lib + selective
|
|
# binds) can traverse on its way to the overlay bind targets.
|
|
'owner': 'left4me',
|
|
'group': 'left4me',
|
|
'mode': '0755',
|
|
},
|
|
'/var/lib/left4me/installation': {'owner': 'left4me', 'group': 'left4me'},
|
|
'/var/lib/left4me/overlays': {'owner': 'left4me', 'group': 'left4me'},
|
|
'/var/lib/left4me/instances': {'owner': 'left4me', 'group': 'left4me'},
|
|
'/var/lib/left4me/runtime': {'owner': 'left4me', 'group': 'left4me'},
|
|
'/var/lib/left4me/workshop_cache': {'owner': 'left4me', 'group': 'left4me'},
|
|
'/var/lib/left4me/tmp': {'owner': 'left4me', 'group': 'left4me'},
|
|
'/var/lib/left4me/steam': {'owner': 'left4me', 'group': 'left4me'},
|
|
# Note: the venv (/var/lib/left4me/.venv) is created by the
|
|
# left4me_create_venv action; declaring it here too would race with
|
|
# `python -m venv` which expects to create the directory itself.
|
|
'/usr/local/libexec/left4me': {
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'mode': '0755',
|
|
},
|
|
'/etc/systemd/system/left4me-web.service.d': {
|
|
'owner': 'root', 'group': 'root', 'mode': '0755',
|
|
},
|
|
'/etc/systemd/system/left4me-server@.service.d': {
|
|
'owner': 'root', 'group': 'root', 'mode': '0755',
|
|
},
|
|
}
|
|
|
|
groups = {
|
|
'left4me': {'gid': 980},
|
|
}
|
|
|
|
users = {
|
|
'left4me': {
|
|
'uid': 980,
|
|
'gid': 980,
|
|
'home': '/var/lib/left4me',
|
|
'shell': '/usr/sbin/nologin',
|
|
},
|
|
}
|
|
# UID/GID pinned in the system-package range (100-999, per Debian
|
|
# policy) so file ownership is deterministic across rebuilds and
|
|
# backup restores. 980 is unused elsewhere in this repo.
|
|
# (981 — formerly l4d2-sandbox — was collapsed into 980 on 2026-05-15;
|
|
# see left4me/docs/superpowers/plans/2026-05-15-uid-collapse.md.)
|
|
|
|
# Privileged helpers are installed by the `install_left4me_scripts`
|
|
# action (below) directly from the left4me git checkout at
|
|
# `/opt/left4me/src/scripts/{libexec,sbin}/` — no verbatim copy in this
|
|
# bundle's files/ tree. Sudoers (further below) lists the specific
|
|
# paths that left4me may invoke as root NOPASSWD.
|
|
|
|
files = {
|
|
'/etc/left4me/sandbox-resolv.conf': {
|
|
'source': 'etc/left4me/sandbox-resolv.conf',
|
|
'mode': '0644',
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
},
|
|
'/etc/left4me/host.env': {
|
|
'source': 'etc/left4me/host.env.mako',
|
|
'content_type': 'mako',
|
|
'mode': '0640',
|
|
'owner': 'root',
|
|
# group=left4me so the alembic + seed-overlays actions (which run as
|
|
# `sudo -u left4me sh -c '. /etc/left4me/host.env'`) can source it.
|
|
# Same pattern as web.env below.
|
|
'group': 'left4me',
|
|
'needs': [
|
|
'group:left4me',
|
|
],
|
|
},
|
|
'/etc/left4me/web.env': {
|
|
'source': 'etc/left4me/web.env.mako',
|
|
'content_type': 'mako',
|
|
'mode': '0640',
|
|
'owner': 'root',
|
|
'group': 'left4me',
|
|
'needs': [
|
|
'group:left4me',
|
|
],
|
|
},
|
|
}
|
|
|
|
symlinks = {
|
|
'/etc/sysctl.d/99-left4me.conf': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/sysctl.d/99-left4me.conf',
|
|
'owner': 'root',
|
|
'group': 'root',
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_sysctl_reload',
|
|
],
|
|
},
|
|
'/etc/systemd/system/left4me-web.service.d/10-hardening.conf': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'directory:/etc/systemd/system/left4me-web.service.d',
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_daemon_reload',
|
|
],
|
|
},
|
|
'/etc/systemd/system/left4me-server@.service.d/10-hardening.conf': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'directory:/etc/systemd/system/left4me-server@.service.d',
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_daemon_reload',
|
|
],
|
|
},
|
|
'/etc/sudoers.d/left4me': {
|
|
'target': '/opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
|
|
'owner': 'root', 'group': 'root',
|
|
'needs': [
|
|
'action:left4me_chmod_sudoers',
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
# sudo follows symlinks; with the target file at root:root 0440
|
|
# in a root-owned source tree, sudo accepts it. No daemon-reload
|
|
# equivalent — sudo re-reads /etc/sudoers.d/ on each invocation.
|
|
},
|
|
}
|
|
|
|
actions = {
|
|
'left4me_sysctl_reload': {
|
|
'command': 'sysctl --system >/dev/null',
|
|
'triggered': True,
|
|
},
|
|
'left4me_daemon_reload': {
|
|
'command': 'systemctl daemon-reload',
|
|
'triggered': True,
|
|
'cascade_skip': False,
|
|
},
|
|
'left4me_verify_hardening_dropins_loaded': {
|
|
# Post-apply self-test: confirm systemd actually picked up the
|
|
# hardening drop-ins we shipped via symlink. Catches the failure
|
|
# mode where the symlink lands but daemon-reload didn't take or
|
|
# someone manually unlinked the drop-in. For the gameserver template
|
|
# we query an imaginary instance — systemd resolves drop-in paths
|
|
# for `name@instance.service` against the template (`name@.service.d/`),
|
|
# so the instance need not exist or ever have run.
|
|
'command': (
|
|
'systemctl show left4me-server@verify.service -p DropInPaths --value '
|
|
'| tr " " "\\n" '
|
|
'| grep -qx /etc/systemd/system/left4me-server@.service.d/10-hardening.conf '
|
|
'&& '
|
|
'systemctl show left4me-web.service -p DropInPaths --value '
|
|
'| tr " " "\\n" '
|
|
'| grep -qx /etc/systemd/system/left4me-web.service.d/10-hardening.conf'
|
|
),
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'action:left4me_daemon_reload',
|
|
'symlink:/etc/systemd/system/left4me-web.service.d/10-hardening.conf',
|
|
'symlink:/etc/systemd/system/left4me-server@.service.d/10-hardening.conf',
|
|
],
|
|
},
|
|
'left4me_chmod_sudoers': {
|
|
# sudo refuses sudoers.d entries that aren't 0440 (or 0400) root:root.
|
|
# git_deploy extracts as root with the in-repo file mode; this action
|
|
# is belt-and-braces in case the repo mode drifts. Idempotent via
|
|
# the `unless` gate.
|
|
'command': 'chmod 0440 /opt/left4me/src/deploy/files/etc/sudoers.d/left4me',
|
|
'unless': 'test "$(stat -c %a /opt/left4me/src/deploy/files/etc/sudoers.d/left4me)" = "440"',
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
],
|
|
},
|
|
'left4me_dpkg_add_i386_arch': {
|
|
# steamcmd is 32-bit and pulls libc6:i386 + lib32z1 from the i386 arch.
|
|
# apt-get update is part of this action because newly-added foreign
|
|
# archs need a fresh package list before any :i386 package resolves.
|
|
'command': 'dpkg --add-architecture i386 && apt-get update',
|
|
'unless': 'dpkg --print-foreign-architectures | grep -qx i386',
|
|
'cascade_skip': False,
|
|
},
|
|
'left4me_install_steamcmd': {
|
|
# Steam's tarball is rolling with no published checksum, so we can't
|
|
# use download: (which requires a hash). Guard with a presence check
|
|
# on steamcmd.sh — steamcmd self-updates at runtime, so chasing the
|
|
# tarball version from bw isn't useful.
|
|
'command': (
|
|
'sudo -u left4me sh -c "'
|
|
'cd /var/lib/left4me/steam && '
|
|
'curl -fsSL https://media.steampowered.com/installer/steamcmd_linux.tar.gz | '
|
|
'tar -xz'
|
|
'"'
|
|
),
|
|
'unless': 'test -x /var/lib/left4me/steam/steamcmd.sh',
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'directory:/var/lib/left4me/steam',
|
|
'pkg_apt:curl',
|
|
'pkg_apt:libc6_i386', # bw pkg_apt convention: _ → :
|
|
'pkg_apt:lib32z1',
|
|
'user:left4me',
|
|
],
|
|
},
|
|
}
|
|
|
|
# steamcmd is invoked by absolute path (LEFT4ME_STEAMCMD in host.env),
|
|
# not via PATH lookup — see l4d2host/cli.py:install. We don't need to put
|
|
# anything in /usr/local/bin for it.
|
|
|
|
git_deploy = {
|
|
'/opt/left4me/src': {
|
|
'repo': node.metadata.get('left4me/git_url'),
|
|
'rev': node.metadata.get('left4me/git_branch'),
|
|
'triggers': [
|
|
# Rebuild + reinstall the packages whenever the checkout
|
|
# changes. pip_install does its own out-of-tree build (copies
|
|
# source to a left4me-owned tempdir before invoking pip), so
|
|
# the source tree itself stays root-owned and untouched.
|
|
# pip_install cascades into alembic_upgrade → web restart.
|
|
'action:left4me_pip_install',
|
|
# alembic upgrade head is idempotent — keeping it as a direct
|
|
# trigger off git_deploy is belt-and-braces in case the
|
|
# pip_install cascade is ever short-circuited.
|
|
'action:left4me_alembic_upgrade',
|
|
# Privileged-helper scripts: reinstall from the new checkout
|
|
# into /usr/local/{libexec,sbin}/ as root-owned. No-op when
|
|
# the checkout didn't actually change (action is triggered).
|
|
'action:install_left4me_scripts',
|
|
# Reload systemd unit definitions whenever the checkout changes;
|
|
# handles updates to hardening drop-in content without requiring
|
|
# a symlink change.
|
|
'action:left4me_daemon_reload',
|
|
],
|
|
},
|
|
}
|
|
|
|
actions['install_left4me_scripts'] = {
|
|
# Copy privileged scripts from the deployed left4me checkout into
|
|
# /usr/local/{libexec,sbin}/ as root:root 0755. Source of truth for
|
|
# the file content is left4me's scripts/{libexec,sbin}/ tree (these
|
|
# are application code, not deploy artifacts; left4me's deploy/ is
|
|
# reference material only). The two install globs map source dirs
|
|
# 1:1 to deploy targets. Triggered only on git_deploy updates so a
|
|
# no-op apply doesn't re-copy.
|
|
'command': (
|
|
'install -m 0755 -o root -g root -t /usr/local/libexec/left4me/ '
|
|
'/opt/left4me/src/scripts/libexec/*; '
|
|
'install -m 0755 -o root -g root -t /usr/local/sbin/ '
|
|
'/opt/left4me/src/scripts/sbin/*'
|
|
),
|
|
'triggered': True,
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
'directory:/usr/local/libexec/left4me',
|
|
],
|
|
}
|
|
|
|
actions['left4me_create_venv'] = {
|
|
'command': 'sudo -u left4me /usr/bin/python3 -m venv /var/lib/left4me/.venv',
|
|
'unless': 'test -x /var/lib/left4me/.venv/bin/python',
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'directory:/var/lib/left4me',
|
|
'pkg_apt:python3-venv',
|
|
'user:left4me',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_pip_upgrade',
|
|
],
|
|
}
|
|
|
|
actions['left4me_pip_upgrade'] = {
|
|
'command': 'sudo -u left4me /var/lib/left4me/.venv/bin/python -m pip install --upgrade pip',
|
|
'triggered': True,
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'pkg_apt:python3-pip',
|
|
],
|
|
# No triggers — pip_install is driven by git_deploy on actual code
|
|
# updates, not by venv setup. Keeps pip_upgrade scoped to exactly
|
|
# its purpose.
|
|
}
|
|
|
|
actions['left4me_pip_install'] = {
|
|
# Non-editable install of l4d2host + l4d2web into the venv. We have
|
|
# to copy the source to a left4me-writable tempdir first because
|
|
# setuptools.build_meta writes <pkg>.egg-info/ into the source dir
|
|
# during `get_requires_for_build_wheel`, and the source tree is
|
|
# root-owned. cp -r is fast (small tree, world-readable), the build
|
|
# itself happens in $tmpdir, and pip installs the resulting wheel
|
|
# into /var/lib/left4me/.venv/site-packages. --force-reinstall
|
|
# because the version string in pyproject.toml (0.1.0) doesn't
|
|
# change commit-to-commit; without it pip would skip on no-op.
|
|
# triggered:True so this only fires on actual git_deploy changes
|
|
# (the cp + build is too heavy to run on every apply).
|
|
'command': """sudo -u left4me sh -c '
|
|
set -e
|
|
tmpdir=$(mktemp -d -t left4me-build-XXXXXX)
|
|
trap "rm -rf \\"$tmpdir\\"" EXIT
|
|
cp -r /opt/left4me/src/l4d2host /opt/left4me/src/l4d2web "$tmpdir/"
|
|
/var/lib/left4me/.venv/bin/pip install --force-reinstall "$tmpdir/l4d2host" "$tmpdir/l4d2web"
|
|
'""",
|
|
'triggered': True,
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'git_deploy:/opt/left4me/src',
|
|
'action:left4me_create_venv',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_alembic_upgrade',
|
|
],
|
|
}
|
|
|
|
actions['left4me_alembic_upgrade'] = {
|
|
# Mirrors deploy-test-server.sh:239-242. Runs as left4me with both env
|
|
# files sourced; JOB_WORKER_ENABLED=false so a stray worker doesn't race
|
|
# with the migration.
|
|
'command': (
|
|
'sudo -u left4me sh -c "'
|
|
'cd /opt/left4me/src/l4d2web && '
|
|
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
|
|
'env JOB_WORKER_ENABLED=false PYTHONPATH=/opt/left4me/src '
|
|
'/var/lib/left4me/.venv/bin/alembic -c /opt/left4me/src/l4d2web/alembic.ini upgrade head'
|
|
'"'
|
|
),
|
|
'triggered': True,
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'action:left4me_pip_install',
|
|
'file:/etc/left4me/host.env',
|
|
'file:/etc/left4me/web.env',
|
|
],
|
|
'triggers': [
|
|
'action:left4me_seed_overlays',
|
|
'svc_systemd:left4me-web.service:restart',
|
|
],
|
|
}
|
|
|
|
actions['left4me_seed_overlays'] = {
|
|
# Idempotent: refreshes script bodies in place; existing overlay rows keep their ids.
|
|
'command': (
|
|
'sudo -u left4me sh -c "'
|
|
'set -a && . /etc/left4me/host.env && . /etc/left4me/web.env && set +a && '
|
|
'env JOB_WORKER_ENABLED=false PYTHONPATH=/opt/left4me/src '
|
|
'/var/lib/left4me/.venv/bin/flask --app l4d2web.app:create_app '
|
|
'seed-script-overlays /opt/left4me/src/examples/script-overlays'
|
|
'"'
|
|
),
|
|
'triggered': True,
|
|
'cascade_skip': False,
|
|
'needs': [
|
|
'action:left4me_alembic_upgrade',
|
|
],
|
|
}
|