From bc269545dee8ce724b7f1986ca6898a7c181940e Mon Sep 17 00:00:00 2001 From: Robert Reynolds Date: Fri, 22 May 2026 16:55:47 -0600 Subject: [PATCH 1/4] first draft from Claude --- README.md | 157 +++++++++++++++++++++++++++------- examples/fetch-runner.service | 24 ++++-- examples/fetch-runner.sudoers | 43 ++++++++++ examples/jobs.toml | 16 +++- src/fetch_runner/cli.py | 84 ++++++++++++++++++ src/fetch_runner/config.py | 36 +++++++- src/fetch_runner/git_ops.py | 91 ++++++++++++++++++-- src/fetch_runner/guard.py | 31 +++++++ src/fetch_runner/runner.py | 60 +++++++++++-- tests/test_config.py | 124 +++++++++++++++++++++++++++ tests/test_git_ops.py | 70 +++++++++++++++ tests/test_guard.py | 26 ++++++ tests/test_runner.py | 83 ++++++++++++++++++ tests/test_sudoers.py | 89 +++++++++++++++++++ 14 files changed, 874 insertions(+), 60 deletions(-) create mode 100644 examples/fetch-runner.sudoers create mode 100644 tests/test_git_ops.py create mode 100644 tests/test_runner.py create mode 100644 tests/test_sudoers.py diff --git a/README.md b/README.md index bbd8dff..d561c5e 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,53 @@ # fetch-runner -Run scripts when `git fetch` finds new commits to specified git branches +Run scripts when `git fetch` finds new commits to specified git branches. + +## Overview + +fetch-runner uses **two distinct users**: + +- The **polling user** (set under `[general].user` in the config and as + `User=` in the systemd unit) is the user fetch-runner itself runs as. + It owns nothing in the repos; it just runs the poll loop, validates + scripts, and dispatches work via sudo. +- The **run-as user** (set per-job with `run_as` in the config) owns the + local git repository for that job and is the user every git operation + *and* the deploy script execute as. It defaults to the polling user + when omitted, which preserves the original single-user mode. + +When `run_as` matches the polling user, fetch-runner invokes git and the +script directly. When they differ, fetch-runner dispatches via +`sudo -n -u ` for both, and a narrow sudoers rule must allow +each combination. fetch-runner can generate that sudoers fragment for +you (see step 5 below). + +### Filesystem ownership + +For each job, with `run_as = "app1"`: + +- The repo directory (e.g. `/srv/app1`) and everything under it is owned + `app1:app1`. The polling user does not need write access anywhere in + the repo. +- The polling user needs **search** permission (`x` bit) on the repo + directory and its parents so it can `chdir` into the repo before + exec'ing the deploy script. Standard mode `0755` is enough. +- The deploy script needs to be **readable** by the polling user (so it + can run the post-checkout guard / permissions checks). Mode `0755` is + the simplest choice; the guard refuses execution as any other user + regardless. ## Setup ### 1. Install fetch-runner -As the deploy user (we use `my-app-user` in this document), install fetch-runner with `uv`: +Pick the user that will run fetch-runner itself (we use `fetch-runner` in +this document). As that user, install with `uv`: ```bash uv tool install git+https://github.com/BYU-ODH/fetch-runner ``` Note the path of the installed executable (typically something like -`/home/my-app-user/.local/bin/fetch-runner`). You will need it in step 4. +`/home/fetch-runner/.local/bin/fetch-runner`). You will need it in step 4. ### 2. Add a deploy script to your app @@ -24,37 +59,53 @@ chmod +x /srv/myapp/deploy.sh ``` Open the script and replace every occurrence of `deploy-user` with the -username that runs your app's deployments (e.g. `my-app-user`). The guard block -at the top of the script prevents accidental execution as the wrong user or -as root. +**run-as user** for this job — i.e. the account your app's deployment +should run as (e.g. `app1`). The guard block at the top of the script +prevents accidental execution as any other user, or as root. + +To regenerate the canonical guard block for a given user: + +```bash +fetch-runner --print-guard app1 +``` Consider committing this script to your app's repository so the deploy procedure is version-controlled alongside the code it deploys. ### 3. Create the jobs config -Copy the example config to the deploy user's home directory and edit it: +Copy the example config to the polling user's home directory and edit it: ```bash -cp /path/to/fetch-runner/examples/jobs.toml /home/my-app-user/jobs.toml +cp /path/to/fetch-runner/examples/jobs.toml /home/fetch-runner/jobs.toml ``` -Edit `/home/my-app-user/jobs.toml`: +Edit `/home/fetch-runner/jobs.toml`: -- Set `user` under `[general]` to the deploy user (e.g. `"my-app-user"`). - fetch-runner exits at startup if the running user does not match this value. -- Set `poll_interval_seconds` to how often fetch-runner should check for new - commits (default: `60`). +- Set `user` under `[general]` to the polling user (e.g. `"fetch-runner"`). + fetch-runner exits at startup if the running process is not this user. +- Set `poll_interval_seconds` to how often to check for new commits + (default: `60`). - For each `[[jobs]]` entry, set: - `name` — a human-readable label shown in logs - - `path` — absolute path to the local git repository to poll + - `path` — absolute path to the local git repository to poll. The + directory and its `.git` must be owned (and writable) by `run_as`, + not by the polling user. - `branch` — the branch to watch (e.g. `"main"` or `"production"`) - - `script` — absolute path to the script to run when new commits are found - (e.g. `/srv/myapp/deploy.sh`) - - `timeout_seconds` — how long to let the script run before killing it - (optional; omit to use the default) + - `script` — absolute path to the script to run when new commits arrive + - `run_as` — *optional;* the user that owns the repo, runs every git + operation, and executes the deploy script. Defaults to + `[general].user`. When set, sudoers must allow it (step 5). + - `timeout_seconds` — optional; how long to let the script run before + killing it. -### 4. Install and start the systemd service +Validate the config without starting the service: + +```bash +fetch-runner --check /home/fetch-runner/jobs.toml +``` + +### 4. Install the systemd service Copy the example unit file to systemd's unit directory: @@ -66,29 +117,65 @@ sudo cp /path/to/fetch-runner/examples/fetch-runner.service \ Edit `/etc/systemd/system/fetch-runner.service` and update the lines in the `CUSTOMIZE` block: -- **`User` / `Group`** — set both to your deploy user (must match `user` in - `jobs.toml`). -- **`ExecStart`** — replace `/usr/local/bin/fetch-runner` with the full path - to the executable you noted in step 1, then replace - `/etc/fetch-runner/jobs.toml` with the path to the config file from step 3. - For example: +- **`User` / `Group`** — set both to the **polling user** (must match + `[general].user` in `jobs.toml`). +- **`ExecStart`** — replace `/usr/local/bin/fetch-runner` with the full + path noted in step 1, then replace the config path. For example: ``` - ExecStart=/home/my-app-user/.local/bin/fetch-runner /home/my-app-user/jobs.toml + ExecStart=/home/fetch-runner/.local/bin/fetch-runner /home/fetch-runner/jobs.toml ``` -- **`ReadWritePaths`** — list every directory your deploy scripts need to +- **`ReadWritePaths`** — list every directory the polling user needs to write to (at minimum, the parent directories of your git repositories). - Space-separate multiple paths, e.g.: - ``` - ReadWritePaths=/srv/myapp /srv/anotherapp - ``` -Reload systemd and enable the service: +**Security note**: the example unit deliberately omits `NoNewPrivileges=` +and `RestrictSUIDSGID=` because both block `sudo`'s setuid transition, +and fetch-runner relies on sudo when `run_as` differs from the polling +user. The narrow sudoers fragment (step 5) is what bounds what +fetch-runner can do with that privilege. If every job uses +`run_as = [general].user` (single-user mode), you can re-enable both +settings. + +### 5. Install the sudoers fragment (only if any job uses a different `run_as`) + +Generate the sudoers fragment from your config and install it: + +```bash +fetch-runner --print-sudoers /home/fetch-runner/jobs.toml \ + | sudo tee /etc/sudoers.d/fetch-runner > /dev/null +sudo chmod 0440 /etc/sudoers.d/fetch-runner +sudo visudo -cf /etc/sudoers.d/fetch-runner # syntax check +``` + +The output authorizes two operations per cross-user job: the git binary +(unrestricted args, but pinned to `(run_as)`) and the deploy script +(pinned absolutely). Re-run this command after any change to `jobs.toml` +so the sudoers file stays in sync. + +The git rule is intentionally not arg-restricted. The escalation it +grants — "polling user can run git AS run_as" — is no broader than what +the deploy-script rule already permits, since the script also runs as +`run_as`. Tightening it with sudoers wildcards is brittle (any change to +fetch-runner's git argv breaks the rule with a confusing runtime error) +without meaningfully narrowing the threat. + +If every job's `run_as` equals the polling user, the command prints a +no-op fragment and you can skip this step. + +### 6. Enable and start the service ```bash sudo systemctl daemon-reload sudo systemctl enable --now fetch-runner ``` +## Migrating from a single-user setup + +Existing configs that do not set `run_as` keep working with no changes — +`run_as` defaults to `[general].user` and fetch-runner skips sudo +entirely in that case. To split a single-user setup, add `run_as` to one +job at a time, update that job's deploy script guard to name the new +run-as user, regenerate the sudoers fragment, and reload the service. + ## Debugging Check whether the service is running and see its recent log output: @@ -108,3 +195,9 @@ To review all logs since the service last started: ```bash journalctl -u fetch-runner -b ``` + +If a job fails with `sudo: a password is required`, the sudoers fragment +is missing or out of date — re-run step 5. If it fails with +`fetch-runner-guard: refusing to run as `, the script's guard names +a different user than the job's `run_as`; regenerate the guard with +`fetch-runner --print-guard `. diff --git a/examples/fetch-runner.service b/examples/fetch-runner.service index 4393399..624e556 100644 --- a/examples/fetch-runner.service +++ b/examples/fetch-runner.service @@ -11,10 +11,13 @@ Type=simple # CUSTOMIZE: edit the lines in this block for each deployment. # =========================================================================== -# MUST match the `user` field in the jobs.toml this unit starts. -# fetch-runner exits at startup if they do not match. -User=deploy -Group=deploy +# MUST match the `user` field under [general] in the jobs.toml this unit +# starts. This is the *polling* user — the one that runs `git fetch` and +# owns the local repo checkouts. It is NOT necessarily the user your +# deploy scripts run as; see [[jobs]].run_as in jobs.toml. +# fetch-runner exits at startup if User= and [general].user disagree. +User=fetch-runner +Group=fetch-runner # Path to the fetch-runner binary and to the config file it should load. # If you installed fetch-runner from a venv, point ExecStart at that venv's @@ -41,9 +44,16 @@ TimeoutStopSec=30s StandardOutput=journal StandardError=journal -# Block privilege escalation; the deploy user never needs more than it has. -NoNewPrivileges=true -RestrictSUIDSGID=true +# NoNewPrivileges and RestrictSUIDSGID are deliberately NOT set here: +# fetch-runner uses `sudo -u ` to dispatch deploy scripts to per-job +# users, and both settings block sudo's setuid transition. The narrow +# sudoers fragment (see `fetch-runner --print-sudoers`) is what bounds what +# fetch-runner can actually do with that privilege — it may only run the +# exact script paths listed, as the exact run_as users listed, with no +# password and a fixed env_keep allowlist. If every job in your jobs.toml +# uses run_as = [general].user (the single-user mode), you can re-enable: +# NoNewPrivileges=true +# RestrictSUIDSGID=true CapabilityBoundingSet= AmbientCapabilities= diff --git a/examples/fetch-runner.sudoers b/examples/fetch-runner.sudoers new file mode 100644 index 0000000..137bdb4 --- /dev/null +++ b/examples/fetch-runner.sudoers @@ -0,0 +1,43 @@ +# Example sudoers fragment for fetch-runner. Install with: +# +# sudo visudo -f /etc/sudoers.d/fetch-runner +# +# The file must be owned by root:root with mode 0440. +# +# The simplest way to keep this in sync with jobs.toml is to *generate* it: +# +# fetch-runner --print-sudoers /etc/fetch-runner/jobs.toml \ +# | sudo tee /etc/sudoers.d/fetch-runner > /dev/null +# sudo visudo -cf /etc/sudoers.d/fetch-runner # syntax check +# sudo chmod 0440 /etc/sudoers.d/fetch-runner +# +# Below is a hand-written copy of what that command would produce for the +# example jobs.toml, so you can read the shape of the rules. The exact git +# binary path comes from `shutil.which("git")` at generation time. + +# Preserve FETCH_RUNNER_* env vars only when running these scripts. +Defaults!/srv/api/deploy.sh env_keep += "FETCH_RUNNER_JOB FETCH_RUNNER_BRANCH FETCH_RUNNER_COMMIT FETCH_RUNNER_REPO" +Defaults!/srv/web/deploy.sh env_keep += "FETCH_RUNNER_JOB FETCH_RUNNER_BRANCH FETCH_RUNNER_COMMIT FETCH_RUNNER_REPO" + +# Allow fetch-runner to run git as each run_as user. Repos are owned by +# the run_as user, so all git operations cross the privilege boundary here. +fetch-runner ALL=(app1) NOPASSWD: /usr/bin/git +fetch-runner ALL=(app2) NOPASSWD: /usr/bin/git + +# Allow fetch-runner to run each deploy script as its run_as user. +fetch-runner ALL=(app1) NOPASSWD: /srv/api/deploy.sh +fetch-runner ALL=(app2) NOPASSWD: /srv/web/deploy.sh + +# Notes: +# * Script paths are pinned absolutely. Do NOT use wildcards or list +# directories — sudoers wildcards on the command field are a classic +# escalation vector. +# * The run-as user is pinned per line (e.g. `(app1)`). Never `(ALL)`. +# * NOPASSWD is required because fetch-runner runs as a service with no +# tty; the narrow command + run-as pinning is what keeps this safe. +# * env_keep is scoped to the specific script via `Defaults!` so +# other sudo invocations from this user do not inherit the allowlist. +# * The git rule is intentionally not arg-restricted: it lets the polling +# user run git AS the run_as user with any args. That is equivalent to +# "polling user can do anything run_as can do on their own files," +# which is already true via the deploy-script rule above. diff --git a/examples/jobs.toml b/examples/jobs.toml index bac6528..aff17cc 100644 --- a/examples/jobs.toml +++ b/examples/jobs.toml @@ -1,9 +1,17 @@ # Sample fetch-runner config. -# fetch-runner will refuse to load this unless the process is running as the -# user named below (and not as root). +# +# fetch-runner uses two distinct users: +# * [general].user — the "polling user" the fetch-runner process runs as. +# It owns the local git repositories (so `git fetch` / `git checkout` +# succeed) but should otherwise have no write access on the host. +# fetch-runner refuses to start if the running user does not match. +# * [[jobs]].run_as — the user each deploy script runs as. Optional; +# defaults to [general].user. When it differs, fetch-runner dispatches +# via `sudo -u ` and a narrow sudoers rule must allow it +# (see `fetch-runner --print-sudoers `). [general] -user = "deploy" +user = "fetch-runner" poll_interval_seconds = 60 [[jobs]] @@ -11,6 +19,7 @@ name = "my example api" path = "/srv/api" branch = "main" script = "/srv/api/deploy.sh" +run_as = "app1" timeout_seconds = 600 [[jobs]] @@ -18,3 +27,4 @@ name = "my example web" path = "/srv/web" branch = "production" script = "/srv/web/deploy.sh" +run_as = "app2" diff --git a/src/fetch_runner/cli.py b/src/fetch_runner/cli.py index 63ccf87..e6bf8f9 100644 --- a/src/fetch_runner/cli.py +++ b/src/fetch_runner/cli.py @@ -8,7 +8,11 @@ from . import __version__ from .config import ConfigError +from .config import RunnerConfig from .config import load_config +from .git_ops import GitError +from .git_ops import _git_absolute_path +from .guard import PRESERVED_ENVIRONMENT_VARIABLE_NAMES from .guard import GuardError from .guard import render_canonical_script_guard from .runner import GitPollingRunner @@ -32,6 +36,14 @@ def main(argv: list[str] | None = None) -> int: metavar="USER", help="print the canonical guard block for USER and exit (for pasting into a new script)", ) + argument_parser.add_argument( + "--print-sudoers", + action="store_true", + help=( + "load the config and print a sudoers fragment for every job whose " + "run_as user differs from the polling user; exit without running" + ), + ) argument_parser.add_argument( "-v", "--verbose", @@ -64,6 +76,10 @@ def main(argv: list[str] | None = None) -> int: print(f"config error: {e}", file=sys.stderr) return 2 + if cli_args.print_sudoers: + sys.stdout.write(render_sudoers_fragment(runner_config)) + return 0 + if cli_args.check: print( f"ok: user={runner_config.runtime_user} " @@ -83,6 +99,74 @@ def _handle_stop_signal(signum, _frame): return runner.run_forever() +def render_sudoers_fragment(runner_config: RunnerConfig) -> str: + """Return a sudoers fragment authorizing every cross-user job in the config. + + Emits three sections for jobs whose ``run_as`` differs from the polling + user: + + 1. ``Defaults!