diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ed1cc9..f99ecda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,6 +54,43 @@ jobs: CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -o "/tmp/pullgate-$arch" ./cmd/pullgate done + e2e: + name: end-to-end test + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + - run: git --version + - run: go vet -tags e2e ./test/e2e/... + - name: End-to-end test + # A local bare repo with two commits, the real binary, the listener + # against a fake Telegram server, a tap, and the apply of the + # second commit (DESIGN.md section 14). + run: go test -race -tags e2e ./test/e2e/... + + units: + name: example units + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Check the example scripts and units + run: | + set -eu + for f in contrib/examples/*/*.sh; do sh -n "$f"; done + # The binary is not installed here; every other message fails. + out="$(systemd-analyze verify --man=no contrib/systemd/* 2>&1 || true)" + echo "$out" + if echo "$out" | grep -v "Command /usr/local/bin/pullgate is not executable" | grep -q .; then + exit 1 + fi + lint: runs-on: ubuntu-latest steps: diff --git a/.golangci.yml b/.golangci.yml index 9022e76..fb7f964 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -38,6 +38,13 @@ linters: msg: library code must not use the default logger; take a *slog.Logger # Match on types, so an import alias or a *log.Logger cannot hide a call. analyze-types: true + gosec: + excludes: + # G301/G302: pullgate sets the mode of each directory and file on + # purpose (DESIGN.md sections 4 and 6), and the security review + # checks them. + - G301 + - G302 nolintlint: require-explanation: true require-specific: true @@ -50,6 +57,11 @@ linters: linters: - forbidigo text: library code must not + # Tests and the test helpers make their own files and run git. + - path: (_test\.go$|^internal/testgit/|^internal/telegram/faketg/|^test/) + linters: + - gosec + - noctx formatters: enable: diff --git a/DESIGN.md b/DESIGN.md index 9dca30f..bbdbbea 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -108,7 +108,9 @@ The paths can be changed with `--config-dir`, `--state-dir` and config, the deploy key, the known hosts file, or the program (the first word) of a command is a symlink, is not owned by root, or can be written by group or others; or when a directory above one of them fails the check of -section 6. The deploy key must also not be readable by group or others. The +section 6. A symlink on the way to a program (such as `/bin -> usr/bin`) +is allowed only when it is owned by root and the directory that holds it +passes the check too. The deploy key must also not be readable by group or others. The listener checks `listener.toml` and its token file the same way (the token file must be owned by the listener user). In tests, "root" means the user that runs the test. @@ -399,8 +401,9 @@ The steps, with the state synced to disk at each arrow: 2. Export fresh trees (section 6). Run `apply` as root in the tree of the SHA (environment in section 11). Then `health`, if it is set. 3. Both succeed: the applied SHA becomes this SHA, `refs/pullgate/applied` - moves, `in_progress` and the held SHA are cleared, all marks of older - SHAs are dropped → report "applied". + moves, `in_progress` and the held SHA are cleared, all SHA marks are + dropped (a SHA that is not a descendant is refused again anyway) → + report "applied". 4. One fails: count a failed try for this SHA →. Then by `on_failure`: - `rollback`, with an applied SHA: write `in_progress = {phase: "rollback", sha, old_sha}` →. Run `apply` with the last good SHA, then @@ -459,7 +462,7 @@ and its tree; `PULLGATE_OLD_SHA` and `PULLGATE_OLD_TREE` are the SHA that just failed and its tree; `PULLGATE_CHANGED_FILES` lists the paths that differ between them. So `apply` must be safe to run again on an older tree. -Their working directory is `PULLGATE_TREE`. Each has a timeout +Their working directory is `PULLGATE_TREE`. Their umask is 022. Each has a timeout (`check_timeout`, `apply_timeout`); on timeout the whole process group is killed and the command counts as failed. pullgate keeps at most 64 KiB of their output. The output of `apply` and `health` goes to the journal; the @@ -469,7 +472,9 @@ section 12. **check and summary (rule 3)** run as `check_user`, with its own group and no other groups. When pullgate runs as root, `check_user` must not be root -(uid 0): pullgate refuses such a config. When pullgate does not run as root +(uid 0), and must not be the listener user or share its group, because a +check that is taken over could then write approvals: pullgate refuses such +a config. When pullgate does not run as root (tests, a trial), it cannot change user, so `check_user` must be the user that runs it. A process of `check_user` could leave its process group and live on. So after each `check` and `summary`, root runs `kill -KILL -1` as diff --git a/README.md b/README.md index 2ed651c..9134e34 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,197 @@ # pullgate -Pull a git repo, ask one person on Telegram to apply one exact commit, then apply it. Small Go tool for plain Linux hosts. + +pullgate lets a Linux host change only through its own updates. The host +pulls a git repo by itself. It applies a new commit only after its owner +taps **Apply** on a Telegram question that names that exact commit. Then +root applies it, with a health check and a rollback or a hold. + +- **The gate lives on the host.** What runs, who may approve and which paths + get a warning come from `/etc/pullgate`, never from the repo. A commit + cannot change the gate. +- **One exact commit.** The question shows the full SHA. Apply applies only + that SHA, even when a newer commit arrives in between. +- **No code from the new commit runs before Apply.** Optional `check` and + `summary` commands read the commit as data, as an unprivileged user. +- **Only the listener holds the bot token.** It runs as its own user and + opens no port. +- **Forward only** by default. A force push or an older commit is refused. +- **Root never runs git in a directory another user can write.** + +It is a small Go program for plain Linux with systemd. It needs `git` and +`ssh`. No server, no web UI. + +Read [DESIGN.md](DESIGN.md) for how it works and +[THREAT-MODEL.md](THREAT-MODEL.md) for what it protects and where it stops. + +## How it works + +``` +timer ──> pullgate run NAME (root) + fetch ─> read the commit ─> check, summary (check_user) + ─> question into the outbox + │ +pullgate listen (own user, holds the token) ─> Telegram ─> owner taps Apply + │ + approval (only a one-time code) into the inbox + │ +path unit ─> pullgate apply NAME (root) + looks the code up in its own state ─> apply ─> health + ─> success: report; failure: rollback or hold, and report +``` + +## Install + +pullgate has no release yet. Build it with Go 1.26 or newer: + +```sh +CGO_ENABLED=0 go build -trimpath -o pullgate ./cmd/pullgate +``` + +Then, as root: + +```sh +install -m 0755 pullgate /usr/local/bin/pullgate +useradd --system --no-create-home --shell /usr/sbin/nologin pullgate +# One check user per instance, never the listener user: +useradd --system --no-create-home --shell /usr/sbin/nologin pullgate-check-config-repo +install -d -m 0755 /etc/pullgate /var/spool/pullgate +install -d -m 0711 /var/lib/pullgate +cp contrib/systemd/* /etc/systemd/system/ +``` + +1. Make a Telegram bot with BotFather. Put its token in + `/etc/pullgate/bot-token` (owner root, mode 0600). +2. Find your Telegram user ID and the chat ID of your private chat with the + bot. Write `/etc/pullgate/listener.toml` from + [contrib/examples/listener.toml](contrib/examples/listener.toml). +3. Make a **read-only** deploy key for the repo: + `ssh-keygen -t ed25519 -N '' -f /etc/pullgate/NAME.key`. Add the public key + to the repo as a read-only deploy key. +4. Pin the host key of the git server in `/etc/pullgate/NAME.known_hosts`. + Check the fingerprint against the one your git host publishes. +5. Write `/etc/pullgate/NAME.toml` (owner root, mode 0600). Start from one of + the examples below. +6. Start it: + +```sh +systemctl daemon-reload +systemctl enable --now pullgate-listen.service +systemctl enable --now pullgate-apply@NAME.path pullgate-run@NAME.timer +``` + +Every step logs to the journal: `journalctl -t pullgate`. + +## Config + +The instance config `/etc/pullgate/NAME.toml`: + +```toml +mode = "ask" # "ask" (default), "auto" (no question; for development hosts) or "off" +on_failure = "rollback" # "rollback" or "pause"; the default is "pause" +host = "example-host" # shown in each message + +[repo] +url = "ssh://git@git.example.com/owner/config-repo.git" +branch = "main" +deploy_key = "/etc/pullgate/NAME.key" +known_hosts = "/etc/pullgate/NAME.known_hosts" + +[commands] +check = ["/path/to/check"] # optional; runs as check_user; a failure means no question +summary = ["/path/to/summary"] # optional; runs as check_user; its output is in the question +apply = ["/path/to/apply"] # required; runs as root after Apply +health = ["/path/to/health"] # optional; runs as root after apply +check_user = "pullgate-check" + +[watch] +paths = ["policy/**", "*.toml"] # a warning for each changed path that matches + +[limits] +questions_per_day = 10 +approval_ttl = "12h" +max_failed_tries = 1 +allow_older = false +``` + +The commands get these environment variables: `PULLGATE_SHA`, +`PULLGATE_OLD_SHA`, `PULLGATE_TREE` (a clean, read-only copy of the commit), +`PULLGATE_OLD_TREE` (the same for the applied commit), +`PULLGATE_CHANGED_FILES` (a file with the changed paths, each ended by a NUL +byte), `PULLGATE_FAILED_SHA`, `PULLGATE_ROLLBACK` and `PULLGATE_INSTANCE`. +The full list of keys and what they do is in [DESIGN.md](DESIGN.md), +sections 5 and 11. + +**What happens when an apply fails** is your choice per instance: + +- `on_failure = "rollback"`: pullgate runs `apply` again with the last good + commit, then `health`. So `apply` must be safe to run again on an older + tree. If the rollback fails too, the instance is paused. +- `on_failure = "pause"`: no rollback. The failed commit is held and not + tried again. A newer commit is still asked, with a warning at the top, so + you can fix it forward. + +`pullgate status NAME` shows the state. As root: +`pullgate status NAME --pause` stops the instance, +`pullgate status NAME --resume` starts it again, and +`pullgate status NAME --ask-again` asks about the tip again. + +## Example 1: a config file and a service reload + +A service reads one policy file from a private repo. pullgate checks the +file, shows a diff of it in the question, copies it into place and reloads +the service. A failed health check rolls back to the last good file. + +Files: [contrib/examples/config-file/](contrib/examples/config-file/) + +- `config-repo.toml`: `on_failure = "rollback"`, a watch on `policy.toml`. +- `check.sh`: the file must exist, be small and be plain text. +- `summary.sh`: `diff -u` of the old and the new tree. +- `apply.sh`: install the file and `systemctl reload`. +- `health.sh`: `systemctl is-active`. + +## Example 2: ansible-playbook on the tree + +A server runs `ansible-playbook` locally on the approved commit. Running an +older playbook again can do more harm than good, so this example holds a +failed commit instead of rolling back. + +Files: [contrib/examples/ansible/](contrib/examples/ansible/) + +- `playbooks.toml`: `on_failure = "pause"`, watches on `ansible.cfg`, + `site.yml` and sudoers files. +- `apply.sh`: `ansible-playbook --connection local site.yml` in the tree. + +There is no `check` here: `ansible-playbook --syntax-check` loads +`ansible.cfg` and plugins from the tree, and that is code from the commit +before Apply. + +## The systemd units + +[contrib/systemd/](contrib/systemd/) has the units. Their +`systemd-analyze security` scores (systemd 255, 2026-11-11; lower is +better): + +| unit | score | why | +|---|---|---| +| `pullgate-listen.service` | 1.2 OK | own user, no capabilities, read-only system | +| `pullgate-run@.service` | 2.4 OK | root, but only the capabilities it needs to run `check` as `check_user`, to stop it on a timeout, and to fix the spool; read-only system | +| `pullgate-apply@.service` | 7.9 EXPOSED | it runs your `apply` command as root, which must change the system. Add a drop-in for what your `apply` needs (`systemctl edit pullgate-apply@NAME.service`) | + +The run unit sets `MemoryDenyWriteExecute=yes`, which also holds for +`check` and `summary`. A command that needs a JIT (for example Node.js) does +not work there. + +## Development + +```sh +go test -race ./... +go vet ./... +golangci-lint run --build-tags e2e +go test -race -tags e2e ./test/e2e/... +``` + +The rules for changes are in [AGENTS.md](AGENTS.md). + +## License + +Apache-2.0. See [LICENSE](LICENSE). diff --git a/cmd/pullgate/main.go b/cmd/pullgate/main.go index 03ddd87..aeec330 100644 --- a/cmd/pullgate/main.go +++ b/cmd/pullgate/main.go @@ -3,18 +3,160 @@ package main import ( + "context" + "errors" + "flag" "fmt" + "io" + "log/slog" "os" + "os/signal" + "syscall" + + "github.com/code6x/pullgate/internal/config" + "github.com/code6x/pullgate/internal/gate" + "github.com/code6x/pullgate/internal/listener" + "github.com/code6x/pullgate/internal/telegram" ) // version is set at build time with -ldflags "-X main.version=...". var version = "dev" +const usage = `usage: pullgate [--config-dir DIR] [--state-dir DIR] [--spool-dir DIR] COMMAND +commands: + run NAME fetch, check, and ask (the timer runs it, as root) + apply NAME act on approvals (the path unit runs it, as root) + status NAME [--ask-again] [--pause] [--resume] + listen [--config FILE] the Telegram listener (its own user) + version` + func main() { - if len(os.Args) == 2 && os.Args[1] == "version" { - fmt.Println("pullgate", version) - return + os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) +} + +func usageErr(stderr io.Writer, msg string) int { + _, _ = fmt.Fprintf(stderr, "pullgate: %s; see \"pullgate --help\" (exit 64)\n", msg) + return 64 +} + +func run(args []string, stdout, stderr io.Writer) int { + // pullgate sets the mode of its own files itself. 022 is what the + // owner's apply command expects (DESIGN.md section 11). + syscall.Umask(0o022) + fs := flag.NewFlagSet("pullgate", flag.ContinueOnError) + fs.SetOutput(io.Discard) + configDir := fs.String("config-dir", "/etc/pullgate", "") + stateDir := fs.String("state-dir", "/var/lib/pullgate", "") + spoolDir := fs.String("spool-dir", "/var/spool/pullgate", "") + if err := fs.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + _, _ = fmt.Fprintln(stdout, usage) + return 0 + } + return usageErr(stderr, err.Error()) + } + rest := fs.Args() + if len(rest) == 0 { + return usageErr(stderr, "give a command: run, apply, status, listen or version") + } + log := slog.New(slog.NewTextHandler(stderr, nil)) + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + euid := uint32(os.Geteuid()) //nolint:gosec // a uid fits in uint32 + + switch cmd := rest[0]; cmd { + case "version": + _, _ = fmt.Fprintln(stdout, "pullgate", version) + return 0 + case "run", "apply", "status": + sf := flag.NewFlagSet(cmd, flag.ContinueOnError) + sf.SetOutput(io.Discard) + var flags gate.StatusFlags + if cmd == "status" { + sf.BoolVar(&flags.AskAgain, "ask-again", false, "") + sf.BoolVar(&flags.Pause, "pause", false, "") + sf.BoolVar(&flags.Resume, "resume", false, "") + } + if len(rest) < 2 { + return usageErr(stderr, "give the instance name: pullgate "+cmd+" NAME") + } + name := rest[1] + if err := sf.Parse(rest[2:]); err != nil || sf.NArg() > 0 { + return usageErr(stderr, "unknown arguments after pullgate "+cmd+" "+name) + } + log = log.With("instance", name, "step", cmd) + cfg, err := config.Load(*configDir, name, euid) + if err != nil { + log.Error("config", "err", err) + return 78 + } + env, err := gate.New(cfg, *stateDir, *spoolDir, euid, log, stderr) + if err != nil { + log.Error("setup", "err", err) + return 78 + } + switch cmd { + case "run": + res, err := env.Run(ctx) + return done(log, string(res), err) + case "apply": + res, err := env.Apply(ctx) + return done(log, string(res), err) + default: + if err := env.Status(stdout, flags); err != nil { + log.Error("status", "err", err) + return 1 + } + return 0 + } + case "listen": + lf := flag.NewFlagSet("listen", flag.ContinueOnError) + lf.SetOutput(io.Discard) + path := lf.String("config", "/etc/pullgate/listener.toml", "") + ownConfig := lf.Bool("trust-own-config", false, "") + if err := lf.Parse(rest[1:]); err != nil || lf.NArg() > 0 { + return usageErr(stderr, "unknown arguments after pullgate listen") + } + log = log.With("step", "listen") + configUID := uint32(0) + if *ownConfig { + configUID = euid // tests only: the config belongs to the test user + } + cfg, err := config.LoadListener(*path, configUID) + if err != nil { + log.Error("config", "err", err) + return 78 + } + if euid == 0 { + log.Error("the listener must not run as root (rule 4). Fix: run it as its own user, like the example unit") + return 78 + } + tok, err := cfg.ReadToken(euid) + if err != nil { + log.Error("token", "err", err) + return 78 + } + l := &listener.Listener{Cfg: cfg, TG: telegram.New(cfg.APIURL, tok), Log: log} + if err := l.Load(); err != nil { + log.Error("state", "err", err) + return 1 + } + log.Info("listening", "instances", cfg.Instances) + if err := l.Run(ctx); err != nil { + log.Error("listen", "err", err) + return 1 + } + return 0 + default: + return usageErr(stderr, fmt.Sprintf("unknown command %q", cmd)) + } +} + +func done(log *slog.Logger, res string, err error) int { + if err != nil { + log.Error("failed", "result", res, "err", err) + return 1 } - fmt.Fprintln(os.Stderr, `pullgate: give a command: version; see DESIGN.md (exit 64)`) - os.Exit(64) + log.Info("done", "result", res) + return 0 } diff --git a/contrib/examples/ansible/apply.sh b/contrib/examples/ansible/apply.sh new file mode 100755 index 0000000..c33099d --- /dev/null +++ b/contrib/examples/ansible/apply.sh @@ -0,0 +1,7 @@ +#!/bin/sh +# Runs as root after Apply, in the tree of the approved commit. +set -eu +cd "$PULLGATE_TREE" +# ansible writes its temporary files under HOME. +export HOME=/root +exec ansible-playbook --inventory localhost, --connection local site.yml diff --git a/contrib/examples/ansible/playbooks.toml b/contrib/examples/ansible/playbooks.toml new file mode 100644 index 0000000..cc2fccb --- /dev/null +++ b/contrib/examples/ansible/playbooks.toml @@ -0,0 +1,33 @@ +# Example 2: a server that runs ansible-playbook locally on the approved +# commit. Install as /etc/pullgate/playbooks.toml (owner root, mode 0600). +mode = "ask" +# No rollback: running an older playbook again can do more harm than good. +# A failed commit is held; fix it forward with a new commit. +on_failure = "pause" +host = "example-host" + +[repo] +url = "ssh://git@git.example.com/owner/playbooks.git" +branch = "main" +deploy_key = "/etc/pullgate/playbooks.key" +known_hosts = "/etc/pullgate/playbooks.known_hosts" + +[commands] +# No check and no summary: "ansible-playbook --syntax-check" would load +# ansible.cfg, plugins and vars plugins from the tree, which is code from +# the commit, before Apply (rule 3). The question shows the commits and +# the changed files. +apply = ["/usr/local/lib/pullgate/playbooks/apply.sh"] +check_user = "pullgate-check-playbooks" +apply_timeout = "1h" + +[watch] +# Changes to these get a warning line in the question. +paths = ["ansible.cfg", "site.yml", "roles/pullgate/**", "**/sudoers*"] + +[limits] +questions_per_day = 10 +approval_ttl = "24h" + +[listener] +user = "pullgate" diff --git a/contrib/examples/config-file/apply.sh b/contrib/examples/config-file/apply.sh new file mode 100755 index 0000000..a9cf37d --- /dev/null +++ b/contrib/examples/config-file/apply.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Runs as root after Apply. Safe to run again on an older tree: it only +# puts one file in place and reloads. A rollback runs it with the last +# good tree. +set -eu +src="$PULLGATE_TREE/policy.toml" +dst=/etc/example-broker/policy.toml +[ -f "$src" ] && [ ! -L "$src" ] || { echo "policy.toml is missing or a symlink"; exit 1; } +install -m 0640 -o root -g example-broker "$src" "$dst.new" +mv -f "$dst.new" "$dst" +systemctl reload example-broker.service diff --git a/contrib/examples/config-file/check.sh b/contrib/examples/config-file/check.sh new file mode 100755 index 0000000..a5c056e --- /dev/null +++ b/contrib/examples/config-file/check.sh @@ -0,0 +1,11 @@ +#!/bin/sh +# Runs as check_user. Reads the new policy file as data, runs nothing from +# the tree. Fails when the file is missing, too large, or not plain text. +set -eu +f="$PULLGATE_TREE/policy.toml" +[ -f "$f" ] && [ ! -L "$f" ] || { echo "policy.toml is missing or a symlink"; exit 1; } +[ "$(wc -c < "$f")" -le 65536 ] || { echo "policy.toml is larger than 64 KiB"; exit 1; } +if LC_ALL=C grep -q '[^[:print:][:space:]]' "$f"; then + echo "policy.toml holds control characters" + exit 1 +fi diff --git a/contrib/examples/config-file/config-repo.toml b/contrib/examples/config-file/config-repo.toml new file mode 100644 index 0000000..1a25214 --- /dev/null +++ b/contrib/examples/config-file/config-repo.toml @@ -0,0 +1,33 @@ +# Example 1: a service that reads one policy file from a private repo. +# pullgate copies the file into place and reloads the service. +# Install as /etc/pullgate/config-repo.toml (owner root, mode 0600). +mode = "ask" +# A failed apply runs apply again with the last good commit. That is safe +# here: apply only copies one file and reloads. +on_failure = "rollback" +host = "example-host" + +[repo] +url = "ssh://git@git.example.com/owner/config-repo.git" +branch = "main" +deploy_key = "/etc/pullgate/config-repo.key" +known_hosts = "/etc/pullgate/config-repo.known_hosts" + +[commands] +# check and summary run as check_user and only read the tree as data. +check = ["/usr/local/lib/pullgate/config-repo/check.sh"] +summary = ["/usr/local/lib/pullgate/config-repo/summary.sh"] +apply = ["/usr/local/lib/pullgate/config-repo/apply.sh"] +health = ["/usr/local/lib/pullgate/config-repo/health.sh"] +check_user = "pullgate-check-config-repo" + +[watch] +paths = ["policy.toml"] + +[limits] +questions_per_day = 10 +approval_ttl = "12h" +max_failed_tries = 1 + +[listener] +user = "pullgate" diff --git a/contrib/examples/config-file/health.sh b/contrib/examples/config-file/health.sh new file mode 100755 index 0000000..94367b8 --- /dev/null +++ b/contrib/examples/config-file/health.sh @@ -0,0 +1,5 @@ +#!/bin/sh +# Runs as root after apply. A failure here starts the rollback. +set -eu +sleep 2 +systemctl is-active --quiet example-broker.service diff --git a/contrib/examples/config-file/summary.sh b/contrib/examples/config-file/summary.sh new file mode 100755 index 0000000..f179288 --- /dev/null +++ b/contrib/examples/config-file/summary.sh @@ -0,0 +1,12 @@ +#!/bin/sh +# Runs as check_user. Shows what changes in the policy file, from the two +# trees only; it does not read the live file in /etc. +set -u +new="$PULLGATE_TREE/policy.toml" +if [ -z "$PULLGATE_OLD_TREE" ]; then + echo "first apply: policy.toml has $(wc -l < "$new") lines" + exit 0 +fi +diff -u "$PULLGATE_OLD_TREE/policy.toml" "$new" +# diff exits 1 when the files differ; that is not a failure here. +[ $? -le 1 ] diff --git a/contrib/examples/listener.toml b/contrib/examples/listener.toml new file mode 100644 index 0000000..7735f60 --- /dev/null +++ b/contrib/examples/listener.toml @@ -0,0 +1,6 @@ +# /etc/pullgate/listener.toml (owner root, mode 0640, group pullgate). +# The bot token comes from the systemd credential "bot-token" of +# pullgate-listen.service (/etc/pullgate/bot-token, owner root, mode 0600). +owner_id = 123456789 # your Telegram user ID +chat_id = 123456789 # the private chat of the bot with you +instances = ["config-repo", "playbooks"] diff --git a/contrib/systemd/pullgate-apply@.path b/contrib/systemd/pullgate-apply@.path new file mode 100644 index 0000000..973b071 --- /dev/null +++ b/contrib/systemd/pullgate-apply@.path @@ -0,0 +1,11 @@ +# Starts pullgate-apply@NAME.service when the listener writes an approval, +# or when auto mode writes its approval (DESIGN.md section 8). +[Unit] +Description=pullgate approvals for %i + +[Path] +DirectoryNotEmpty=/var/spool/pullgate/%i/inbox +PathExists=/var/lib/pullgate/%i/auto-approval + +[Install] +WantedBy=paths.target diff --git a/contrib/systemd/pullgate-apply@.service b/contrib/systemd/pullgate-apply@.service new file mode 100644 index 0000000..ae83feb --- /dev/null +++ b/contrib/systemd/pullgate-apply@.service @@ -0,0 +1,27 @@ +# pullgate apply NAME: act on approvals as root (DESIGN.md sections 9, 10). +# It runs the owner's apply and health commands. What they need decides +# how strict this unit can be, so the sandbox here is light. Add more in a +# drop-in for your instance, for example: +# systemctl edit pullgate-apply@config-repo.service +# The example configs in contrib/examples say what fits them. +[Unit] +Description=pullgate apply for %i +# A stuck inbox must not start this in a loop. +StartLimitIntervalSec=60 +StartLimitBurst=10 + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/pullgate apply %i +SyslogIdentifier=pullgate +UMask=0022 +TimeoutStartSec=2h +KillMode=control-group +PrivateTmp=yes +ProtectHome=read-only +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectClock=yes +RestrictRealtime=yes +LockPersonality=yes +SystemCallArchitectures=native diff --git a/contrib/systemd/pullgate-listen.service b/contrib/systemd/pullgate-listen.service new file mode 100644 index 0000000..88ab118 --- /dev/null +++ b/contrib/systemd/pullgate-listen.service @@ -0,0 +1,50 @@ +# pullgate listen: the only process with the bot token (rule 4). It runs as +# its own user, connects out only, and opens no port. +# Make the user first: useradd --system --no-create-home pullgate +[Unit] +Description=pullgate Telegram listener +After=network-online.target +Wants=network-online.target + +[Service] +User=pullgate +Group=pullgate +ExecStart=/usr/local/bin/pullgate listen --config /etc/pullgate/listener.toml +SyslogIdentifier=pullgate +Restart=always +RestartSec=10s +UMask=0077 +# The bot token: a root-only file, given to the service as a credential. +LoadCredential=bot-token:/etc/pullgate/bot-token +StateDirectory=pullgate-listener +StateDirectoryMode=0700 +ReadWritePaths=/var/spool/pullgate + +CapabilityBoundingSet= +AmbientCapabilities= +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +ProcSubset=pid +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RemoveIPC=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallFilter=~@privileged @resources +SystemCallErrorNumber=EPERM +DevicePolicy=closed +PrivateIPC=yes diff --git a/contrib/systemd/pullgate-run@.service b/contrib/systemd/pullgate-run@.service new file mode 100644 index 0000000..73f488a --- /dev/null +++ b/contrib/systemd/pullgate-run@.service @@ -0,0 +1,53 @@ +# pullgate run NAME: fetch, check, and ask (DESIGN.md section 7). +# Started by pullgate-run@NAME.timer. Runs as root, because root owns the +# clone (rule 6). check and summary run as check_user inside this sandbox. +[Unit] +Description=pullgate run for %i +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/pullgate run %i +SyslogIdentifier=pullgate +UMask=0077 +TimeoutStartSec=30min +# When the run ends, systemd stops every process left in its cgroup, +# including a check that left its process group (DESIGN.md section 11). +KillMode=control-group + +# Sandbox. Root needs: CAP_SETUID/CAP_SETGID to run check as check_user, +# CAP_CHOWN/CAP_FOWNER to fix the spool owners, CAP_DAC_OVERRIDE to remove +# files in the listener's inbox, CAP_KILL to stop a check that runs too long. +CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_CHOWN CAP_FOWNER CAP_DAC_OVERRIDE CAP_KILL +AmbientCapabilities= +NoNewPrivileges=yes +ProtectSystem=strict +StateDirectory=pullgate +StateDirectoryMode=0711 +ReadWritePaths=/var/spool/pullgate +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectKernelLogs=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +ProtectProc=invisible +ProcSubset=pid +RestrictNamespaces=yes +RestrictRealtime=yes +RestrictSUIDSGID=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RemoveIPC=yes +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +SystemCallArchitectures=native +# @system-service has @setuid and @chown, which check_user and the spool +# need, so @privileged is not taken out here. +SystemCallFilter=@system-service +SystemCallFilter=~@resources @mount @swap @reboot @module @raw-io @clock @debug @cpu-emulation @obsolete +SystemCallErrorNumber=EPERM +DevicePolicy=closed diff --git a/contrib/systemd/pullgate-run@.timer b/contrib/systemd/pullgate-run@.timer new file mode 100644 index 0000000..e3c6a60 --- /dev/null +++ b/contrib/systemd/pullgate-run@.timer @@ -0,0 +1,10 @@ +[Unit] +Description=pullgate run for %i, every 5 minutes + +[Timer] +OnBootSec=2min +OnUnitInactiveSec=5min +RandomizedDelaySec=30s + +[Install] +WantedBy=timers.target diff --git a/go.mod b/go.mod index 183dec8..3c5b37b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/code6x/pullgate go 1.26.10 + +require github.com/BurntSushi/toml v1.6.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1742310 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..de06115 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,401 @@ +// Package config reads the instance config (/etc/pullgate/NAME.toml) and the +// listener config, and checks the files they name (DESIGN.md sections 4, 5 +// and 13). Nothing here is ever read from the repo (rule 1). +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/BurntSushi/toml" + + "github.com/code6x/pullgate/internal/gitx" + "github.com/code6x/pullgate/internal/safedir" +) + +// Modes and failure modes. +const ( + ModeAsk = "ask" + ModeAuto = "auto" + ModeOff = "off" + + OnFailureRollback = "rollback" + OnFailurePause = "pause" +) + +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,31}$`) + +// ValidName reports whether s is a valid instance name. +func ValidName(s string) bool { return nameRE.MatchString(s) } + +// Duration is a time.Duration written as "12h" in TOML. +type Duration struct{ time.Duration } + +// UnmarshalText reads a duration such as "5m" or "12h". +func (d *Duration) UnmarshalText(b []byte) error { + v, err := time.ParseDuration(string(b)) + if err != nil { + return err + } + d.Duration = v + return nil +} + +// Repo is the [repo] section. +type Repo struct { + URL string `toml:"url"` + Branch string `toml:"branch"` + DeployKey string `toml:"deploy_key"` + KnownHosts string `toml:"known_hosts"` +} + +// Commands is the [commands] section. +type Commands struct { + Check []string `toml:"check"` + Summary []string `toml:"summary"` + Apply []string `toml:"apply"` + Health []string `toml:"health"` + CheckUser string `toml:"check_user"` + CheckTimeout Duration `toml:"check_timeout"` + ApplyTimeout Duration `toml:"apply_timeout"` +} + +// Watch is the [watch] section. +type Watch struct { + Paths []string `toml:"paths"` +} + +// Limits is the [limits] section. +type Limits struct { + QuestionsPerDay int `toml:"questions_per_day"` + ApprovalTTL Duration `toml:"approval_ttl"` + MaxFailedTries int `toml:"max_failed_tries"` + MaxFiles int `toml:"max_files"` + MaxTreeBytes int64 `toml:"max_tree_bytes"` + AllowOlder bool `toml:"allow_older"` + RefuseSymlinks bool `toml:"refuse_symlinks"` +} + +// ListenerUser is the [listener] section of an instance. +type ListenerUser struct { + User string `toml:"user"` +} + +// Instance is one instance config. +type Instance struct { + Name string `toml:"-"` + Path string `toml:"-"` + Mode string `toml:"mode"` + OnFailure string `toml:"on_failure"` + Host string `toml:"host"` + Repo Repo `toml:"repo"` + Commands Commands `toml:"commands"` + Watch Watch `toml:"watch"` + Limits Limits `toml:"limits"` + Listener ListenerUser `toml:"listener"` +} + +// MaxTTL is the longest approval_ttl. +const MaxTTL = 7 * 24 * time.Hour + +func (c *Instance) defaults() { + if c.Mode == "" { + c.Mode = ModeAsk + } + if c.OnFailure == "" { + c.OnFailure = OnFailurePause // the safe choice (issue #1) + } + if c.Host == "" { + c.Host, _ = os.Hostname() + } + if c.Repo.Branch == "" { + c.Repo.Branch = "main" + } + if c.Commands.CheckUser == "" { + c.Commands.CheckUser = "pullgate-check" + } + if c.Commands.CheckTimeout.Duration == 0 { + c.Commands.CheckTimeout.Duration = 5 * time.Minute + } + if c.Commands.ApplyTimeout.Duration == 0 { + c.Commands.ApplyTimeout.Duration = 30 * time.Minute + } + if c.Limits.QuestionsPerDay == 0 { + c.Limits.QuestionsPerDay = 10 + } + if c.Limits.ApprovalTTL.Duration == 0 { + c.Limits.ApprovalTTL.Duration = 12 * time.Hour + } + if c.Limits.MaxFailedTries == 0 { + c.Limits.MaxFailedTries = 1 + } + if c.Limits.MaxFiles == 0 { + c.Limits.MaxFiles = 100000 + } + if c.Limits.MaxTreeBytes == 0 { + c.Limits.MaxTreeBytes = 1 << 30 + } + if c.Listener.User == "" { + c.Listener.User = "pullgate" + } +} + +func (c *Instance) errf(key, format string, a ...any) error { + return fmt.Errorf("%s: %s: %s", c.Path, key, fmt.Sprintf(format, a...)) +} + +func (c *Instance) validate() error { + switch c.Mode { + case ModeAsk, ModeAuto, ModeOff: + default: + return c.errf("mode", "%q is not a mode. Fix: use \"ask\", \"auto\" or \"off\"", c.Mode) + } + switch c.OnFailure { + case OnFailureRollback, OnFailurePause: + default: + return c.errf("on_failure", "%q is not allowed. Fix: use \"rollback\" or \"pause\"", c.OnFailure) + } + if strings.ContainsAny(c.Host, "<>&\n\r") || len(c.Host) > 64 { + return c.errf("host", "the name must be at most 64 characters, without <, >, & or line breaks") + } + if _, err := gitx.LocalPath(c.Repo.URL); err != nil { + return c.errf("[repo] url", "%v", err) + } + if !gitx.ValidBranch(c.Repo.Branch) { + return c.errf("[repo] branch", "%q is not a plain branch name. Fix: use a name like \"main\"", c.Repo.Branch) + } + if len(c.Commands.Apply) == 0 { + return c.errf("[commands] apply", "it is missing. Fix: set apply = [\"/absolute/path\", ...]") + } + for key, argv := range map[string][]string{"check": c.Commands.Check, "summary": c.Commands.Summary, "apply": c.Commands.Apply, "health": c.Commands.Health} { + if len(argv) > 0 && !filepath.IsAbs(argv[0]) { + return c.errf("[commands] "+key, "the program %q is not an absolute path. Fix: give the full path, like \"/usr/local/bin/x\"", argv[0]) + } + for _, a := range argv { + if strings.ContainsRune(a, 0) { + return c.errf("[commands] "+key, "an argument holds a NUL") + } + } + } + if c.Commands.CheckUser == "root" { + return c.errf("[commands] check_user", "check and summary must never run as root (rule 3). Fix: use an unprivileged user, like \"pullgate-check\"") + } + if c.Limits.ApprovalTTL.Duration <= 0 || c.Limits.ApprovalTTL.Duration > MaxTTL { + return c.errf("[limits] approval_ttl", "must be more than 0 and at most 168h") + } + if c.Limits.QuestionsPerDay < 0 || c.Limits.MaxFailedTries < 0 || c.Limits.MaxFiles < 0 || c.Limits.MaxTreeBytes < 0 { + return c.errf("[limits]", "a limit is below 0. Fix: use a positive number") + } + if c.Commands.CheckTimeout.Duration < 0 || c.Commands.ApplyTimeout.Duration < 0 { + return c.errf("[commands]", "a timeout is below 0") + } + for _, p := range c.Watch.Paths { + if p == "" || strings.HasPrefix(p, "/") { + return c.errf("[watch] paths", "%q is empty or absolute. Fix: use a path inside the repo, like \"policy/**\"", p) + } + } + return nil +} + +// Load reads the instance config NAME from dir. The file must be owned by +// uid and not writable by others, with safe parent directories. +func Load(dir, name string, uid uint32) (*Instance, error) { + if !ValidName(name) { + return nil, fmt.Errorf("%q is not an instance name. Fix: use [a-z0-9][a-z0-9-]{0,31}", name) + } + path := filepath.Join(dir, name+".toml") + if err := checkFile(path, uid, false); err != nil { + return nil, err + } + c := &Instance{Name: name, Path: path} + if err := decodeFile(path, c); err != nil { + return nil, err + } + c.defaults() + if err := c.validate(); err != nil { + return nil, err + } + return c, nil +} + +// CheckGateFiles checks the deploy key, the known hosts file and the +// program of each command (DESIGN.md section 4). +func (c *Instance) CheckGateFiles(uid uint32) error { + local, _ := gitx.LocalPath(c.Repo.URL) + if local == "" { + if c.Repo.DeployKey == "" || c.Repo.KnownHosts == "" { + return c.errf("[repo]", "an ssh url needs deploy_key and known_hosts. Fix: set both") + } + if err := checkFile(c.Repo.DeployKey, uid, true); err != nil { + return c.errf("[repo] deploy_key", "%v", err) + } + if err := checkFile(c.Repo.KnownHosts, uid, false); err != nil { + return c.errf("[repo] known_hosts", "%v", err) + } + } + for key, argv := range map[string][]string{"check": c.Commands.Check, "summary": c.Commands.Summary, "apply": c.Commands.Apply, "health": c.Commands.Health} { + if len(argv) == 0 { + continue + } + if err := checkProgram(argv[0], uid); err != nil { + return c.errf("[commands] "+key, "%v", err) + } + } + return nil +} + +// checkProgram checks a program file like a gate file. A symlink on the +// way (such as /bin -> usr/bin) is allowed only when the directory that +// holds it passes the check too, and the target passes it. +func checkProgram(p string, uid uint32) error { + real, err := filepath.EvalSymlinks(p) + if err != nil { + return fmt.Errorf("the program %s: %w. Fix: install it, or fix the path", p, err) + } + if real != p { + if err := safedir.CheckParents(filepath.Dir(p), uid); err != nil && !errors.Is(err, safedir.ErrSymlink) { + return fmt.Errorf("the program %s: %w", p, err) + } + if err := checkLinks(p, uid); err != nil { + return err + } + } + return checkFile(real, uid, false) +} + +// checkLinks checks that each symlink on the way to p is owned by uid +// (or root) and that the directory that holds it passes the check. +func checkLinks(p string, uid uint32) error { + cur := "/" + for _, part := range strings.Split(strings.TrimPrefix(filepath.Clean(p), "/"), "/") { + next := filepath.Join(cur, part) + fi, err := os.Lstat(next) + if err != nil { + return fmt.Errorf("the program %s: %w", p, err) + } + if fi.Mode()&os.ModeSymlink != 0 { + real, err := filepath.EvalSymlinks(cur) + if err != nil { + return err + } + if err := safedir.CheckParents(real, uid); err != nil { + return fmt.Errorf("the program %s: the symlink %s is in an unsafe directory: %w", p, next, err) + } + if err := safedir.CheckOwner(fi, uid); err != nil { + return fmt.Errorf("the program %s: the symlink %s: %w", p, next, err) + } + } + if r, err := filepath.EvalSymlinks(next); err == nil { + cur = r + } else { + return err + } + } + return nil +} + +// checkFile checks a file and the directories above it. +func checkFile(path string, uid uint32, private bool) error { + if err := safedir.CheckParents(filepath.Dir(path), uid); err != nil { + return err + } + if err := safedir.CheckFile(path, uid, private); err != nil { + return err + } + return nil +} + +func decodeFile(path string, v any) error { + b, err := os.ReadFile(path) //nolint:gosec // the path is checked by the caller + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + md, err := toml.Decode(string(b), v) + if err != nil { + return fmt.Errorf("%s: %w. Fix: correct the TOML", path, err) + } + if und := md.Undecoded(); len(und) > 0 { + return fmt.Errorf("%s: unknown key %q. Fix: remove it, or check its spelling in DESIGN.md section 5", path, und[0].String()) + } + return nil +} + +// Listener is the listener config. +type Listener struct { + Path string `toml:"-"` + TokenFile string `toml:"token_file"` + OwnerID int64 `toml:"owner_id"` + ChatID int64 `toml:"chat_id"` + Instances []string `toml:"instances"` + SpoolDir string `toml:"spool_dir"` + StateFile string `toml:"state_file"` + APIURL string `toml:"api_url"` +} + +// LoadListener reads the listener config. The file must be owned by +// configUID (root) and not writable by others. +func LoadListener(path string, configUID uint32) (*Listener, error) { + if err := checkFile(path, configUID, false); err != nil { + return nil, err + } + c := &Listener{Path: path} + if err := decodeFile(path, c); err != nil { + return nil, err + } + if c.SpoolDir == "" { + c.SpoolDir = "/var/spool/pullgate" + } + if c.StateFile == "" { + c.StateFile = "/var/lib/pullgate-listener/state.json" + } + if c.APIURL == "" { + c.APIURL = "https://api.telegram.org" + } + if c.TokenFile == "" { + if d := os.Getenv("CREDENTIALS_DIRECTORY"); d != "" { + c.TokenFile = filepath.Join(d, "bot-token") + } + } + switch { + case c.TokenFile == "": + return nil, fmt.Errorf("%s: token_file: it is missing. Fix: set token_file, or give a systemd credential named bot-token", path) + case c.OwnerID <= 0: + return nil, fmt.Errorf("%s: owner_id: it is missing. Fix: set the Telegram user ID of the owner", path) + case c.ChatID == 0: + return nil, fmt.Errorf("%s: chat_id: it is missing. Fix: set the chat ID (the private chat with the owner)", path) + case len(c.Instances) == 0: + return nil, fmt.Errorf("%s: instances: the list is empty. Fix: list the instance names", path) + case !strings.HasPrefix(c.APIURL, "https://") && !strings.HasPrefix(c.APIURL, "http://127.0.0.1:"): + return nil, fmt.Errorf("%s: api_url: it must be an https:// url", path) + } + for _, n := range c.Instances { + if !ValidName(n) { + return nil, fmt.Errorf("%s: instances: %q is not an instance name", path, n) + } + } + return c, nil +} + +// ReadToken reads the bot token. The file must be owned by uid (the +// listener user) and readable by nobody else. The token is never printed. +func (c *Listener) ReadToken(uid uint32) (string, error) { + if err := safedir.CheckFile(c.TokenFile, uid, true); err != nil { + if !strings.HasPrefix(c.TokenFile, os.Getenv("CREDENTIALS_DIRECTORY")+"/") || os.Getenv("CREDENTIALS_DIRECTORY") == "" { + return "", fmt.Errorf("token_file: %w", err) + } + } + b, err := os.ReadFile(c.TokenFile) + if err != nil { + return "", fmt.Errorf("read the token file %s: %w", c.TokenFile, err) + } + tok := strings.TrimSpace(string(b)) + if tok == "" || strings.ContainsAny(tok, " /\n\t") { + return "", errors.New("the token file holds no valid bot token. Fix: put the token from BotFather in it, on one line") + } + return tok, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..78080af --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,195 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "syscall" + "testing" + + "github.com/code6x/pullgate/internal/testgit" +) + +func TestMain(m *testing.M) { + syscall.Umask(0o077) + os.Exit(m.Run()) +} + +const good = ` +[repo] +url = "ssh://git@git.example.com/owner/config-repo.git" +deploy_key = "/etc/pullgate/x.key" +known_hosts = "/etc/pullgate/x.known_hosts" +[commands] +apply = ["/usr/local/sbin/install-config"] +` + +func write(t *testing.T, body string, mode os.FileMode) string { + t.Helper() + d := testgit.PrivateDir(t) + if err := os.WriteFile(filepath.Join(d, "x.toml"), []byte(body), mode); err != nil { + t.Fatal(err) + } + if err := os.Chmod(filepath.Join(d, "x.toml"), mode); err != nil { + t.Fatal(err) + } + return d +} + +func uid() uint32 { return uint32(os.Geteuid()) } + +func TestDefaults(t *testing.T) { + c, err := Load(write(t, good, 0o600), "x", uid()) + if err != nil { + t.Fatal(err) + } + if c.Mode != "ask" || c.OnFailure != "pause" || c.Limits.QuestionsPerDay != 10 || c.Limits.MaxFailedTries != 1 || + c.Limits.ApprovalTTL.Hours() != 12 || c.Limits.AllowOlder || c.Commands.CheckUser != "pullgate-check" { + t.Fatalf("defaults: %+v", c) + } +} + +func TestRefused(t *testing.T) { + cases := map[string]string{ + "unknown key": good + "\n[limits]\nquestions = 3\n", + "bad mode": `mode = "yes"` + good, + "bad on_failure": `on_failure = "retry"` + good, + "relative program": strings.Replace(good, "/usr/local/sbin/install-config", "install-config", 1), + "no apply": strings.Replace(good, `apply = ["/usr/local/sbin/install-config"]`, "", 1), + "check_user root": good + "check_user = \"root\"\n", + "https url": strings.Replace(good, "ssh://", "https://", 1), + "url as option": strings.Replace(good, "ssh://git@git.example.com/owner/config-repo.git", "--upload-pack=x", 1), + "branch option": good + "[repo]\n", + "ttl too long": good + "[limits]\napproval_ttl = \"200h\"\n", + "absolute watch path": good + "[watch]\npaths = [\"/etc/x\"]\n", + "host html": `host = ""` + good, + } + for n, body := range cases { + t.Run(n, func(t *testing.T) { + if _, err := Load(write(t, body, 0o600), "x", uid()); err == nil { + t.Fatal("must be refused") + } + }) + } +} + +func TestFileChecks(t *testing.T) { + d := write(t, good, 0o666) + if _, err := Load(d, "x", uid()); err == nil { + t.Fatal("a config that others can write must be refused") + } + d = write(t, good, 0o600) + if _, err := Load(d, "x", uid()+1); err == nil { + t.Fatal("a config of another user must be refused") + } + if _, err := Load(d, "../x", uid()); err == nil { + t.Fatal("a bad name must be refused") + } +} + +func TestGateFiles(t *testing.T) { + d := testgit.PrivateDir(t) + key := filepath.Join(d, "k") + kh := filepath.Join(d, "kh") + prog := filepath.Join(d, "apply") + for _, p := range []string{key, kh, prog} { + if err := os.WriteFile(p, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + } + c := &Instance{Path: "x.toml", Repo: Repo{URL: "ssh://h/x", DeployKey: key, KnownHosts: kh}, Commands: Commands{Apply: []string{prog}}} + if err := c.CheckGateFiles(uid()); err != nil { + t.Fatal(err) + } + if err := os.Chmod(key, 0o640); err != nil { + t.Fatal(err) + } + if err := c.CheckGateFiles(uid()); err == nil { + t.Fatal("a deploy key readable by group must be refused") + } + _ = os.Chmod(key, 0o600) + if err := os.Chmod(prog, 0o722); err != nil { + t.Fatal(err) + } + if err := c.CheckGateFiles(uid()); err == nil { + t.Fatal("a program that others can write must be refused") + } + _ = os.Chmod(prog, 0o700) + if err := os.Chmod(d, 0o777); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(d, 0o700) }() + if err := c.CheckGateFiles(uid()); err == nil { + t.Fatal("a directory above that others can write must be refused") + } +} + +func TestListener(t *testing.T) { + d := testgit.PrivateDir(t) + tok := filepath.Join(d, "token") + p := filepath.Join(d, "listener.toml") + body := "token_file = \"" + tok + "\"\nowner_id = 1\nchat_id = 1\ninstances = [\"a\"]\n" + if err := os.WriteFile(p, []byte(body), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(tok, []byte("1:abc\n"), 0o600); err != nil { + t.Fatal(err) + } + c, err := LoadListener(p, uid()) + if err != nil { + t.Fatal(err) + } + if got, err := c.ReadToken(uid()); err != nil || got != "1:abc" { + t.Fatalf("token: %v", err) + } + if err := os.Chmod(tok, 0o644); err != nil { + t.Fatal(err) + } + if _, err := c.ReadToken(uid()); err == nil { + t.Fatal("a token file that others can read must be refused") + } + if err := os.WriteFile(p, []byte(body+"api_url = \"http://api.example.com\"\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadListener(p, uid()); err == nil { + t.Fatal("a plain http api_url must be refused") + } + if err := os.WriteFile(p, []byte("owner_id = 1\nchat_id = 1\ninstances = [\"a\"]\nowner = 2\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := LoadListener(p, uid()); err == nil { + t.Fatal("an unknown key must be refused") + } +} + +// TestExamples checks that each example config is valid and sets +// on_failure itself: rollback for the config file, pause for ansible. +func TestExamples(t *testing.T) { + for file, want := range map[string]string{ + "../../contrib/examples/config-file/config-repo.toml": OnFailureRollback, + "../../contrib/examples/ansible/playbooks.toml": OnFailurePause, + } { + b, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(b), "\non_failure = \""+want+"\"") { + t.Errorf("%s must set on_failure = %q", file, want) + } + c := &Instance{Path: file} + if err := decodeFile(file, c); err != nil { + t.Fatal(err) + } + c.defaults() + if err := c.validate(); err != nil { + t.Errorf("%s: %v", file, err) + } + if c.OnFailure != want { + t.Errorf("%s: on_failure %q", file, c.OnFailure) + } + } + l := &Listener{} + if err := decodeFile("../../contrib/examples/listener.toml", l); err != nil { + t.Fatal(err) + } +} diff --git a/internal/gate/apply.go b/internal/gate/apply.go new file mode 100644 index 0000000..8832572 --- /dev/null +++ b/internal/gate/apply.go @@ -0,0 +1,321 @@ +package gate + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "strings" + "syscall" + + "github.com/code6x/pullgate/internal/config" + "github.com/code6x/pullgate/internal/message" + "github.com/code6x/pullgate/internal/spool" + "github.com/code6x/pullgate/internal/state" +) + +// takeAuto reads and removes the auto approval: a root-owned regular file. +func (e *Env) takeAuto() (*spool.Approval, error) { + p := e.Dir.AutoFile() + f, err := os.OpenFile(p, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) //nolint:gosec // root's own state directory + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + _ = os.Remove(p) + return nil, fmt.Errorf("read the auto approval: %w", err) + } + defer func() { _ = f.Close() }() + _ = os.Remove(p) + fi, err := f.Stat() + if err != nil { + return nil, err + } + st, ok := fi.Sys().(*syscall.Stat_t) + if !fi.Mode().IsRegular() || !ok || st.Uid != e.UID || fi.Mode().Perm()&0o077 != 0 { + return nil, errors.New("the auto approval is not a private file of root; ignored") + } + b, err := io.ReadAll(io.LimitReader(f, 64)) + if err != nil { + return nil, err + } + return &spool.Approval{Code: strings.TrimSpace(string(b)), Action: "apply"}, nil +} + +// Apply is one "pullgate apply" (DESIGN.md sections 9 and 10). +func (e *Env) Apply(ctx context.Context) (Result, error) { + if err := e.Dir.Ensure(); err != nil { + return "", err + } + lock, err := e.Dir.Lock(true) + if err != nil { + return "", err + } + defer func() { _ = lock.Close() }() + st, err := e.Dir.Load() + if err != nil { + return "", err + } + if st.InProgress != nil { + return CutOff, e.cutOff(st) + } + paused, off := e.Dir.Paused(), e.Cfg.Mode == config.ModeOff + if paused || off { + return e.ignoreAll(st, paused) + } + res := Nothing + for { + a, err := e.next() + var bad badFile + if errors.As(err, &bad) { + e.Log.Warn("bad approval, removed", "err", err) + continue + } + if err != nil { + return "", err + } + if a == nil { + break + } + r, done, err := e.handle(ctx, st, *a) + if r != "" { + res = r + } + if err != nil || done { + if serr := e.Dir.Save(st); serr != nil && err == nil { + err = serr + } + return res, err + } + } + return res, e.Dir.Save(st) +} + +// badFile is a bad approval file; it was removed already. +type badFile struct{ error } + +// next returns the next approval: the auto approval first, then the inbox +// in name order. It returns nil when there is none. A bad file comes back +// as a badFile error; other errors mean the inbox cannot be read. +func (e *Env) next() (*spool.Approval, error) { + if a, err := e.takeAuto(); a != nil || err != nil { + if err != nil { + return nil, badFile{err} + } + return a, nil + } + es, err := e.Spool.TakeApprovals(e.Owners.ListenerUID, 1) + if err != nil { + return nil, err + } + if len(es) == 0 { + return nil, nil + } + if es[0].Err != nil { + return nil, badFile{fmt.Errorf("inbox file %s: %w", es[0].Name, es[0].Err)} + } + return &es[0].Approval, nil +} + +// ignoreAll removes every approval while the instance is paused or off. +func (e *Env) ignoreAll(st *state.State, paused bool) (Result, error) { + why := "the instance is off (mode = \"off\")." + res := Off + if paused { + why = "the instance is paused. Run as root: pullgate status " + e.Cfg.Name + " --resume" + res = Paused + } + for { + a, err := e.next() + var bad badFile + if errors.As(err, &bad) { + continue + } + if err != nil { + return "", err + } + if a == nil { + break + } + if i := st.FindCode(a.Code); i >= 0 { + q := st.Remove(i) + if m := st.Marks[q.SHA]; m != nil && m.Mark == state.MarkAsked { + delete(st.Marks, q.SHA) // ask again after resume + } + e.reply(q.ID, "The tap for "+code(q.SHA)+" was ignored: "+why) + } + e.Log.Info("approval ignored", "reason", string(res)) + } + return res, e.Dir.Save(st) +} + +// handle acts on one approval. done is true after an apply. +func (e *Env) handle(ctx context.Context, st *state.State, a spool.Approval) (Result, bool, error) { + i := st.FindCode(a.Code) + if i < 0 { + e.Log.Warn("approval with an unknown code ignored") + return Ignored, false, nil + } + now := e.now() + q := st.Remove(i) // the code works once + if now.Before(q.Asked) || !now.Before(q.Expires) { + if m := st.Marks[q.SHA]; m != nil && m.Mark == state.MarkAsked { + st.SetMark(q.SHA, state.MarkExpired, now) + } + e.Log.Warn("approval out of its time; ignored", "question", q.ID, "sha", q.SHA) + e.reply(q.ID, "The tap for "+code(q.SHA)+" came outside the time limit of the question, so it was ignored.") + return Ignored, false, e.Dir.Save(st) + } + if err := e.Dir.Save(st); err != nil { + return "", true, err + } + if a.Action == "skip" { + st.SetMark(q.SHA, state.MarkSkipped, now) + e.Log.Info("skipped", "sha", q.SHA) + e.reply(q.ID, "Skipped "+code(q.SHA)+". A new commit asks again.") + return Skipped, false, e.Dir.Save(st) + } + r, err := e.applySHA(ctx, st, q) + ran := r != Ignored && r != Refused + return r, ran || err != nil, err +} + +// applySHA applies the SHA of question q, taken from root's own state. +func (e *Env) applySHA(ctx context.Context, st *state.State, q state.Question) (Result, error) { + sha, now := q.SHA, e.now() + if sha == st.Applied { + e.reply(q.ID, code(sha)+" is applied already.") + return Ignored, nil + } + if st.Applied != "" && !e.Cfg.Limits.AllowOlder { + ok, err := e.Git.IsAncestor(ctx, st.Applied, sha) + if err != nil { + return "", err + } + if !ok { + st.SetMark(sha, state.MarkRefused, now) + e.Log.Warn("refused: not a descendant of the applied SHA", "sha", sha, "applied", st.Applied) + e.reply(q.ID, "Refused "+code(sha)+": it is not a descendant of the applied commit "+code(st.Applied)+".") + return Refused, nil + } + } + old := st.Applied + st.InProgress = &state.InProgress{Phase: "apply", SHA: sha, OldSHA: old, Started: now} + if err := e.Dir.Save(st); err != nil { + return "", err + } + t, err := e.newTrees() + if err != nil { + return "", err + } + defer t.remove() + + err = e.tryApply(ctx, t, "apply", sha, old, st.Held, false) + if stopped(ctx, err) { + return "", err // in_progress stays: the next start pauses + } + if err == nil { + st.Applied, st.AppliedAt = sha, e.now() + st.InProgress, st.Held = nil, "" + st.Marks = map[string]*state.Mark{} + if gerr := e.Git.SetApplied(ctx, sha); gerr != nil { + e.Log.Warn("keep the applied ref", "err", gerr) + } + e.afterApply(st) + if err := e.Dir.Save(st); err != nil { + return "", err + } + e.Log.Info("applied", "sha", sha) + e.reply(q.ID, "✅ Applied "+code(sha)+".") + return Applied, nil + } + + m := st.SetMark(sha, state.MarkFailed, e.now()) + m.Tries++ + if err := e.Dir.Save(st); err != nil { + return "", err + } + failure := esc(err) + if e.Cfg.OnFailure == config.OnFailureRollback && old != "" { + st.InProgress = &state.InProgress{Phase: "rollback", SHA: sha, OldSHA: old, Started: e.now()} + if err := e.Dir.Save(st); err != nil { + return "", err + } + rerr := e.tryApply(ctx, t, "rb", old, sha, sha, true) + if stopped(ctx, rerr) { + return "", rerr + } + if rerr == nil { + st.InProgress = nil + if err := e.Dir.Save(st); err != nil { + return "", err + } + e.reply(q.ID, "❌ The apply of "+code(sha)+" failed ("+failure+"). Rolled back to "+code(old)+".") + return RolledBack, nil + } + if perr := e.Dir.Pause("the rollback to " + old + " failed after the apply of " + sha + " failed"); perr != nil { + // in_progress stays, so the next start pauses (cutOff). + e.Log.Error("write the pause file", "err", perr) + e.reply(q.ID, "❌ The apply of "+code(sha)+" failed, the rollback failed too, and the pause file could not be written. Check the host now.") + return RollbackError, perr + } + st.InProgress = nil + if err := e.Dir.Save(st); err != nil { + return "", err + } + e.reply(q.ID, "❌ The apply of "+code(sha)+" failed ("+failure+"), and the rollback to "+code(old)+" failed too ("+esc(rerr)+"). The instance is paused. Check the host, then run as root: pullgate status "+e.Cfg.Name+" --resume") + return RollbackError, nil + } + // on_failure = "pause", or nothing to roll back to: hold the SHA. + st.Held = sha + st.InProgress = nil + if err := e.Dir.Save(st); err != nil { + return "", err + } + e.reply(q.ID, "❌ The apply of "+code(sha)+" failed ("+failure+"). It is held and not tried again. A newer commit will be asked, so you can fix it forward.") + return Held, nil +} + +// stopped reports whether err comes from a stop of pullgate itself. +func stopped(ctx context.Context, err error) bool { + return err != nil && (ctx.Err() != nil || errors.Is(err, ErrStopped)) +} + +// tryApply exports fresh trees and runs apply, then health. +func (e *Env) tryApply(ctx context.Context, t *trees, tag, sha, old, failed string, rollback bool) error { + p, err := e.prepare(ctx, t, tag, sha, old) + if err != nil { + return fmt.Errorf("prepare: %w", err) + } + p.env.Failed = failed + p.env.Rollback = rollback + if err := e.runRoot(ctx, "apply", e.Cfg.Commands.Apply, p.env); err != nil { + return err + } + if argv := e.Cfg.Commands.Health; len(argv) > 0 { + if err := e.runRoot(ctx, "health", argv, p.env); err != nil { + return err + } + } + return nil +} + +// afterApply closes questions whose base is no longer the applied SHA, so +// the next run asks again with a right summary. +func (e *Env) afterApply(st *state.State) { + keep := st.Questions[:0] + for _, q := range st.Questions { + if q.Base == st.Applied { + keep = append(keep, q) + continue + } + if !q.Auto { + e.edit(q.ID, "This question for "+code(q.SHA)+" is closed: the applied commit changed. The next run asks again.") + } + } + st.Questions = keep +} + +func esc(err error) string { return message.Esc(err.Error()) } diff --git a/internal/gate/env.go b/internal/gate/env.go new file mode 100644 index 0000000..c77201e --- /dev/null +++ b/internal/gate/env.go @@ -0,0 +1,335 @@ +// Package gate is root's side: "pullgate run", "pullgate apply" and +// "pullgate status" (DESIGN.md sections 7, 9, 10 and 13). +package gate + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "os/user" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/code6x/pullgate/internal/config" + "github.com/code6x/pullgate/internal/gitx" + "github.com/code6x/pullgate/internal/spool" + "github.com/code6x/pullgate/internal/state" +) + +// Sandbox is the user that runs check and summary (rule 3). +type Sandbox struct { + UID, GID int + Switch bool // set the credential (pullgate runs as root) +} + +// ResolveSandbox finds check_user. As root (euid 0), the user must not be +// root, and the commands switch to it. Not as root, pullgate cannot switch +// user, so check_user must be the user that runs pullgate. +func ResolveSandbox(euid int, name string) (Sandbox, error) { + u, err := user.Lookup(name) + if err != nil { + return Sandbox{}, fmt.Errorf("check_user %q: %w. Fix: make the user (useradd --system %s), or set [commands] check_user", name, err, name) + } + uid, err1 := strconv.Atoi(u.Uid) + gid, err2 := strconv.Atoi(u.Gid) + if err1 != nil || err2 != nil { + return Sandbox{}, fmt.Errorf("check_user %q has no numeric uid or gid", name) + } + if uid == 0 || gid == 0 { + return Sandbox{}, fmt.Errorf("check_user %q is root (uid or gid 0); check and summary must never run as root (rule 3). Fix: set [commands] check_user to an unprivileged user", name) + } + if euid == 0 { + return Sandbox{UID: uid, GID: gid, Switch: true}, nil + } + if uid != euid { + return Sandbox{}, fmt.Errorf("pullgate does not run as root, so it cannot run check as %q. Fix: run it as root, or set check_user to the user that runs it", name) + } + return Sandbox{UID: uid, GID: gid}, nil +} + +// ResolveOwners finds the listener user, for the spool. +func ResolveOwners(name string) (spool.Owners, error) { + u, err := user.Lookup(name) + if err != nil { + return spool.Owners{}, fmt.Errorf("listener user %q: %w. Fix: make the user (useradd --system %s), or set [listener] user", name, err, name) + } + uid, err1 := strconv.Atoi(u.Uid) + gid, err2 := strconv.Atoi(u.Gid) + if err1 != nil || err2 != nil { + return spool.Owners{}, fmt.Errorf("listener user %q has no numeric uid or gid", name) + } + if uid == 0 && os.Geteuid() == 0 { + return spool.Owners{}, fmt.Errorf("the listener user %q is root; the listener must run as its own user (rule 4). Fix: set [listener] user", name) + } + return spool.Owners{ListenerUID: uid, ListenerGID: gid}, nil +} + +// Env holds what root needs for one instance. +type Env struct { + Cfg *config.Instance + Dir state.Dir + Spool spool.Dirs + UID uint32 // the user that owns the gate files (root) + Owners spool.Owners + Sandbox Sandbox + Git *gitx.Git + Log *slog.Logger + CmdOut io.Writer // where the output of apply and health goes (the journal) + Now func() time.Time +} + +// New makes an Env. Paths: the state of NAME is stateBase/NAME, the spool +// is spoolBase/NAME. +func New(cfg *config.Instance, stateBase, spoolBase string, uid uint32, log *slog.Logger, cmdOut io.Writer) (*Env, error) { + sb, err := ResolveSandbox(int(uid), cfg.Commands.CheckUser) + if err != nil { + return nil, fmt.Errorf("%s: %w", cfg.Path, err) + } + owners, err := ResolveOwners(cfg.Listener.User) + if err != nil { + return nil, fmt.Errorf("%s: %w", cfg.Path, err) + } + if uid == 0 && (sb.UID == owners.ListenerUID || sb.GID == owners.ListenerGID) { + return nil, fmt.Errorf("%s: check_user and the listener user must be different users with different groups: a check that is taken over could write approvals (THREAT-MODEL.md T2). Fix: set [commands] check_user to its own user", cfg.Path) + } + if err := cfg.CheckGateFiles(uid); err != nil { + return nil, err + } + dir := state.Dir{Path: filepath.Join(stateBase, cfg.Name)} + return &Env{ + Cfg: cfg, Dir: dir, Spool: spool.Dirs{Base: spoolBase, Name: cfg.Name}, + UID: uid, Owners: owners, Sandbox: sb, Log: log, CmdOut: cmdOut, + Git: &gitx.Git{Dir: dir.Repo(), Home: dir.Path, UID: uid, Key: cfg.Repo.DeployKey, KnownHosts: cfg.Repo.KnownHosts}, + }, nil +} + +func (e *Env) now() time.Time { + if e.Now != nil { + return e.Now() + } + return time.Now() +} + +// MaxCmdOutput is the most output kept from a command. +const MaxCmdOutput = 64 << 10 + +type capBuf struct { + b bytes.Buffer + cut bool +} + +func (c *capBuf) Write(p []byte) (int, error) { + if room := MaxCmdOutput - c.b.Len(); room > 0 { + if len(p) > room { + c.b.Write(p[:room]) + c.cut = true + } else { + c.b.Write(p) + } + } else { + c.cut = true + } + return len(p), nil +} + +// cmdEnv is the environment of a command (DESIGN.md section 11). +type cmdEnv struct { + SHA, OldSHA, Tree, OldTree, Changed, Failed string + Rollback bool +} + +func (e *Env) environ(c cmdEnv) []string { + rb := "" + if c.Rollback { + rb = "1" + } + return []string{ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "PULLGATE_INSTANCE=" + e.Cfg.Name, + "PULLGATE_SHA=" + c.SHA, + "PULLGATE_OLD_SHA=" + c.OldSHA, + "PULLGATE_TREE=" + c.Tree, + "PULLGATE_OLD_TREE=" + c.OldTree, + "PULLGATE_CHANGED_FILES=" + c.Changed, + "PULLGATE_FAILED_SHA=" + c.Failed, + "PULLGATE_ROLLBACK=" + rb, + } +} + +// command builds the exec.Cmd of argv. With sandbox it runs as check_user +// with no extra groups (rule 3). +func (e *Env) command(ctx context.Context, argv []string, c cmdEnv, sandbox bool) *exec.Cmd { + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) //nolint:gosec // argv comes from the host config (rule 1) + cmd.Env = e.environ(c) + cmd.Dir = c.Tree + attr := &syscall.SysProcAttr{Setpgid: true} + if sandbox && e.Sandbox.Switch { + attr.Credential = &syscall.Credential{Uid: uint32(e.Sandbox.UID), Gid: uint32(e.Sandbox.GID), Groups: []uint32{}} //nolint:gosec // checked ids + } + cmd.SysProcAttr = attr + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = 5 * time.Second + return cmd +} + +// runSandboxed runs check or summary as check_user and returns its output. +func (e *Env) runSandboxed(parent context.Context, argv []string, c cmdEnv) (string, error) { + ctx, cancel := context.WithTimeout(parent, e.Cfg.Commands.CheckTimeout.Duration) + defer cancel() + cmd := e.command(ctx, argv, c, true) + out := &capBuf{} + cmd.Stdout = out + cmd.Stderr = out + err := cmd.Run() + e.killSandbox() + if err != nil && parent.Err() != nil { + return "", fmt.Errorf("stopped: %w", parent.Err()) + } + if ctx.Err() != nil && err != nil { + err = fmt.Errorf("timed out after %s", e.Cfg.Commands.CheckTimeout.Duration) + } + return out.b.String(), err +} + +// killSandbox stops every process of check_user, so that nothing of a +// check lives on to change a later check or summary. +func (e *Env) killSandbox() { + if !e.Sandbox.Switch { + return + } + kill, err := exec.LookPath("kill") + if err != nil { + e.Log.Warn("no kill program; processes of check_user may live on", "err", err) + return + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, kill, "-KILL", "-1") //nolint:gosec // fixed program + cmd.Env = []string{"PATH=/usr/bin:/bin"} + cmd.Dir = "/" + cmd.SysProcAttr = &syscall.SysProcAttr{Credential: &syscall.Credential{Uid: uint32(e.Sandbox.UID), Gid: uint32(e.Sandbox.GID), Groups: []uint32{}}} //nolint:gosec // checked ids + _ = cmd.Run() +} + +// runRoot runs apply or health as root. The output goes to the journal. +func (e *Env) runRoot(parent context.Context, what string, argv []string, c cmdEnv) error { + ctx, cancel := context.WithTimeout(parent, e.Cfg.Commands.ApplyTimeout.Duration) + defer cancel() + cmd := e.command(ctx, argv, c, false) + w := e.CmdOut + if w == nil { + w = io.Discard + } + cmd.Stdout = w + cmd.Stderr = w + e.Log.Info("start "+what, "sha", c.SHA, "rollback", c.Rollback) + err := cmd.Run() + if err != nil && parent.Err() != nil { + return fmt.Errorf("%s stopped: %w", what, ErrStopped) + } + if ctx.Err() != nil && err != nil { + err = fmt.Errorf("timed out after %s", e.Cfg.Commands.ApplyTimeout.Duration) + } + if err != nil { + e.Log.Warn(what+" failed", "sha", c.SHA, "err", err) + return fmt.Errorf("%s: %w", what, err) + } + e.Log.Info(what+" done", "sha", c.SHA) + return nil +} + +// ErrStopped means pullgate itself was stopped (a signal) during a +// command. The result of an apply is then unknown: in_progress stays, and +// the next start pauses (DESIGN.md section 10). +var ErrStopped = errors.New("pullgate was stopped") + +// trees is the per-process directory of trees. +type trees struct { + dir string +} + +// newTrees removes trees that an earlier process left, and makes a new +// directory for this process. The caller holds the lock. +func (e *Env) newTrees() (*trees, error) { + if err := e.Dir.Ensure(); err != nil { + return nil, err + } + old, _ := filepath.Glob(filepath.Join(e.Dir.Trees(), "*")) + hidden, _ := filepath.Glob(filepath.Join(e.Dir.Trees(), ".*")) + for _, p := range append(old, hidden...) { + if err := gitx.RemoveTree(p); err != nil { + return nil, fmt.Errorf("remove an old tree %s: %w", p, err) + } + } + d, err := os.MkdirTemp(e.Dir.Trees(), "p-") + if err != nil { + return nil, err + } + if err := os.Chmod(d, 0o711); err != nil { + return nil, err + } + return &trees{dir: d}, nil +} + +func (t *trees) remove() { _ = gitx.RemoveTree(t.dir) } + +func (e *Env) gid() int { + if e.Sandbox.Switch { + return e.Sandbox.GID + } + return -1 +} + +func (e *Env) limits() gitx.Limits { + return gitx.Limits{MaxFiles: e.Cfg.Limits.MaxFiles, MaxBytes: e.Cfg.Limits.MaxTreeBytes} +} + +// prepared is a SHA with its trees and the list of changed files. +type prepared struct { + env cmdEnv + files []string + tree *gitx.Tree +} + +// prepare exports sha (and old, if set) into t under the name tag, and +// writes the changed files list. +func (e *Env) prepare(ctx context.Context, t *trees, tag, sha, old string) (*prepared, error) { + p := &prepared{env: cmdEnv{SHA: sha, OldSHA: old}} + var err error + p.env.Tree = filepath.Join(t.dir, tag+"-new") + p.tree, err = e.Git.Export(ctx, sha, p.env.Tree, e.limits(), e.gid()) + if err != nil { + return nil, err + } + if e.Cfg.Limits.RefuseSymlinks && len(p.tree.Symlinks) > 0 { + return nil, &gitx.HostileError{Msg: fmt.Sprintf("the commit has a symlink out of the tree (%s), and refuse_symlinks is on", p.tree.Symlinks[0].Path)} + } + if old != "" { + p.env.OldTree = filepath.Join(t.dir, tag+"-old") + if _, err := e.Git.Export(ctx, old, p.env.OldTree, e.limits(), e.gid()); err != nil { + return nil, err + } + } + p.files, err = e.Git.ChangedFiles(ctx, old, sha) + if err != nil { + return nil, err + } + var b strings.Builder + for _, f := range p.files { + b.WriteString(f) + b.WriteByte(0) + } + p.env.Changed = filepath.Join(t.dir, tag+"-changed") + if err := os.WriteFile(p.env.Changed, []byte(b.String()), 0o444); err != nil { //nolint:gosec // read-only list for the commands + return nil, err + } + return p, nil +} diff --git a/internal/gate/fixture_test.go b/internal/gate/fixture_test.go new file mode 100644 index 0000000..6ecee75 --- /dev/null +++ b/internal/gate/fixture_test.go @@ -0,0 +1,235 @@ +package gate + +import ( + "context" + "fmt" + "io" + "log/slog" + "os" + "os/user" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/code6x/pullgate/internal/config" + "github.com/code6x/pullgate/internal/spool" + "github.com/code6x/pullgate/internal/state" + "github.com/code6x/pullgate/internal/testgit" +) + +func TestMain(m *testing.M) { + syscall.Umask(0o077) + os.Exit(m.Run()) +} + +type fixture struct { + t *testing.T + r *testgit.Remote + base string + ctrl string // files that the test scripts read and write + cfg string // the config dir + env *Env + now time.Time +} + +const name = "config-repo" + +// newFixture makes a remote, scripts, a config and an Env. extra is TOML +// added to the config: top-level keys first, then sections. +func newFixture(t *testing.T, top, sections string) *fixture { + t.Helper() + testgit.NeedGit(t) + f := &fixture{t: t, r: testgit.New(t), base: testgit.PrivateDir(t), now: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)} + f.ctrl = filepath.Join(f.base, "ctrl") + f.cfg = filepath.Join(f.base, "etc") + for _, d := range []string{f.ctrl, f.cfg, filepath.Join(f.base, "bin"), filepath.Join(f.base, "state"), filepath.Join(f.base, "spool")} { + if err := os.Mkdir(d, 0o700); err != nil { + t.Fatal(err) + } + } + c := f.ctrl + f.script("apply.sh", `echo "apply $PULLGATE_SHA old=$PULLGATE_OLD_SHA rb=$PULLGATE_ROLLBACK failed=$PULLGATE_FAILED_SHA" >> `+c+`/log +if [ "$PULLGATE_ROLLBACK" = 1 ] && [ -e `+c+`/fail-rollback ]; then exit 3; fi +if [ -e "$PULLGATE_TREE/fail-apply" ]; then exit 2; fi +tr '\0' '\n' < "$PULLGATE_CHANGED_FILES" > `+c+`/changed +cat "$PULLGATE_TREE/value" > `+c+`/live +`) + f.script("health.sh", `if [ -e "$PULLGATE_TREE/fail-health" ]; then exit 1; fi +`) + f.script("check.sh", `id -u > `+c+`/check-uid +echo x >> `+c+`/check-count +if [ -e "$PULLGATE_TREE/fail-check" ]; then echo "bad config "; exit 1; fi +`) + f.script("summary.sh", `id -u > `+c+`/summary-uid +echo "value $(cat "$PULLGATE_TREE/value")" +if [ -n "$PULLGATE_OLD_TREE" ]; then echo "old value $(cat "$PULLGATE_OLD_TREE/value")"; fi +`) + u, err := user.Current() + if err != nil { + t.Fatal(err) + } + b := filepath.Join(f.base, "bin") + toml := fmt.Sprintf(`host = "example-host" +%s +[repo] +url = %q +branch = "main" + +[commands] +check = [%q] +summary = [%q] +apply = [%q] +health = [%q] +check_user = %q + +[listener] +user = %q + +[watch] +paths = ["policy/**"] +%s +`, top, f.r.Bare, b+"/check.sh", b+"/summary.sh", b+"/apply.sh", b+"/health.sh", u.Username, u.Username, sections) + if err := os.WriteFile(filepath.Join(f.cfg, name+".toml"), []byte(toml), 0o600); err != nil { + t.Fatal(err) + } + f.load() + return f +} + +func (f *fixture) load() { + f.t.Helper() + uid := uint32(os.Geteuid()) + cfg, err := config.Load(f.cfg, name, uid) + if err != nil { + f.t.Fatal(err) + } + env, err := New(cfg, filepath.Join(f.base, "state"), filepath.Join(f.base, "spool"), uid, slog.New(slog.NewTextHandler(io.Discard, nil)), io.Discard) + if err != nil { + f.t.Fatal(err) + } + env.Now = func() time.Time { return f.now } + f.env = env +} + +func (f *fixture) script(n, body string) { + f.t.Helper() + if err := os.WriteFile(filepath.Join(f.base, "bin", n), []byte("#!/bin/sh\n"+body), 0o700); err != nil { + f.t.Fatal(err) + } +} + +func (f *fixture) commit(subject, value string, more map[string]string) string { + f.t.Helper() + files := map[string]string{"value": value + "\n"} + for k, v := range more { + files[k] = v + } + return f.r.Commit(subject, files) +} + +func (f *fixture) run() Result { + f.t.Helper() + res, err := f.env.Run(context.Background()) + if err != nil { + f.t.Fatalf("run: %v", err) + } + return res +} + +func (f *fixture) apply() Result { + f.t.Helper() + res, err := f.env.Apply(context.Background()) + if err != nil { + f.t.Fatalf("apply: %v", err) + } + return res +} + +// outbox reads and removes the outbox, as the listener would. +func (f *fixture) outbox() []spool.Message { + f.t.Helper() + files, err := f.env.Spool.ReadOutbox() + if err != nil { + f.t.Fatal(err) + } + var out []spool.Message + for _, of := range files { + if of.Err != nil { + f.t.Fatal(of.Err) + } + out = append(out, of.Message) + if err := f.env.Spool.Done(of.Name); err != nil { + f.t.Fatal(err) + } + } + return out +} + +// ask returns the one ask message in the outbox (and drops the rest). +func (f *fixture) ask() spool.Message { + f.t.Helper() + var asks []spool.Message + for _, m := range f.outbox() { + if m.Kind == spool.KindAsk { + asks = append(asks, m) + } + } + if len(asks) != 1 { + f.t.Fatalf("want one question, got %d", len(asks)) + } + return asks[0] +} + +func (f *fixture) tap(code, action string) { + f.t.Helper() + if err := f.env.Spool.WriteApproval(spool.Approval{Code: code, Action: action}); err != nil { + f.t.Fatal(err) + } +} + +func (f *fixture) state() *state.State { + f.t.Helper() + st, err := f.env.Dir.Load() + if err != nil { + f.t.Fatal(err) + } + return st +} + +func (f *fixture) read(n string) string { + b, _ := os.ReadFile(filepath.Join(f.ctrl, n)) + return strings.TrimSpace(string(b)) +} + +func (f *fixture) touch(n string) { + f.t.Helper() + if err := os.WriteFile(filepath.Join(f.ctrl, n), nil, 0o600); err != nil { + f.t.Fatal(err) + } +} + +// applyNew commits a value, asks, taps Apply and applies it. +func (f *fixture) applyNew(value string) string { + f.t.Helper() + sha := f.commit("set "+value, value, nil) + if r := f.run(); r != Asked { + f.t.Fatalf("run: %s, want asked", r) + } + f.tap(f.ask().Code, "apply") + if r := f.apply(); r != Applied { + f.t.Fatalf("apply: %s, want applied", r) + } + f.outbox() + return sha +} + +func contains(ms []spool.Message, s string) bool { + for _, m := range ms { + if strings.Contains(m.Text, s) { + return true + } + } + return false +} diff --git a/internal/gate/gate_test.go b/internal/gate/gate_test.go new file mode 100644 index 0000000..bd9dcc6 --- /dev/null +++ b/internal/gate/gate_test.go @@ -0,0 +1,732 @@ +package gate + +import ( + "context" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/code6x/pullgate/internal/spool" + "github.com/code6x/pullgate/internal/state" +) + +func TestFirstRunAsksWithAllFilesThenApplies(t *testing.T) { + f := newFixture(t, `on_failure = "rollback"`, "") + sha := f.commit("first", "one", map[string]string{"policy/main.toml": "x = 1\n"}) + if r := f.run(); r != Asked { + t.Fatalf("run: %s", r) + } + q := f.ask() + for _, want := range []string{"" + sha + "", "First apply: there is no applied commit yet. All 2 files are listed.", + "⚠ Watched path changed: policy/main.toml", "value one", "example-host", "config-repo"} { + if !strings.Contains(q.Text, want) { + t.Errorf("the question misses %q:\n%s", want, q.Text) + } + } + if strings.Contains(q.Text, sha[:0]+"old value") { + t.Error("no old tree on the first run") + } + if len(q.Code) != 22 { + t.Fatalf("code %q", q.Code) + } + f.tap(q.Code, "apply") + if r := f.apply(); r != Applied { + t.Fatalf("apply: %s", r) + } + if f.read("live") != "one" || f.state().Applied != sha { + t.Fatalf("not applied: live %q, state %q", f.read("live"), f.state().Applied) + } + if !contains(f.outbox(), "✅ Applied "+sha) { + t.Error("no report") + } + // The next run has nothing to do. + if r := f.run(); r != Nothing { + t.Fatalf("run after apply: %s", r) + } +} + +func TestSecondCommitGetsOldTreeAndForwardOnly(t *testing.T) { + f := newFixture(t, ``, "") + a := f.applyNew("one") + b := f.commit("second", "two", nil) + if r := f.run(); r != Asked { + t.Fatalf("run: %s", r) + } + q := f.ask() + if !strings.Contains(q.Text, "Since "+a+"") || !strings.Contains(q.Text, "old value one") || !strings.Contains(q.Text, "• second") { + t.Fatalf("the question must show the old tree and the commits:\n%s", q.Text) + } + f.tap(q.Code, "apply") + if r := f.apply(); r != Applied || f.read("live") != "two" { + t.Fatalf("apply: %s, live %q", r, f.read("live")) + } + if !strings.Contains(f.read("log"), "apply "+b+" old="+a) { + t.Fatalf("PULLGATE_OLD_SHA: %s", f.read("log")) + } + if f.read("changed") != "value" { + t.Fatalf("PULLGATE_CHANGED_FILES: %q", f.read("changed")) + } + // An older SHA is refused: push a back. + f.outbox() + f.r.Push(a) + if r := f.run(); r != Refused { + t.Fatalf("an older commit must be refused, got %s", r) + } + ms := f.outbox() + if !contains(ms, "Refused "+a) || contains(ms, "Apply this commit?") { + t.Fatal("a refusal sends a notice and no question") + } + // It is not offered again. + if r := f.run(); r != Nothing || len(f.outbox()) != 0 { + t.Fatalf("an old commit is not offered again: %s", r) + } +} + +func TestForcePushRefusedUnlessAllowOlder(t *testing.T) { + f := newFixture(t, ``, "") + f.applyNew("one") + f.r.Git("checkout", "--quiet", "--orphan", "other") + f.commit("rewritten", "evil", nil) + if r := f.run(); r != Refused { + t.Fatalf("a rewritten branch must be refused, got %s", r) + } + f.outbox() + + g := newFixture(t, ``, "[limits]\nallow_older = true\n") + g.applyNew("one") + g.r.Git("checkout", "--quiet", "--orphan", "other") + other := g.commit("rewritten", "two", nil) + if r := g.run(); r != Asked { + t.Fatalf("with allow_older it is asked, got %s", r) + } + q := g.ask() + if !strings.Contains(q.Text, "⚠ This commit is not a descendant of the applied commit.") { + t.Fatalf("the warning is missing:\n%s", q.Text) + } + g.tap(q.Code, "apply") + if r := g.apply(); r != Applied || g.state().Applied != other { + t.Fatalf("apply: %s", r) + } +} + +func TestRootTakesSHAFromItsOwnState(t *testing.T) { + f := newFixture(t, ``, "") + a := f.commit("first", "one", nil) + f.run() + q := f.ask() + // A newer commit after the question does not change what is applied. + f.commit("second", "two", nil) + f.tap(q.Code, "apply") + if r := f.apply(); r != Applied { + t.Fatalf("apply: %s", r) + } + if f.state().Applied != a || f.read("live") != "one" { + t.Fatalf("applied %s (live %q), want the asked SHA %s", f.state().Applied, f.read("live"), a) + } +} + +func TestTapOnSupersededQuestionAppliesOldSHA(t *testing.T) { + f := newFixture(t, ``, "") + a := f.commit("first", "one", nil) + f.run() + q1 := f.ask() + b := f.commit("second", "two", nil) + if r := f.run(); r != Asked { + t.Fatalf("run: %s", r) + } + ms := f.outbox() + if !contains(ms, "a newer commit replaced this question") { + t.Fatal("the old question must be closed") + } + // The listener took the tap on q1 before it handled the close. + f.tap(q1.Code, "apply") + if r := f.apply(); r != Applied || f.state().Applied != a { + t.Fatalf("the old SHA must be applied (rule 2): %s %s", r, f.state().Applied) + } + // The question for b had base "" and is closed, so the next run asks + // again with a right summary. + if !contains(f.outbox(), "the applied commit changed") { + t.Fatal("the stale question must be closed") + } + if r := f.run(); r != Asked { + t.Fatalf("run: %s", r) + } + q := f.ask() + if !strings.Contains(q.Text, ""+b+"") || !strings.Contains(q.Text, "Since "+a) { + t.Fatalf("the new question is wrong:\n%s", q.Text) + } +} + +func TestUnknownCodeAndReplayAreIgnored(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + f.run() + q := f.ask() + f.tap("AAAAAAAAAAAAAAAAAAAAAA", "apply") + if r := f.apply(); r != Ignored || f.state().Applied != "" { + t.Fatalf("an unknown code must be ignored: %s", r) + } + f.tap(q.Code, "apply") + f.apply() + f.commit("second", "two", nil) + // A replay of the used code does nothing. + f.tap(q.Code, "apply") + if r := f.apply(); r != Ignored || f.read("live") != "one" { + t.Fatalf("a replayed code must be ignored: %s", r) + } +} + +func TestBadApprovalFilesAreRemoved(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + f.run() + q := f.ask() + in := f.env.Spool.Inbox() + sha := f.state().Open().SHA + // An approval that names a SHA has an unknown field: refused. + bad := `{"code":"` + q.Code + `","action":"apply","sha":"` + sha + `"}` + if err := os.WriteFile(filepath.Join(in, "1.json"), []byte(bad), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(in, "2.json"), []byte(strings.Repeat("x", 5000)), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(f.base, "elsewhere"), filepath.Join(in, "3.json")); err != nil { + t.Fatal(err) + } + if r := f.apply(); r != Nothing || f.state().Applied != "" { + t.Fatalf("bad approvals must apply nothing: %s", r) + } + if left, _ := os.ReadDir(in); len(left) != 0 { + t.Fatalf("bad files must be removed: %v", left) + } + if f.state().Open() == nil { + t.Fatal("the question must stay open") + } +} + +func TestSkip(t *testing.T) { + f := newFixture(t, ``, "") + a := f.commit("first", "one", nil) + f.run() + f.tap(f.ask().Code, "skip") + if r := f.apply(); r != Skipped || f.state().Applied != "" { + t.Fatalf("skip: %s", r) + } + if r := f.run(); r != Nothing { + t.Fatalf("a skipped SHA is not asked again: %s", r) + } + f.outbox() + if err := f.env.Status(new(strings.Builder), StatusFlags{AskAgain: true}); err != nil { + t.Fatal(err) + } + if r := f.run(); r != Asked || f.ask().Code == "" { + t.Fatalf("--ask-again asks again: %s", r) + } + _ = a +} + +func TestExpiredApprovalIsIgnored(t *testing.T) { + f := newFixture(t, ``, "[limits]\napproval_ttl = \"1h\"\n") + f.commit("first", "one", nil) + f.run() + q := f.ask() + f.tap(q.Code, "apply") + f.now = f.now.Add(61 * time.Minute) + if r := f.apply(); r != Ignored || f.state().Applied != "" { + t.Fatalf("an expired approval must be ignored: %s", r) + } + if !contains(f.outbox(), "outside the time limit") { + t.Error("no report") + } + // An unanswered question that expired is not asked again. + if r := f.run(); r != Nothing { + t.Fatalf("run: %s", r) + } +} + +func TestClockBeforeAskedIsIgnored(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + f.run() + f.tap(f.ask().Code, "apply") + f.now = f.now.Add(-time.Hour) // the clock went back + if r := f.apply(); r != Ignored || f.state().Applied != "" { + t.Fatalf("a clock before the question must deny: %s", r) + } +} + +func TestExpiredQuestionIsClosedByRun(t *testing.T) { + f := newFixture(t, ``, "[limits]\napproval_ttl = \"1h\"\n") + f.commit("first", "one", nil) + f.run() + f.outbox() + f.now = f.now.Add(2 * time.Hour) + if r := f.run(); r != Nothing { + t.Fatalf("run: %s", r) + } + if !contains(f.outbox(), "expired") || f.state().Open() != nil { + t.Fatal("the expired question must be closed") + } + f.commit("second", "two", nil) + if r := f.run(); r != Asked { + t.Fatalf("a new commit asks again: %s", r) + } +} + +func TestFailingCheckSendsNoQuestionAndIsNotCheckedAgain(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", map[string]string{"fail-check": "1"}) + if r := f.run(); r != CheckFailed { + t.Fatalf("run: %s", r) + } + ms := f.outbox() + if contains(ms, "Apply this commit?") || !contains(ms, "The check of") || !contains(ms, "bad config <b>") { + t.Fatalf("a failed check sends a notice with its output, and no question: %v", ms) + } + if r := f.run(); r != Nothing { + t.Fatalf("second run: %s", r) + } + if got := strings.Count(f.read("check-count"), "x"); got != 1 { + t.Fatalf("the check ran %d times, want 1", got) + } +} + +func TestCheckAndSummaryRunAsCheckUser(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + f.run() + want := strconv.Itoa(f.env.Sandbox.UID) + if f.read("check-uid") != want || f.read("summary-uid") != want { + t.Fatalf("check uid %q, summary uid %q, want %s", f.read("check-uid"), f.read("summary-uid"), want) + } + if f.read("check-uid") == "0" { + t.Fatal("check ran as root") + } +} + +func TestResolveSandboxNeverRoot(t *testing.T) { + if _, err := ResolveSandbox(0, "root"); err == nil { + t.Fatal("check_user root must be refused") + } + if _, err := ResolveSandbox(1000, "root"); err == nil { + t.Fatal("check_user root must be refused, also when not root") + } + if _, err := ResolveSandbox(0, "no-such-user-pullgate"); err == nil { + t.Fatal("a missing user must be refused") + } + f := newFixture(t, ``, "") + me := f.env.Sandbox.UID + sb, err := ResolveSandbox(0, f.env.Cfg.Commands.CheckUser) + if me != 0 && (err != nil || !sb.Switch || sb.UID != me) { + t.Fatalf("as root, check switches to check_user: %+v %v", sb, err) + } + if _, err := ResolveSandbox(me+1, f.env.Cfg.Commands.CheckUser); err == nil { + t.Fatal("not as root, check_user must be the running user") + } + // The command of check and summary carries the credential. + f.env.Sandbox = Sandbox{UID: 4242, GID: 4343, Switch: true} + cmd := f.env.command(context.Background(), []string{"/bin/true"}, cmdEnv{Tree: "/"}, true) + c := cmd.SysProcAttr.Credential + if c == nil || c.Uid != 4242 || c.Gid != 4343 || len(c.Groups) != 0 { + t.Fatalf("credential %+v", c) + } + if cmd := f.env.command(context.Background(), []string{"/bin/true"}, cmdEnv{Tree: "/"}, false); cmd.SysProcAttr.Credential != nil { + t.Fatal("apply runs as root") + } + for _, kv := range cmd.Env { + if !strings.HasPrefix(kv, "PATH=") && !strings.HasPrefix(kv, "LANG=") && !strings.HasPrefix(kv, "PULLGATE_") { + t.Fatalf("unexpected env %q", kv) + } + } +} + +func TestRootRefusesGitInWritableDir(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + f.run() + if err := os.Chmod(filepath.Join(f.env.Dir.Repo(), "config"), 0o666); err != nil { + t.Fatal(err) + } + f.commit("second", "two", nil) + if _, err := f.env.Run(context.Background()); err == nil || !strings.Contains(err.Error(), "can be written by group or others") { + t.Fatalf("root must refuse git there: %v", err) + } +} + +func TestFailingApplyRollsBack(t *testing.T) { + for _, fail := range []string{"fail-apply", "fail-health"} { + t.Run(fail, func(t *testing.T) { + f := newFixture(t, `on_failure = "rollback"`, "") + a := f.applyNew("one") + b := f.commit("bad", "two", map[string]string{fail: "1"}) + f.run() + f.tap(f.ask().Code, "apply") + if r := f.apply(); r != RolledBack { + t.Fatalf("apply: %s", r) + } + st := f.state() + if st.Applied != a || st.InProgress != nil || f.env.Dir.Paused() { + t.Fatalf("state after rollback: %+v", st) + } + if f.read("live") != "one" { + t.Fatalf("live %q, want the old value", f.read("live")) + } + if !strings.Contains(f.read("log"), "apply "+a+" old="+b+" rb=1 failed="+b) { + t.Fatalf("the rollback env is wrong:\n%s", f.read("log")) + } + if !contains(f.outbox(), "Rolled back to "+a) { + t.Fatal("no report") + } + // max_failed_tries = 1: not asked again. + if r := f.run(); r != Nothing { + t.Fatalf("run: %s", r) + } + }) + } +} + +func TestMaxFailedTries(t *testing.T) { + f := newFixture(t, `on_failure = "rollback"`, "[limits]\nmax_failed_tries = 2\n") + f.applyNew("one") + f.commit("bad", "two", map[string]string{"fail-apply": "1"}) + for try := 1; try <= 2; try++ { + if r := f.run(); r != Asked { + t.Fatalf("try %d: run %s", try, r) + } + f.tap(f.ask().Code, "apply") + if r := f.apply(); r != RolledBack { + t.Fatalf("try %d: apply %s", try, r) + } + f.outbox() + } + if r := f.run(); r != Nothing { + t.Fatalf("after max_failed_tries the SHA waits: %s", r) + } +} + +func TestFailingRollbackPauses(t *testing.T) { + f := newFixture(t, `on_failure = "rollback"`, "") + f.applyNew("one") + f.commit("bad", "two", map[string]string{"fail-health": "1"}) + f.run() + f.tap(f.ask().Code, "apply") + f.touch("fail-rollback") + if r := f.apply(); r != RollbackError { + t.Fatalf("apply: %s", r) + } + if !f.env.Dir.Paused() || f.state().InProgress != nil { + t.Fatal("a failed rollback must write the pause file") + } + if !contains(f.outbox(), "The instance is paused") { + t.Fatal("no report") + } + f.commit("fix", "three", nil) + if r := f.run(); r != Paused { + t.Fatalf("a paused instance does nothing: %s", r) + } +} + +func TestOnFailurePauseHoldsAndAsksNewer(t *testing.T) { + f := newFixture(t, `on_failure = "pause"`, "") + a := f.applyNew("one") + b := f.commit("bad", "two", map[string]string{"fail-apply": "1"}) + f.run() + f.tap(f.ask().Code, "apply") + if r := f.apply(); r != Held { + t.Fatalf("apply: %s", r) + } + if strings.Contains(f.read("log"), "rb=1") { + t.Fatal("pause must not roll back") + } + if f.env.Dir.Paused() || f.state().Held != b || f.state().Applied != a { + t.Fatalf("pause holds the SHA and writes no pause file: %+v", f.state()) + } + if !contains(f.outbox(), "It is held and not tried again") { + t.Fatal("no report") + } + if r := f.run(); r != Nothing { + t.Fatalf("the held SHA is not tried again: %s", r) + } + var out strings.Builder + if err := f.env.Status(&out, StatusFlags{}); err != nil || !strings.Contains(out.String(), "held:") { + t.Fatalf("status must show the held SHA: %s", out.String()) + } + c := f.commit("fix", "three", map[string]string{"fail-apply": ""}) + if r := f.run(); r != Asked { + t.Fatalf("a newer commit is asked: %s", r) + } + q := f.ask() + if !strings.Contains(q.Text, "⚠ The last apply ("+b+") failed.") { + t.Fatalf("the warning is missing:\n%s", q.Text) + } + f.tap(q.Code, "apply") + if r := f.apply(); r != Applied || f.state().Applied != c || f.state().Held != "" { + t.Fatalf("the fix must apply: %s %+v", r, f.state()) + } + if !strings.Contains(f.read("log"), "apply "+c+" old="+a+" rb= failed="+b) { + t.Fatalf("PULLGATE_FAILED_SHA: %s", f.read("log")) + } +} + +func TestCodeDefaultIsPause(t *testing.T) { + f := newFixture(t, ``, "") + if f.env.Cfg.OnFailure != "pause" { + t.Fatalf("the code default must be pause, got %q", f.env.Cfg.OnFailure) + } + f.applyNew("one") + f.commit("bad", "two", map[string]string{"fail-apply": "1"}) + f.run() + f.tap(f.ask().Code, "apply") + if r := f.apply(); r != Held { + t.Fatalf("apply: %s", r) + } +} + +func TestFirstApplyFailsWithRollbackHolds(t *testing.T) { + f := newFixture(t, `on_failure = "rollback"`, "") + f.commit("bad", "one", map[string]string{"fail-apply": "1"}) + f.run() + f.tap(f.ask().Code, "apply") + if r := f.apply(); r != Held || f.env.Dir.Paused() { + t.Fatalf("with nothing to roll back to, the SHA is held: %s", r) + } +} + +func TestPauseFile(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + f.run() + q := f.ask() + if err := f.env.Status(new(strings.Builder), StatusFlags{Pause: true}); err != nil { + t.Fatal(err) + } + if r := f.run(); r != Paused { + t.Fatalf("run: %s", r) + } + f.tap(q.Code, "apply") + if r := f.apply(); r != Paused || f.state().Applied != "" { + t.Fatalf("a paused instance must not apply: %s", r) + } + if !contains(f.outbox(), "ignored: the instance is paused") { + t.Fatal("no report") + } + if err := f.env.Status(new(strings.Builder), StatusFlags{Resume: true}); err != nil { + t.Fatal(err) + } + if r := f.run(); r != Asked { + t.Fatalf("after resume the tip is asked again: %s", r) + } +} + +func TestOffMode(t *testing.T) { + f := newFixture(t, `mode = "off"`, "") + f.commit("first", "one", nil) + if r := f.run(); r != Off { + t.Fatalf("run: %s", r) + } + if r := f.apply(); r != Off { + t.Fatalf("apply: %s", r) + } +} + +func TestLock(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + if err := f.env.Dir.Ensure(); err != nil { + t.Fatal(err) + } + l, err := f.env.Dir.Lock(false) + if err != nil { + t.Fatal(err) + } + if r := f.run(); r != Busy { + t.Fatalf("a second run must stop: %s", r) + } + _ = l.Close() + if r := f.run(); r != Asked { + t.Fatalf("run: %s", r) + } +} + +func TestQuestionsPerDay(t *testing.T) { + f := newFixture(t, ``, "[limits]\nquestions_per_day = 1\n") + f.commit("first", "one", nil) + if r := f.run(); r != Asked { + t.Fatalf("run: %s", r) + } + f.outbox() + f.commit("second", "two", nil) + if r := f.run(); r != LimitReached { + t.Fatalf("the limit must hold: %s", r) + } + if !contains(f.outbox(), "questions_per_day") { + t.Fatal("one notice") + } + f.run() + if len(f.outbox()) != 0 { + t.Fatal("only one notice per day") + } + f.now = f.now.Add(25 * time.Hour) + if r := f.run(); r != Asked { + t.Fatalf("a day later it asks: %s", r) + } +} + +func TestAutoMode(t *testing.T) { + f := newFixture(t, `mode = "auto"`, "") + sha := f.commit("first", "one", nil) + if r := f.run(); r != AutoQueued { + t.Fatalf("run: %s", r) + } + for _, m := range f.outbox() { + if m.Kind == spool.KindAsk { + t.Fatal("auto mode asks no question") + } + } + if r := f.apply(); r != Applied || f.state().Applied != sha { + t.Fatalf("apply: %s", r) + } + if _, err := os.Lstat(f.env.Dir.AutoFile()); err == nil { + t.Fatal("the auto approval must be removed") + } + // An auto approval that root did not write is refused. + f.commit("second", "two", nil) + f.run() + if err := os.Chmod(f.env.Dir.AutoFile(), 0o644); err != nil { + t.Fatal(err) + } + if r := f.apply(); r == Applied { + t.Fatal("an auto approval that others can read or write must be refused") + } +} + +func TestCrashBetweenApplyAndStateWrite(t *testing.T) { + f := newFixture(t, ``, "") + f.applyNew("one") + b := f.commit("second", "two", nil) + st := f.state() + st.InProgress = &state.InProgress{Phase: "apply", SHA: b, OldSHA: st.Applied, Started: f.now} + if err := f.env.Dir.Save(st); err != nil { + t.Fatal(err) + } + if r := f.run(); r != CutOff { + t.Fatalf("run: %s", r) + } + if !f.env.Dir.Paused() || f.state().InProgress != nil || f.state().Marks[b].Mark != state.MarkFailed { + t.Fatalf("a cut-off apply must pause and mark the SHA failed: %+v", f.state()) + } + if !contains(f.outbox(), "was cut off") { + t.Fatal("no report") + } +} + +func TestSymlinkWarningAndRefuse(t *testing.T) { + f := newFixture(t, ``, "") + if err := os.Symlink("/etc/shadow", filepath.Join(f.r.Work, "conf")); err != nil { + t.Fatal(err) + } + f.commit("link", "one", nil) + f.run() + if q := f.ask(); !strings.Contains(q.Text, "⚠ Symlink out of the tree: conf → /etc/shadow") { + t.Fatalf("the symlink warning is missing:\n%s", q.Text) + } + g := newFixture(t, ``, "[limits]\nrefuse_symlinks = true\n") + if err := os.Symlink("../../x", filepath.Join(g.r.Work, "conf")); err != nil { + t.Fatal(err) + } + g.commit("link", "one", nil) + if r := g.run(); r != CheckFailed { + t.Fatalf("refuse_symlinks must stop the commit: %s", r) + } + if contains(g.outbox(), "Apply this commit?") { + t.Fatal("no question for a refused commit") + } +} + +func TestBranchBackToApplied(t *testing.T) { + f := newFixture(t, ``, "") + a := f.applyNew("one") + f.commit("second", "two", nil) + f.run() + f.outbox() + f.r.Push(a) + if r := f.run(); r != Nothing || f.state().Open() != nil { + t.Fatalf("back to the applied SHA closes the question: %s", r) + } + if !contains(f.outbox(), "back to the applied commit") { + t.Fatal("no close") + } +} + +func TestCheckTimeout(t *testing.T) { + f := newFixture(t, ``, "") + f.script("check.sh", "sleep 30\n") + f.env.Cfg.Commands.CheckTimeout.Duration = time.Second + f.commit("first", "one", nil) + start := time.Now() + if r := f.run(); r != CheckFailed { + t.Fatalf("run: %s", r) + } + if time.Since(start) > 15*time.Second { + t.Fatal("the timeout did not stop the check") + } + if !contains(f.outbox(), "timed out") { + t.Fatal("no notice") + } +} + +func TestClosedQuestionAsksAgainWhenTheSHAComesBack(t *testing.T) { + f := newFixture(t, ``, "") + a := f.applyNew("one") + b := f.commit("second", "two", nil) + f.run() + f.outbox() + f.r.Push(a) // the branch goes back: the question for b is closed + f.run() + f.outbox() + f.r.Push(b) // and b comes back + if r := f.run(); r != Asked { + t.Fatalf("b must be asked again: %s", r) + } +} + +func TestFailedOutboxWriteIsTriedAgain(t *testing.T) { + f := newFixture(t, ``, "") + f.commit("first", "one", nil) + // The check runs after the spool is set up: it breaks the outbox. + f.script("check.sh", "chmod 0500 "+f.env.Spool.Outbox()+"\n") + if _, err := f.env.Run(context.Background()); err == nil { + t.Fatal("a failed outbox write must be an error") + } + if f.state().Open() != nil { + t.Fatal("an undelivered question must not stay open") + } + f.script("check.sh", "true\n") + if r := f.run(); r != Asked { // Ensure fixes the mode again + t.Fatalf("the next run must ask: %s", r) + } +} + +func TestStopDuringApplyLeavesInProgress(t *testing.T) { + f := newFixture(t, `on_failure = "rollback"`, "") + f.applyNew("one") + f.commit("second", "two", nil) + f.run() + f.tap(f.ask().Code, "apply") + f.script("apply.sh", "sleep 30\n") + ctx, cancel := context.WithCancel(context.Background()) + time.AfterFunc(500*time.Millisecond, cancel) + if _, err := f.env.Apply(ctx); err == nil { + t.Fatal("a stop during apply must be an error") + } + if f.state().InProgress == nil || f.env.Dir.Paused() { + t.Fatal("a stop must leave in_progress, and must not roll back or pause yet") + } + if r := f.run(); r != CutOff || !f.env.Dir.Paused() { + t.Fatalf("the next start must pause: %s", r) + } +} diff --git a/internal/gate/run.go b/internal/gate/run.go new file mode 100644 index 0000000..bc17508 --- /dev/null +++ b/internal/gate/run.go @@ -0,0 +1,321 @@ +package gate + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/code6x/pullgate/internal/config" + "github.com/code6x/pullgate/internal/gitx" + "github.com/code6x/pullgate/internal/message" + "github.com/code6x/pullgate/internal/spool" + "github.com/code6x/pullgate/internal/state" +) + +// Result says what a run or an apply did, for tests and logs. +type Result string + +// Results. +const ( + Busy Result = "busy" + Paused Result = "paused" + Off Result = "off" + CutOff Result = "cut-off" + Nothing Result = "nothing" + Refused Result = "refused" + LimitReached Result = "limit" + CheckFailed Result = "check-failed" + Asked Result = "asked" + AutoQueued Result = "auto" + Applied Result = "applied" + Skipped Result = "skipped" + Ignored Result = "ignored" + RolledBack Result = "rolled-back" + RollbackError Result = "rollback-failed" + Held Result = "held" +) + +func (e *Env) head() string { return message.Head(e.Cfg.Host, e.Cfg.Name) } + +func (e *Env) notice(text string) { + if err := e.Spool.Send(spool.Message{Kind: spool.KindNotice, Text: e.head() + "\n" + text}); err != nil { + e.Log.Error("write a notice to the outbox", "err", err) + } +} + +func (e *Env) reply(q int64, text string) { + if err := e.Spool.Send(spool.Message{Kind: spool.KindReply, Question: q, Text: e.head() + "\n" + text}); err != nil { + e.Log.Error("write a reply to the outbox", "err", err) + } +} + +func (e *Env) edit(q int64, text string) { + if err := e.Spool.Send(spool.Message{Kind: spool.KindEdit, Question: q, Text: e.head() + "\n" + text}); err != nil { + e.Log.Error("write an edit to the outbox", "err", err) + } +} + +func code(sha string) string { return "" + sha + "" } + +// cutOff handles an apply in progress found at start (DESIGN.md section 10). +func (e *Env) cutOff(st *state.State) error { + ip := st.InProgress + e.Log.Error("an apply was cut off; pausing", "phase", ip.Phase, "sha", ip.SHA) + if err := e.Dir.Pause("the " + ip.Phase + " of " + ip.SHA + " was cut off"); err != nil { + return err + } + m := st.SetMark(ip.SHA, state.MarkFailed, e.now()) + m.Tries++ + st.InProgress = nil + if err := e.Dir.Save(st); err != nil { + return err + } + e.notice(fmt.Sprintf("⚠ The %s of %s was cut off. The host may be part way. The instance is paused.\nCheck the host, then run as root: pullgate status %s --resume", ip.Phase, code(ip.SHA), e.Cfg.Name)) + return nil +} + +// expire removes questions whose time is up (DESIGN.md section 7, step 4). +func (e *Env) expire(st *state.State) { + now := e.now() + keep := st.Questions[:0] + for _, q := range st.Questions { + if now.Before(q.Expires) && !now.Before(q.Asked) { + keep = append(keep, q) + continue + } + if m := st.Marks[q.SHA]; m != nil && m.Mark == state.MarkAsked { + st.SetMark(q.SHA, state.MarkExpired, now) + } + if q.Open && !q.Auto { + e.edit(q.ID, "This question for "+code(q.SHA)+" expired. A new commit, or pullgate status "+e.Cfg.Name+" --ask-again, asks again.") + } + e.Log.Info("question expired", "question", q.ID, "sha", q.SHA) + } + st.Questions = keep +} + +func (e *Env) closeOpen(st *state.State, why string) { + if q := st.Open(); q != nil { + q.Open = false + if m := st.Marks[q.SHA]; m != nil && m.Mark == state.MarkAsked { + delete(st.Marks, q.SHA) // a closed question does not block a later ask + } + if !q.Auto { + e.edit(q.ID, "This question for "+code(q.SHA)+" is closed: "+why) + } + } +} + +// Run is one "pullgate run" (DESIGN.md section 7). +func (e *Env) Run(ctx context.Context) (Result, error) { + if err := e.Dir.Ensure(); err != nil { + return "", err + } + lock, err := e.Dir.Lock(false) + if errors.Is(err, state.ErrBusy) { + e.Log.Info("busy: another run or apply holds the lock") + return Busy, nil + } + if err != nil { + return "", err + } + defer func() { _ = lock.Close() }() + if e.Dir.Paused() { + e.Log.Info("paused: the pause file exists", "file", e.Dir.PauseFile()) + return Paused, nil + } + if e.Cfg.Mode == config.ModeOff { + e.Log.Info("off: mode is off") + return Off, nil + } + if err := e.Spool.Ensure(e.UID, e.Owners); err != nil { + return "", err + } + st, err := e.Dir.Load() + if err != nil { + return "", err + } + if st.InProgress != nil { + return CutOff, e.cutOff(st) + } + if err := e.Git.Init(ctx); err != nil { + return "", err + } + e.Log.Info("fetch", "branch", e.Cfg.Repo.Branch) + tip, err := e.Git.Fetch(ctx, e.Cfg.Repo.URL, e.Cfg.Repo.Branch) + if err != nil { + return "", fmt.Errorf("fetch: %w", err) + } + e.Log.Info("fetched", "tip", tip, "applied", st.Applied) + st.LastTip = tip + e.expire(st) + res, err := e.decide(ctx, st, tip) + if serr := e.Dir.Save(st); serr != nil && err == nil { + err = serr + } + return res, err +} + +func (e *Env) decide(ctx context.Context, st *state.State, tip string) (Result, error) { + now := e.now() + open := st.Open() + switch { + case tip == st.Applied: + e.closeOpen(st, "the branch is back to the applied commit.") + return Nothing, nil + case open != nil && open.SHA == tip: + return Nothing, nil + case tip == st.Held: + e.Log.Info("the tip is the held SHA; waiting for a new commit", "sha", tip) + return Nothing, nil + } + if m := st.Marks[tip]; m != nil { + retry := m.Mark == state.MarkFailed && e.Cfg.OnFailure == config.OnFailureRollback && m.Tries < e.Cfg.Limits.MaxFailedTries + if !retry { + e.Log.Info("the tip was handled before; waiting for a new commit", "sha", tip, "mark", m.Mark) + return Nothing, nil + } + } + notDesc := false + if st.Applied != "" { + ok, err := e.Git.IsAncestor(ctx, st.Applied, tip) + if err != nil { + return "", err + } + if !ok { + if !e.Cfg.Limits.AllowOlder { + st.SetMark(tip, state.MarkRefused, now) + e.Log.Warn("refused: the tip does not descend from the applied SHA", "tip", tip, "applied", st.Applied) + e.notice("⚠ Refused " + code(tip) + ": it is not a descendant of the applied commit " + code(st.Applied) + " (an older commit, a force push, or a rewritten branch). Nothing was asked.\nTo allow it, set allow_older = true in [limits] of the host config.") + return Refused, nil + } + notDesc = true + } + } + // Questions per day. + var recent []time.Time + for _, t := range st.AskTimes { + if now.Sub(t) < 24*time.Hour && !t.After(now) { + recent = append(recent, t) + } + } + st.AskTimes = recent + if e.Cfg.Mode == config.ModeAsk && len(recent) >= e.Cfg.Limits.QuestionsPerDay { + e.Log.Warn("questions_per_day reached; not asking", "tip", tip) + if now.Sub(st.LimitNote) >= 24*time.Hour { + st.LimitNote = now + e.notice(fmt.Sprintf("⚠ %d questions in the last 24 hours: the limit questions_per_day is reached. %s waits.", len(recent), code(tip))) + } + return LimitReached, nil + } + + t, err := e.newTrees() + if err != nil { + return "", err + } + defer t.remove() + p, err := e.prepare(ctx, t, "run", tip, st.Applied) + if err != nil { + var hostile *gitx.HostileError + if errors.As(err, &hostile) { + // The commit itself is bad: it is not tried again. + st.SetMark(tip, state.MarkCheckFailed, now) + e.notice("⚠ " + code(tip) + " is refused, so it is not asked: " + message.Esc(err.Error())) + return CheckFailed, nil + } + return "", fmt.Errorf("export %s: %w", tip, err) + } + p.env.Failed = st.Held + if argv := e.Cfg.Commands.Check; len(argv) > 0 { + out, err := e.runSandboxed(ctx, argv, p.env) + if err != nil { + st.SetMark(tip, state.MarkCheckFailed, now) + e.Log.Warn("check failed; not asking", "sha", tip, "err", err) + e.notice("⚠ The check of " + code(tip) + " failed (" + message.Esc(err.Error()) + "), so it is not asked. It is not checked again.\n" + lastLines(out, 10)) + return CheckFailed, nil + } + e.Log.Info("check passed", "sha", tip) + } + q := &message.Question{ + Host: e.Cfg.Host, Instance: e.Cfg.Name, SHA: tip, Applied: st.Applied, + Held: st.Held, NotDescendant: notDesc, Files: p.files, Watch: e.Cfg.Watch.Paths, + Submodules: p.tree.Submodules, + } + for _, s := range p.tree.Symlinks { + q.Symlinks = append(q.Symlinks, message.Symlink{Path: s.Path, Target: s.Target}) + } + q.Subjects, q.TotalCommits, err = e.Git.Subjects(ctx, st.Applied, tip, 50) + if err != nil { + return "", err + } + if argv := e.Cfg.Commands.Summary; len(argv) > 0 { + out, err := e.runSandboxed(ctx, argv, p.env) + if err != nil { + q.SummaryFailed = err.Error() + } else { + q.Summary = out + } + } + text := q.Text() + + e.closeOpen(st, "a newer commit replaced this question.") + c, err := state.NewCode() + if err != nil { + return "", err + } + nq := state.Question{ + ID: st.NextID, Code: c, SHA: tip, Base: st.Applied, Asked: now, + Expires: now.Add(e.Cfg.Limits.ApprovalTTL.Duration), Open: true, + Auto: e.Cfg.Mode == config.ModeAuto, + } + st.NextID++ + st.Questions = append(st.Questions, nq) + st.SetMark(tip, state.MarkAsked, now) + if err := e.Dir.Save(st); err != nil { + return "", err + } + // undo forgets the question when it could not be delivered, so the + // next run tries again. + undo := func(err error) (Result, error) { + if i := st.FindCode(c); i >= 0 { + st.Remove(i) + } + delete(st.Marks, tip) + return "", err + } + if nq.Auto { + if err := state.WriteFileAtomic(e.Dir.AutoFile(), []byte(c+"\n"), 0o600); err != nil { + return undo(err) + } + e.Log.Info("auto mode: apply queued", "sha", tip) + return AutoQueued, nil + } + if err := e.Spool.Send(spool.Message{Kind: spool.KindAsk, Question: nq.ID, Code: c, Expires: nq.Expires, Text: text}); err != nil { + return undo(err) + } + st.AskTimes = append(st.AskTimes, now) + e.Log.Info("asked", "sha", tip, "question", nq.ID) + return Asked, nil +} + +// lastLines returns the last n lines of a command's output, cleaned, in a +//
 block.
+func lastLines(out string, n int) string {
+	lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
+	if len(lines) > n {
+		lines = lines[len(lines)-n:]
+	}
+	for i, l := range lines {
+		if r := []rune(l); len(r) > 100 {
+			l = string(r[:100]) + "…"
+		}
+		lines[i] = message.Esc(l)
+	}
+	s := strings.Join(lines, "\n")
+	if strings.TrimSpace(s) == "" {
+		return ""
+	}
+	return "
" + s + "
" +} diff --git a/internal/gate/status.go b/internal/gate/status.go new file mode 100644 index 0000000..b8c8bf5 --- /dev/null +++ b/internal/gate/status.go @@ -0,0 +1,95 @@ +package gate + +import ( + "fmt" + "io" + + "github.com/code6x/pullgate/internal/state" +) + +// StatusFlags are the flags of "pullgate status". +type StatusFlags struct { + AskAgain, Pause, Resume bool +} + +// Status prints the state, and does what the flags ask (DESIGN.md +// section 13). +func (e *Env) Status(w io.Writer, f StatusFlags) error { + if err := e.Dir.Ensure(); err != nil { + return err + } + if f.Pause { + // The pause file needs no lock, so it works while an apply runs. + if err := e.Dir.Pause("paused by the owner"); err != nil { + return err + } + } + if f.Resume || f.AskAgain { + lock, err := e.Dir.Lock(true) + if err != nil { + return err + } + defer func() { _ = lock.Close() }() + } + st, err := e.Dir.Load() + if err != nil { + return err + } + changed := false + if f.Resume { + if err := e.Dir.Resume(); err != nil { + return err + } + if st.InProgress != nil { + st.InProgress = nil + changed = true + } + } + if f.AskAgain && st.LastTip != "" { + delete(st.Marks, st.LastTip) + if st.Held == st.LastTip { + st.Held = "" + } + changed = true + } + if changed { + if err := e.Dir.Save(st); err != nil { + return err + } + } + show := func(k, v string) { _, _ = fmt.Fprintf(w, "%-12s %s\n", k+":", v) } + show("instance", e.Cfg.Name) + show("mode", e.Cfg.Mode) + show("on_failure", e.Cfg.OnFailure) + show("applied", orNone(st.Applied)) + show("last tip", orNone(st.LastTip)) + if q := st.Open(); q != nil { + show("question", fmt.Sprintf("%s, open until %s", q.SHA, q.Expires.Format("2006-01-02 15:04 MST"))) + } else { + show("question", "none") + } + if st.Held != "" { + show("held", st.Held+" (the last apply failed)") + } + if m := st.Marks[st.LastTip]; m != nil && st.LastTip != "" { + show("tip mark", fmt.Sprintf("%s (tries %d)", m.Mark, m.Tries)) + } + if e.Dir.Paused() { + show("paused", "yes: "+e.Dir.PauseFile()) + } else { + show("paused", "no") + } + if st.InProgress != nil { + show("in progress", fmt.Sprintf("%s of %s since %s", st.InProgress.Phase, st.InProgress.SHA, st.InProgress.Started.Format("2006-01-02 15:04 MST"))) + } + return nil +} + +func orNone(s string) string { + if s == "" { + return "none" + } + return s +} + +var _ = state.MarkAsked diff --git a/internal/gitx/export.go b/internal/gitx/export.go new file mode 100644 index 0000000..398e549 --- /dev/null +++ b/internal/gitx/export.go @@ -0,0 +1,367 @@ +package gitx + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/code6x/pullgate/internal/safedir" +) + +// Limits cap the size of an exported tree. +type Limits struct { + MaxFiles int + MaxBytes int64 +} + +// entry is one line of git ls-tree -r -l -z. +type entry struct { + mode string + typ string + oid string + size int64 + path string +} + +func parseLsTree(out []byte) ([]entry, error) { + var es []entry + for _, rec := range strings.Split(string(out), "\x00") { + if rec == "" { + continue + } + meta, path, ok := strings.Cut(rec, "\t") + if !ok { + return nil, fmt.Errorf("git ls-tree: bad line %q", meta) + } + f := strings.Fields(meta) + if len(f) != 4 { + return nil, fmt.Errorf("git ls-tree: bad line %q", meta) + } + e := entry{mode: f[0], typ: f[1], oid: f[2], path: path} + if !ValidSHA(e.oid) { + return nil, fmt.Errorf("git ls-tree: bad object name %q", e.oid) + } + if f[3] != "-" { + n, err := strconv.ParseInt(f[3], 10, 64) + if err != nil || n < 0 { + return nil, fmt.Errorf("git ls-tree: bad size %q", f[3]) + } + e.size = n + } + es = append(es, e) + } + return es, nil +} + +// CheckPath refuses a tree path that is absolute, empty, or has a part that +// is empty, ".", "..", or ".git" in any case. +func CheckPath(p string) error { + if p == "" || strings.HasPrefix(p, "/") || strings.ContainsRune(p, 0) { + return fmt.Errorf("the commit has a bad path %q", p) + } + for _, part := range strings.Split(p, "/") { + if part == "" || part == "." || part == ".." || strings.EqualFold(part, ".git") { + return fmt.Errorf("the commit has a bad path %q (part %q)", p, part) + } + } + return nil +} + +// HostileError is a problem of the commit itself (a bad path, a limit, an +// unknown mode): trying again cannot help. +type HostileError struct{ Msg string } + +func (e *HostileError) Error() string { return e.Msg } + +func hostile(format string, a ...any) error { return &HostileError{Msg: fmt.Sprintf(format, a...)} } + +// Tree is the result of an export. +type Tree struct { + Submodules []string // paths of submodules, which were skipped + Symlinks []Symlink // symlinks that point out of the tree +} + +// Symlink is a symlink of the tree whose target is absolute or holds "..". +type Symlink struct{ Path, Target string } + +// Outside reports whether a symlink target may point out of the tree. +func Outside(target string) bool { + if strings.HasPrefix(target, "/") { + return true + } + for _, p := range strings.Split(target, "/") { + if p == ".." { + return true + } + } + return false +} + +// Export writes the files of commit sha to dest, which must not exist. +// It never runs a checkout or git archive, so no filter, no export-subst +// and no export-ignore of .gitattributes applies. Submodules are skipped +// and returned. When the tree is complete, every directory is made +// read-only, and dest gets mode 0750 and group gid (gid < 0: no change). +// The files are written to a temporary name first, and get the name dest +// only when the export is complete. +func (g *Git) Export(ctx context.Context, sha, dest string, lim Limits, gid int) (tree *Tree, err error) { + if !ValidSHA(sha) { + return nil, errors.New("export: not a full SHA") + } + var submodules []string + out, err := g.Run(ctx, "ls-tree", "-r", "-l", "-z", "--full-tree", sha) + if err != nil { + return nil, err + } + es, err := parseLsTree(out) + if err != nil { + return nil, err + } + if lim.MaxFiles > 0 && len(es) > lim.MaxFiles { + return nil, hostile("the commit has %d files, more than max_files = %d. Fix: raise [limits] max_files in the config, or make the repo smaller", len(es), lim.MaxFiles) + } + var total int64 + var blobs []entry + for _, e := range es { + if err := CheckPath(e.path); err != nil { + return nil, &HostileError{Msg: err.Error()} + } + switch { + case e.mode == "160000" && e.typ == "commit": + submodules = append(submodules, e.path) + case e.typ == "blob" && (e.mode == "100644" || e.mode == "100755" || e.mode == "120000"): + total += e.size + blobs = append(blobs, e) + default: + return nil, hostile("the commit has %q with an unknown mode %s", e.path, e.mode) + } + } + if lim.MaxBytes > 0 && total > lim.MaxBytes { + return nil, hostile("the commit has %d bytes of files, more than max_tree_bytes = %d. Fix: raise [limits] max_tree_bytes in the config", total, lim.MaxBytes) + } + if err := safedir.CheckParents(filepath.Dir(dest), g.UID); err != nil { + return nil, fmt.Errorf("export refused: %w", err) + } + if _, err := os.Lstat(dest); err == nil { + return nil, fmt.Errorf("the tree %s exists already", dest) + } + tmp, err := os.MkdirTemp(filepath.Dir(dest), ".tmp-") + if err != nil { + return nil, fmt.Errorf("make a tree in %s: %w", filepath.Dir(dest), err) + } + defer func() { + if err != nil { + _ = RemoveTree(tmp) + } + }() + links, err := g.writeBlobs(ctx, tmp, blobs) + if err != nil { + return nil, err + } + if err := seal(tmp, gid); err != nil { + return nil, err + } + if err := os.Rename(tmp, dest); err != nil { + return nil, fmt.Errorf("name the tree %s: %w", dest, err) + } + return &Tree{Submodules: submodules, Symlinks: links}, nil +} + +func (g *Git) writeBlobs(ctx context.Context, dest string, blobs []entry) ([]Symlink, error) { + if len(blobs) == 0 { + return nil, nil + } + if err := safedir.CheckTree(g.Dir, g.UID); err != nil { + return nil, fmt.Errorf("git refused: %w", err) + } + ctx, cancel := context.WithTimeout(ctx, g.timeout()) + defer cancel() + cmd := g.command(ctx, "cat-file", "--batch") + stdin, err := cmd.StdinPipe() + if err != nil { + return nil, err + } + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("git cat-file: %w", err) + } + werr := make(chan error, 1) + go func() { + w := bufio.NewWriter(stdin) + for _, b := range blobs { + if _, err := w.WriteString(b.oid + "\n"); err != nil { + werr <- err + return + } + } + err := w.Flush() + _ = stdin.Close() + werr <- err + }() + r := bufio.NewReader(stdout) + made := map[string]bool{dest: true} + var rerr error + var links []Symlink + for _, b := range blobs { + var target string + if target, rerr = writeOne(r, dest, b, made); rerr != nil { + break + } + if b.mode == "120000" && Outside(target) { + links = append(links, Symlink{Path: b.path, Target: target}) + } + } + if rerr != nil { + _ = cmd.Cancel() + } + _ = discard(r, 1<<62) + waitErr := cmd.Wait() + if rerr != nil { + return nil, rerr + } + if err := <-werr; err != nil { + return nil, fmt.Errorf("git cat-file: %w", err) + } + if waitErr != nil { + return nil, fmt.Errorf("git cat-file: %w", waitErr) + } + return links, nil +} + +func writeOne(r *bufio.Reader, dest string, b entry, made map[string]bool) (target string, err error) { + head, err := r.ReadString('\n') + if err != nil { + return "", fmt.Errorf("git cat-file: %w", err) + } + f := strings.Fields(head) + if len(f) != 3 || f[0] != b.oid || f[1] != "blob" { + return "", fmt.Errorf("git cat-file: unexpected answer %q", strings.TrimSpace(head)) + } + size, err := strconv.ParseInt(f[2], 10, 64) + if err != nil || size != b.size { + return "", fmt.Errorf("git cat-file: wrong size for %q", b.path) + } + full, err := mkParents(dest, b.path, made) + if err != nil { + return "", err + } + if b.mode == "120000" { + if size > 4096 { + return "", fmt.Errorf("the symlink %q is too long", b.path) + } + buf := make([]byte, size) + if _, err := io.ReadFull(r, buf); err != nil { + return "", fmt.Errorf("git cat-file: %w", err) + } + if strings.ContainsRune(string(buf), 0) { + return "", fmt.Errorf("the symlink %q holds a NUL", b.path) + } + target = string(buf) + if err := os.Symlink(target, full); err != nil { + return "", fmt.Errorf("write %q: %w", b.path, err) + } + } else { + fh, err := os.OpenFile(full, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscallNoFollow, 0o600) //nolint:gosec // path checked by CheckPath and mkParents + if err != nil { + return "", fmt.Errorf("write %q: %w", b.path, err) + } + _, cerr := io.CopyN(fh, r, size) + mode := os.FileMode(0o444) + if b.mode == "100755" { + mode = 0o555 + } + if cerr == nil { + cerr = fh.Chmod(mode) + } + if err := fh.Close(); cerr == nil { + cerr = err + } + if cerr != nil { + return "", fmt.Errorf("write %q: %w", b.path, cerr) + } + } + if nl, err := r.ReadByte(); err != nil || nl != '\n' { + return "", errors.New("git cat-file: bad answer end") + } + return target, nil +} + +// mkParents makes the directories above path inside dest. Each one must +// be made in this export, so no symlink of the tree can be on the way. +func mkParents(dest, path string, made map[string]bool) (string, error) { + parts := strings.Split(path, "/") + dir := dest + for _, p := range parts[:len(parts)-1] { + dir = filepath.Join(dir, p) + if made[dir] { + continue + } + if err := os.Mkdir(dir, 0o700); err != nil { + return "", fmt.Errorf("write %q: %w", path, err) + } + made[dir] = true + } + full := filepath.Join(dir, parts[len(parts)-1]) + if !made[filepath.Dir(full)] { + return "", fmt.Errorf("write %q: parent not made by this export", path) + } + return full, nil +} + +// seal makes every directory of the tree read-only (0555), and the top +// directory 0750 with group gid. +func seal(dest string, gid int) error { + var dirs []string + err := filepath.WalkDir(dest, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && p != dest { + dirs = append(dirs, p) + } + return nil + }) + if err != nil { + return fmt.Errorf("seal the tree: %w", err) + } + sort.Sort(sort.Reverse(sort.StringSlice(dirs))) + for _, d := range dirs { + if err := os.Chmod(d, 0o555); err != nil { + return fmt.Errorf("seal the tree: %w", err) + } + } + if gid >= 0 && gid != os.Getegid() { + if err := os.Lchown(dest, -1, gid); err != nil { + return fmt.Errorf("seal the tree: %w", err) + } + } + if err := os.Chmod(dest, 0o750); err != nil { + return fmt.Errorf("seal the tree: %w", err) + } + return nil +} + +// RemoveTree removes an exported tree, whose directories are read-only. +func RemoveTree(dest string) error { + if _, err := os.Lstat(dest); errors.Is(err, fs.ErrNotExist) { + return nil + } + _ = filepath.WalkDir(dest, func(p string, d fs.DirEntry, err error) error { + if err == nil && d.IsDir() { + _ = os.Chmod(p, 0o700) //nolint:gosec // a tree under root-only directories; WalkDir does not follow its symlinks + } + return nil + }) + return os.RemoveAll(dest) +} diff --git a/internal/gitx/gitx.go b/internal/gitx/gitx.go new file mode 100644 index 0000000..16cbf84 --- /dev/null +++ b/internal/gitx/gitx.go @@ -0,0 +1,353 @@ +// Package gitx runs git as root, safely (rule 6, DESIGN.md section 6). +// +// Every call checks the git directory first, runs git with an empty +// environment and no system or global config, with hooks and fsmonitor +// off, only the ssh and file protocols, a timeout and an output cap. +package gitx + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "strings" + "syscall" + "time" + + "github.com/code6x/pullgate/internal/safedir" +) + +// MaxOutput is the most output that pullgate keeps from one git command +// (the tree export streams and has its own caps). +const MaxOutput = 16 << 20 + +var ( + shaRE = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`) + branchRE = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,199}$`) +) + +// ValidSHA reports whether s is a full SHA-1 or SHA-256 object name. +func ValidSHA(s string) bool { return shaRE.MatchString(s) } + +// ValidBranch reports whether b is a safe branch name: it cannot be read as +// an option, and has no "..", "//", "@{" or ".lock" end. +func ValidBranch(b string) bool { + return branchRE.MatchString(b) && !strings.Contains(b, "..") && + !strings.Contains(b, "//") && !strings.HasSuffix(b, "/") && + !strings.HasSuffix(b, ".lock") && !strings.HasSuffix(b, ".") +} + +// Git runs git on one bare repo. +type Git struct { + Dir string // the bare git directory, owned by UID + Home string // HOME for git and ssh + UID uint32 // the user that must own Dir (root in production) + Key string // the deploy key, for ssh URLs + KnownHosts string // the pinned host keys, for ssh URLs + Timeout time.Duration // per command; 0 means 10 minutes +} + +func (g *Git) timeout() time.Duration { + if g.Timeout <= 0 { + return 10 * time.Minute + } + return g.Timeout +} + +func (g *Git) env() []string { + env := []string{ + "PATH=/usr/local/bin:/usr/bin:/bin", + "HOME=" + g.Home, + "GIT_CONFIG_NOSYSTEM=1", + "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_TERMINAL_PROMPT=0", + "GIT_ASKPASS=", + "SSH_ASKPASS=", + "LC_ALL=C", + } + if g.Key != "" { + env = append(env, "GIT_SSH_COMMAND="+sshCommand(g.Key, g.KnownHosts)) + } + return env +} + +// sshCommand is the ssh command line for git. The paths come from the host +// config; they are quoted for the shell that git uses for GIT_SSH_COMMAND. +func sshCommand(key, knownHosts string) string { + return "ssh -F /dev/null -i " + shellQuote(key) + + " -o IdentitiesOnly=yes -o IdentityAgent=none" + + " -o UserKnownHostsFile=" + shellQuote(knownHosts) + + " -o GlobalKnownHostsFile=/dev/null" + + " -o StrictHostKeyChecking=yes -o BatchMode=yes" +} + +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// baseArgs come before every git subcommand. +func (g *Git) baseArgs() []string { + return []string{ + "--git-dir=" + g.Dir, + "-c", "core.hooksPath=/dev/null", + "-c", "core.fsmonitor=false", + "-c", "protocol.allow=never", + "-c", "protocol.ssh.allow=always", + "-c", "protocol.file.allow=always", + "-c", "submodule.recurse=false", + "-c", "fetch.recurseSubmodules=false", + "-c", "core.attributesFile=/dev/null", + "-c", "transfer.fsckObjects=true", + "-c", "gc.auto=0", + "-c", "maintenance.auto=false", + } +} + +func (g *Git) command(ctx context.Context, args ...string) *exec.Cmd { + cmd := exec.CommandContext(ctx, "git", append(g.baseArgs(), args...)...) //nolint:gosec // fixed program, args built here + cmd.Env = g.env() + cmd.Dir = g.Home + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) } + cmd.WaitDelay = 5 * time.Second + return cmd +} + +// capWriter keeps at most n bytes and drops the rest. +type capWriter struct { + buf bytes.Buffer + n int + cut bool +} + +func (w *capWriter) Write(p []byte) (int, error) { + if room := w.n - w.buf.Len(); room > 0 { + if len(p) > room { + w.buf.Write(p[:room]) + w.cut = true + } else { + w.buf.Write(p) + } + } else if len(p) > 0 { + w.cut = true + } + return len(p), nil +} + +// Run checks the git directory and runs one git command. +func (g *Git) Run(ctx context.Context, args ...string) ([]byte, error) { + if err := safedir.CheckTree(g.Dir, g.UID); err != nil { + return nil, fmt.Errorf("git refused: %w", err) + } + return g.runUnchecked(ctx, args...) +} + +// runUnchecked runs git without the directory check. Only Init uses it, +// on a new, empty directory under checked parents. +func (g *Git) runUnchecked(ctx context.Context, args ...string) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, g.timeout()) + defer cancel() + cmd := g.command(ctx, args...) + out := &capWriter{n: MaxOutput} + errOut := &capWriter{n: 4096} + cmd.Stdout = out + cmd.Stderr = errOut + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("git %s: %w: %s", args[0], err, strings.TrimSpace(errOut.buf.String())) + } + if out.cut { + return nil, fmt.Errorf("git %s: the output is larger than %d bytes", args[0], MaxOutput) + } + return out.buf.Bytes(), nil +} + +// Init makes the bare repo when it does not exist yet. The directory above +// it must be safe (rule 6). +func (g *Git) Init(ctx context.Context) error { + if _, err := os.Lstat(g.Dir); err == nil { + return safedir.CheckTree(g.Dir, g.UID) + } + if err := safedir.CheckParents(filepath.Dir(g.Dir), g.UID); err != nil { + return fmt.Errorf("git init refused: %w", err) + } + if err := os.Mkdir(g.Dir, 0o700); err != nil { + return fmt.Errorf("make %s: %w", g.Dir, err) + } + if _, err := g.runUnchecked(ctx, "init", "--quiet", "--bare", "--template=", g.Dir); err != nil { + return err + } + // git follows the umask; nobody else may write here. + if err := filepath.WalkDir(g.Dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + fi, err := d.Info() + if err != nil { + return err + } + return os.Chmod(p, fi.Mode().Perm()&^0o077) //nolint:gosec // a new directory that git just made, under checked parents + }); err != nil { + return fmt.Errorf("git init: %w", err) + } + return safedir.CheckTree(g.Dir, g.UID) +} + +// SetApplied moves refs/pullgate/applied to sha, so git keeps that commit. +func (g *Git) SetApplied(ctx context.Context, sha string) error { + if !ValidSHA(sha) { + return errors.New("set applied: not a full SHA") + } + _, err := g.Run(ctx, "update-ref", "refs/pullgate/applied", sha) + return err +} + +// TipRef is the ref that holds the fetched branch tip. +const TipRef = "refs/pullgate/tip" + +var pathRE = regexp.MustCompile(`^/[A-Za-z0-9._/-]*$`) + +// SafePath reports whether p is an absolute path with only A-Za-z0-9._/-, +// so it can be given to the shell that runs GIT_SSH_COMMAND. +func SafePath(p string) bool { return pathRE.MatchString(p) && !strings.Contains(p, "..") } + +// LocalPath returns the directory of a local repo URL ("/path" or +// "file:///path"), or "" for an SSH URL. Other URLs are an error. +func LocalPath(url string) (string, error) { + switch { + case url == "" || strings.HasPrefix(url, "-"): + return "", errors.New("the repo url is empty or starts with '-'. Fix: set [repo] url in the config") + case strings.HasPrefix(url, "file://"): + p := strings.TrimPrefix(url, "file://") + if !strings.HasPrefix(p, "/") { + return "", fmt.Errorf("the repo url %q is not an absolute file:// url. Fix: use file:///path", url) + } + return filepath.Clean(p), nil + case strings.HasPrefix(url, "/"): + return filepath.Clean(url), nil + case strings.HasPrefix(url, "ssh://"): + return "", nil + case strings.Contains(url, "://"): + return "", fmt.Errorf("the repo url %q uses a protocol that pullgate does not allow. Fix: use an ssh:// url or user@host:path", url) + case strings.Contains(url, ":") && !strings.Contains(strings.SplitN(url, ":", 2)[0], "/"): + return "", nil // user@host:path + default: + return "", fmt.Errorf("the repo url %q is not an ssh url or an absolute path. Fix: use an ssh:// url, user@host:path, or /path", url) + } +} + +// Fetch fetches branch from url and returns the full SHA of its tip. A +// local repo must pass the same check as the clone (rule 6), because git +// runs git-upload-pack in it. +func (g *Git) Fetch(ctx context.Context, url, branch string) (string, error) { + local, err := LocalPath(url) + if err != nil { + return "", err + } + if local != "" { + if err := safedir.CheckTree(local, g.UID); err != nil { + return "", fmt.Errorf("the local repo %s is not safe for root: %w", local, err) + } + } else if g.Key == "" || !SafePath(g.Key) || !SafePath(g.KnownHosts) { + return "", errors.New("an ssh url needs deploy_key and known_hosts, as absolute paths of A-Za-z0-9._/- only. Fix: set them in [repo] of the config") + } + if !ValidBranch(branch) { + return "", fmt.Errorf("the branch name %q is not allowed. Fix: set [repo] branch to a plain branch name", branch) + } + if _, err := g.Run(ctx, "fetch", "--quiet", "--no-tags", "--no-recurse-submodules", + "--no-write-fetch-head", "--no-auto-gc", "--", url, "+refs/heads/"+branch+":"+TipRef); err != nil { + return "", err + } + return g.RevParse(ctx, TipRef) +} + +// RevParse returns the full SHA of the commit that rev names. +func (g *Git) RevParse(ctx context.Context, rev string) (string, error) { + out, err := g.Run(ctx, "rev-parse", "--verify", "--quiet", "--end-of-options", rev+"^{commit}") + if err != nil { + return "", err + } + sha := strings.TrimSpace(string(out)) + if !ValidSHA(sha) { + return "", fmt.Errorf("git rev-parse gave %q, not a full SHA", sha) + } + return sha, nil +} + +// IsAncestor reports whether a is an ancestor of b (or equal to it). +func (g *Git) IsAncestor(ctx context.Context, a, b string) (bool, error) { + if !ValidSHA(a) || !ValidSHA(b) { + return false, errors.New("is-ancestor: not a full SHA") + } + _, err := g.Run(ctx, "merge-base", "--is-ancestor", a, b) + if err == nil { + return true, nil + } + var ee *exec.ExitError + if errors.As(err, &ee) && ee.ExitCode() == 1 { + return false, nil + } + return false, err +} + +// Subjects returns the subjects of the commits in old..sha (all commits of +// sha when old is empty), newest first, at most max, and the total count. +func (g *Git) Subjects(ctx context.Context, old, sha string, max int) ([]string, int, error) { + rng := sha + if old != "" { + rng = old + ".." + sha + } + out, err := g.Run(ctx, "rev-list", "--count", rng) + if err != nil { + return nil, 0, err + } + total, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return nil, 0, fmt.Errorf("git rev-list --count: %w", err) + } + out, err = g.Run(ctx, "log", "-z", "--no-color", "--no-mailmap", "--format=%s", "--max-count="+strconv.Itoa(max), rng, "--") + if err != nil { + return nil, 0, err + } + var subs []string + for _, s := range strings.Split(string(out), "\x00") { + if s != "" { + subs = append(subs, s) + } + } + return subs, total, nil +} + +// ChangedFiles returns the paths that differ between old and sha. With an +// empty old, it returns every path of sha. +func (g *Git) ChangedFiles(ctx context.Context, old, sha string) ([]string, error) { + var out []byte + var err error + if old == "" { + out, err = g.Run(ctx, "ls-tree", "-r", "-z", "--full-tree", "--name-only", sha) + } else { + out, err = g.Run(ctx, "diff-tree", "-r", "-z", "--no-renames", "--no-commit-id", "--name-only", old, sha, "--") + } + if err != nil { + return nil, err + } + var files []string + for _, f := range strings.Split(string(out), "\x00") { + if f != "" { + files = append(files, f) + } + } + return files, nil +} + +// discard reads and drops n bytes. +func discard(r io.Reader, n int64) error { + _, err := io.CopyN(io.Discard, r, n) + return err +} diff --git a/internal/gitx/gitx_test.go b/internal/gitx/gitx_test.go new file mode 100644 index 0000000..56076f9 --- /dev/null +++ b/internal/gitx/gitx_test.go @@ -0,0 +1,268 @@ +package gitx + +import ( + "context" + "os" + "path/filepath" + "slices" + "strings" + "syscall" + "testing" + + "github.com/code6x/pullgate/internal/testgit" +) + +func newGit(t *testing.T) (*Git, string) { + t.Helper() + testgit.NeedGit(t) + d := testgit.PrivateDir(t) + g := &Git{Dir: filepath.Join(d, "repo.git"), Home: d, UID: uint32(os.Geteuid())} + if err := g.Init(context.Background()); err != nil { + t.Fatal(err) + } + return g, d +} + +func TestFetchAncestorLogDiff(t *testing.T) { + ctx := context.Background() + r := testgit.New(t) + a := r.Commit("first", map[string]string{"a.txt": "a", "dir/b.txt": "b"}) + b := r.Commit("second ", map[string]string{"a.txt": "a2", "c.txt": "c"}) + g, _ := newGit(t) + tip, err := g.Fetch(ctx, r.Bare, "main") + if err != nil { + t.Fatal(err) + } + if tip != b { + t.Fatalf("tip %s, want %s", tip, b) + } + if ok, err := g.IsAncestor(ctx, a, b); err != nil || !ok { + t.Fatalf("a must be an ancestor of b: %v %v", ok, err) + } + if ok, err := g.IsAncestor(ctx, b, a); err != nil || ok { + t.Fatalf("b is not an ancestor of a: %v %v", ok, err) + } + subs, n, err := g.Subjects(ctx, a, b, 50) + if err != nil || n != 1 || len(subs) != 1 || subs[0] != "second " { + t.Fatalf("subjects %q %d %v", subs, n, err) + } + files, err := g.ChangedFiles(ctx, a, b) + if err != nil || !slices.Equal(files, []string{"a.txt", "c.txt"}) { + t.Fatalf("changed %q %v", files, err) + } + all, err := g.ChangedFiles(ctx, "", a) + if err != nil || !slices.Equal(all, []string{"a.txt", "dir/b.txt"}) { + t.Fatalf("all files %q %v", all, err) + } +} + +func TestFetchRefusesBadInput(t *testing.T) { + g, _ := newGit(t) + ctx := context.Background() + if _, err := g.Fetch(ctx, "--upload-pack=touch /tmp/x", "main"); err == nil { + t.Fatal("a url that starts with - must be refused") + } + for _, b := range []string{"-x", "a..b", "a b", "", "x.lock", "a//b"} { + if _, err := g.Fetch(ctx, "/nowhere", b); err == nil { + t.Fatalf("branch %q must be refused", b) + } + } +} + +func TestRunRefusesUnsafeGitDir(t *testing.T) { + g, _ := newGit(t) + if err := os.Chmod(filepath.Join(g.Dir, "config"), 0o666); err != nil { + t.Fatal(err) + } + if _, err := g.Run(context.Background(), "rev-parse", "--git-dir"); err == nil || !strings.Contains(err.Error(), "git refused") { + t.Fatalf("root must refuse git in a directory others can write, got %v", err) + } +} + +func TestInitRefusesUnsafeParent(t *testing.T) { + testgit.NeedGit(t) + d := t.TempDir() + if err := os.Chmod(d, 0o777); err != nil { + t.Fatal(err) + } + g := &Git{Dir: filepath.Join(d, "repo.git"), Home: d, UID: uint32(os.Geteuid())} + if err := g.Init(context.Background()); err == nil { + t.Fatal("init under a directory others can write must be refused") + } +} + +func TestHooksDoNotRun(t *testing.T) { + ctx := context.Background() + r := testgit.New(t) + r.Commit("one", map[string]string{"a": "1"}) + g, d := newGit(t) + marker := filepath.Join(d, "hook-ran") + hooks := filepath.Join(g.Dir, "hooks") + if err := os.MkdirAll(hooks, 0o700); err != nil { + t.Fatal(err) + } + script := "#!/bin/sh\ntouch " + marker + "\n" + for _, h := range []string{"reference-transaction", "post-checkout", "fsmonitor-watchman"} { + if err := os.WriteFile(filepath.Join(hooks, h), []byte(script), 0o700); err != nil { + t.Fatal(err) + } + } + if _, err := g.Fetch(ctx, r.Bare, "main"); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(marker); err == nil { + t.Fatal("a hook ran") + } +} + +func TestExport(t *testing.T) { + ctx := context.Background() + r := testgit.New(t) + r.Commit("one", map[string]string{ + "conf/app.toml": "x = 1\n", + "run.sh": "#!/bin/sh\necho hi\n", + ".gitattributes": "*.toml filter=evil export-ignore\n*.sh export-subst\n", + }) + if err := os.Chmod(filepath.Join(r.Work, "run.sh"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink("/etc/passwd", filepath.Join(r.Work, "link")); err != nil { + t.Fatal(err) + } + sha := r.Commit("two $Format:%H$", nil) + g, d := newGit(t) + if _, err := g.Fetch(ctx, r.Bare, "main"); err != nil { + t.Fatal(err) + } + dest := filepath.Join(d, "tree") + tr, err := g.Export(ctx, sha, dest, Limits{MaxFiles: 100, MaxBytes: 1 << 20}, -1) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = RemoveTree(dest) }) + if len(tr.Submodules) != 0 { + t.Fatalf("submodules %v", tr.Submodules) + } + if len(tr.Symlinks) != 1 || tr.Symlinks[0] != (Symlink{Path: "link", Target: "/etc/passwd"}) { + t.Fatalf("symlinks out of the tree: %v", tr.Symlinks) + } + got, err := os.ReadFile(filepath.Join(dest, "conf/app.toml")) + if err != nil || string(got) != "x = 1\n" { + t.Fatalf("export-ignore or a filter applied: %q %v", got, err) + } + if got, _ := os.ReadFile(filepath.Join(dest, "run.sh")); string(got) != "#!/bin/sh\necho hi\n" { + t.Fatalf("export-subst applied: %q", got) + } + fi, err := os.Lstat(filepath.Join(dest, "run.sh")) + if err != nil || fi.Mode().Perm() != 0o555 { + t.Fatalf("run.sh mode %v %v", fi.Mode(), err) + } + fi, err = os.Lstat(filepath.Join(dest, "link")) + if err != nil || fi.Mode()&os.ModeSymlink == 0 { + t.Fatal("the symlink must stay a symlink") + } + fi, err = os.Lstat(filepath.Join(dest, "conf")) + if err != nil || fi.Mode().Perm() != 0o555 { + t.Fatalf("conf must be read-only: %v", fi.Mode()) + } + if err := os.WriteFile(filepath.Join(dest, "conf", "new"), []byte("x"), 0o600); err == nil && os.Geteuid() != 0 { + t.Fatal("the tree must be read-only") + } + if err := RemoveTree(dest); err != nil { + t.Fatal(err) + } +} + +func TestExportLimitsAndSubmodules(t *testing.T) { + ctx := context.Background() + r := testgit.New(t) + r.Commit("one", map[string]string{"a": "12345", "b": "67890"}) + other := r.Git("rev-parse", "HEAD") + r.Git("update-index", "--add", "--cacheinfo", "160000,"+other+",sub") + r.Git("commit", "--quiet", "-m", "with a submodule") + r.Push("HEAD") + sha := r.Git("rev-parse", "HEAD") + g, d := newGit(t) + if _, err := g.Fetch(ctx, r.Bare, "main"); err != nil { + t.Fatal(err) + } + if _, err := g.Export(ctx, sha, filepath.Join(d, "t1"), Limits{MaxFiles: 2}, -1); err == nil { + t.Fatal("max_files must stop the export") + } + if _, err := g.Export(ctx, sha, filepath.Join(d, "t2"), Limits{MaxBytes: 9}, -1); err == nil { + t.Fatal("max_tree_bytes must stop the export") + } + left, _ := filepath.Glob(filepath.Join(d, "t2*")) + tmps, _ := filepath.Glob(filepath.Join(d, ".tmp-*")) + if len(left)+len(tmps) != 0 { + t.Fatalf("a failed export must leave no tree: %v %v", left, tmps) + } + tr, err := g.Export(ctx, sha, filepath.Join(d, "t3"), Limits{MaxFiles: 10, MaxBytes: 100}, -1) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = RemoveTree(filepath.Join(d, "t3")) }) + if !slices.Equal(tr.Submodules, []string{"sub"}) { + t.Fatalf("submodules %v", tr.Submodules) + } + if _, err := os.Lstat(filepath.Join(d, "t3", "sub")); err == nil { + t.Fatal("a submodule must not be written") + } +} + +func TestMain(m *testing.M) { + syscall.Umask(0o077) + os.Exit(m.Run()) +} + +func TestLocalPathAndLocalRepoCheck(t *testing.T) { + for url, want := range map[string]string{ + "/srv/x.git": "/srv/x.git", "file:///srv/x.git": "/srv/x.git", + "ssh://git@git.example.com/x.git": "", "git@git.example.com:x.git": "", + } { + got, err := LocalPath(url) + if err != nil || got != want { + t.Errorf("%q: %q %v", url, got, err) + } + } + for _, url := range []string{"https://git.example.com/x.git", "git://x/y", "rel/path", "file://rel", "-oProxy", ""} { + if _, err := LocalPath(url); err == nil { + t.Errorf("%q must be refused", url) + } + } + r := testgit.New(t) + r.Commit("one", map[string]string{"a": "1"}) + if err := os.Chmod(r.Bare, 0o777); err != nil { + t.Fatal(err) + } + g, _ := newGit(t) + if _, err := g.Fetch(context.Background(), r.Bare, "main"); err == nil { + t.Fatal("a local repo that others can write must be refused") + } + g.Key = "/k ey" + g.KnownHosts = "/kh" + if _, err := g.Fetch(context.Background(), "ssh://git@git.example.com/x.git", "main"); err == nil { + t.Fatal("a key path with a space must be refused") + } +} + +func TestCheckPath(t *testing.T) { + for _, p := range []string{"a", "a/b", ".github/x", "a.git", "gitx"} { + if err := CheckPath(p); err != nil { + t.Errorf("%q: %v", p, err) + } + } + for _, p := range []string{"", "/a", "a//b", "../a", "a/../b", ".git/config", "x/.GIT/hooks", "a/./b", "a\x00b"} { + if err := CheckPath(p); err == nil { + t.Errorf("%q must be refused", p) + } + } +} + +func TestParseLsTreeRejectsBadLines(t *testing.T) { + for _, in := range []string{"100644 blob abc 5\tx\x00", "100644 blob\tx\x00", "garbage\x00"} { + if _, err := parseLsTree([]byte(in)); err == nil { + t.Errorf("%q must be refused", in) + } + } +} diff --git a/internal/gitx/nofollow.go b/internal/gitx/nofollow.go new file mode 100644 index 0000000..549219d --- /dev/null +++ b/internal/gitx/nofollow.go @@ -0,0 +1,5 @@ +package gitx + +import "syscall" + +const syscallNoFollow = syscall.O_NOFOLLOW diff --git a/internal/listener/listener.go b/internal/listener/listener.go new file mode 100644 index 0000000..58d6fb0 --- /dev/null +++ b/internal/listener/listener.go @@ -0,0 +1,351 @@ +// Package listener is "pullgate listen" (rule 4, DESIGN.md section 9). It +// is the only process with the bot token. It sends what root puts in the +// outboxes, and turns a valid tap of the owner into an approval file. It +// opens no port. +package listener + +import ( + "bytes" + "context" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "html" + "io/fs" + "log/slog" + "os" + "strings" + "sync" + "time" + + "github.com/code6x/pullgate/internal/config" + "github.com/code6x/pullgate/internal/spool" + "github.com/code6x/pullgate/internal/state" + "github.com/code6x/pullgate/internal/telegram" +) + +// Closed is the answer to a tap that does not count. +const Closed = "This question is closed." + +// Question is a question that the listener knows. +type Question struct { + Instance string `json:"instance"` + ID int64 `json:"id"` + Code string `json:"code,omitempty"` // "" once tapped or closed + MsgID int64 `json:"msg_id"` + Text string `json:"text"` // the HTML that was sent + TextHash string `json:"text_hash"` // SHA-256 of the text Telegram gave back + Expires time.Time `json:"expires"` +} + +// State is the listener's state file. +type State struct { + Offset int64 `json:"offset"` + Questions []Question `json:"questions"` +} + +// Listener holds the parts. +type Listener struct { + Cfg *config.Listener + TG *telegram.Client + Log *slog.Logger + Now func() time.Time + Poll time.Duration // how often the outboxes are read + LongPoll int // getUpdates timeout in seconds + + mu sync.Mutex + st State +} + +func hash(s string) string { + h := sha256.Sum256([]byte(s)) + return hex.EncodeToString(h[:]) +} + +func (l *Listener) now() time.Time { + if l.Now != nil { + return l.Now() + } + return time.Now() +} + +// Load reads the state file. A missing file is an empty state. +func (l *Listener) Load() error { + b, err := os.ReadFile(l.Cfg.StateFile) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("read the listener state: %w", err) + } + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&l.st); err != nil { + return fmt.Errorf("the listener state %s is broken: %w. Fix: remove it; open questions then stop working, and the next run asks again", l.Cfg.StateFile, err) + } + return nil +} + +func (l *Listener) save() error { + // Drop questions a day after they expire. + keep := l.st.Questions[:0] + for _, q := range l.st.Questions { + if l.now().Before(q.Expires.Add(24 * time.Hour)) { + keep = append(keep, q) + } + } + l.st.Questions = keep + b, err := json.Marshal(&l.st) + if err != nil { + return err + } + return state.WriteFileAtomic(l.Cfg.StateFile, b, 0o600) +} + +func (l *Listener) find(inst string, id int64) *Question { + for i := range l.st.Questions { + if l.st.Questions[i].Instance == inst && l.st.Questions[i].ID == id { + return &l.st.Questions[i] + } + } + return nil +} + +func (l *Listener) dirs(inst string) spool.Dirs { + return spool.Dirs{Base: l.Cfg.SpoolDir, Name: inst} +} + +// Run sends the outboxes and takes taps until ctx ends. +func (l *Listener) Run(ctx context.Context) error { + if err := l.TG.DeleteWebhook(ctx); err != nil { + return fmt.Errorf("%w. Fix: check the bot token and the network", err) + } + poll := l.Poll + if poll == 0 { + poll = 2 * time.Second + } + var wg sync.WaitGroup + defer wg.Wait() + wg.Add(1) + go func() { + defer wg.Done() + t := time.NewTicker(poll) + defer t.Stop() + for { + l.Outbox(ctx) + select { + case <-ctx.Done(): + return + case <-t.C: + } + } + }() + long := l.LongPoll + if long == 0 { + long = 50 + } + for ctx.Err() == nil { + l.mu.Lock() + off := l.st.Offset + l.mu.Unlock() + ups, err := l.TG.GetUpdates(ctx, off, long) + if err != nil { + if ctx.Err() != nil { + break + } + l.Log.Warn("getUpdates failed", "err", err) + select { + case <-ctx.Done(): + case <-time.After(5 * time.Second): + } + continue + } + for _, u := range ups { + l.Update(ctx, u) + } + } + return nil +} + +// Outbox sends every message in the outboxes, in order. +func (l *Listener) Outbox(ctx context.Context) { + l.mu.Lock() + defer l.mu.Unlock() +instances: + for _, inst := range l.Cfg.Instances { + d := l.dirs(inst) + files, err := d.ReadOutbox() + if err != nil { + l.Log.Warn("read the outbox", "instance", inst, "err", err) + continue + } + for _, f := range files { + if f.Err != nil { + l.Log.Warn("bad outbox file, removed", "instance", inst, "file", f.Name, "err", f.Err) + _ = d.Done(f.Name) + continue + } + err := l.send(ctx, inst, f.Message) + var ae *telegram.APIError + switch { + case err == nil: + case errors.As(err, &ae) && ae.Permanent(): + l.Log.Warn("telegram refused a message, dropped", "instance", inst, "kind", f.Message.Kind, "err", err) + case f.Age > 24*time.Hour: + l.Log.Warn("a message could not be sent for a day, dropped", "instance", inst, "kind", f.Message.Kind, "err", err) + default: + l.Log.Warn("send failed, will try again", "instance", inst, "kind", f.Message.Kind, "err", err) + continue instances // keep the order of this instance: try this one again first + } + // Save first: a question whose code is not saved could never + // be answered. A repeated ask is safe (find skips it). + if err := l.save(); err != nil { + l.Log.Error("save the listener state", "err", err) + return + } + if err := d.Done(f.Name); err != nil { + l.Log.Warn("remove an outbox file", "instance", inst, "err", err) + } + } + } +} + +func (l *Listener) send(ctx context.Context, inst string, m spool.Message) error { + chat := l.Cfg.ChatID + switch m.Kind { + case spool.KindAsk: + if len(m.Code) != 22 || !l.now().Before(m.Expires) { + // Expired or broken: never show buttons for it. + l.Log.Warn("a question expired before it could be sent, dropped", "instance", inst, "question", m.Question) + _, err := l.TG.SendMessage(ctx, chat, "pullgate: a question of instance "+html.EscapeString(inst)+" expired before it could be sent. The next commit, or pullgate status "+html.EscapeString(inst)+" --ask-again, asks again.", nil, 0) + return err + } + if q := l.find(inst, m.Question); q != nil { + return nil // sent already + } + msg, err := l.TG.SendMessage(ctx, chat, m.Text, []telegram.Button{ + {Text: "Apply", CallbackData: "a:" + m.Code}, + {Text: "Skip", CallbackData: "s:" + m.Code}, + }, 0) + if err != nil { + return err + } + l.st.Questions = append(l.st.Questions, Question{ + Instance: inst, ID: m.Question, Code: m.Code, MsgID: msg.MessageID, + Text: m.Text, TextHash: hash(msg.Text), Expires: m.Expires, + }) + l.Log.Info("question sent", "instance", inst, "question", m.Question) + return nil + case spool.KindEdit: + q := l.find(inst, m.Question) + if q == nil { + l.Log.Info("edit of a question that was never sent, dropped", "instance", inst, "question", m.Question) + return nil + } + q.Code = "" // closed: a tap after this never counts + q.Text = m.Text + return l.TG.EditMessageText(ctx, chat, q.MsgID, m.Text) + case spool.KindReply: + var to int64 + if q := l.find(inst, m.Question); q != nil { + to = q.MsgID + } + _, err := l.TG.SendMessage(ctx, chat, m.Text, nil, to) + return err + case spool.KindNotice: + _, err := l.TG.SendMessage(ctx, chat, m.Text, nil, 0) + return err + default: + return &telegram.APIError{Method: "outbox", Code: 400, Desc: "unknown kind"} + } +} + +// Update handles one update: only a valid tap of the owner counts. +func (l *Listener) Update(ctx context.Context, u telegram.Update) { + l.mu.Lock() + defer l.mu.Unlock() + if u.UpdateID >= l.st.Offset { + l.st.Offset = u.UpdateID + 1 + } + defer func() { + if err := l.save(); err != nil { + l.Log.Error("save the listener state", "err", err) + } + }() + cq := u.CallbackQuery + if cq == nil { + return + } + answer := Closed + defer func() { + if err := l.TG.AnswerCallbackQuery(ctx, cq.ID, answer); err != nil { + l.Log.Warn("answer a tap", "err", err) + } + }() + q, action, why := l.check(cq) + if q == nil { + l.Log.Info("tap ignored", "reason", why, "from", cq.From.ID) + return + } + if err := l.dirs(q.Instance).WriteApproval(spool.Approval{Code: q.Code, Action: action}); err != nil { + l.Log.Error("write the approval", "instance", q.Instance, "err", err) + answer = "Error: the approval was not saved. Tap again." + return + } + q.Code = "" // a second tap does nothing + note := "Apply tapped. Waiting for the host." + answer = "Apply tapped." + if action == "skip" { + note, answer = "Skipped.", "Skipped." + } + l.Log.Info("tap taken", "instance", q.Instance, "question", q.ID, "action", action) + q.Text = q.Text + "\n\n" + note + "" + if err := l.TG.EditMessageText(ctx, l.Cfg.ChatID, q.MsgID, q.Text); err != nil { + l.Log.Warn("edit the question", "err", err) + } +} + +// check returns the question that a tap is for, or nil and why not. +func (l *Listener) check(cq *telegram.CallbackQuery) (*Question, string, string) { + if cq.From.ID != l.Cfg.OwnerID { + return nil, "", "not the owner" + } + if cq.Message == nil || cq.Message.Chat.ID != l.Cfg.ChatID { + return nil, "", "not the configured chat" + } + action := "" + switch { + case strings.HasPrefix(cq.Data, "a:"): + action = "apply" + case strings.HasPrefix(cq.Data, "s:"): + action = "skip" + default: + return nil, "", "unknown button" + } + code := cq.Data[2:] + if len(code) != 22 { + return nil, "", "bad code" + } + var q *Question + for i := range l.st.Questions { + c := l.st.Questions[i].Code + if c != "" && subtle.ConstantTimeCompare([]byte(c), []byte(code)) == 1 { + q = &l.st.Questions[i] + } + } + switch { + case q == nil: + return nil, "", "no open question with this code" + case q.MsgID != cq.Message.MessageID: + return nil, "", "another message" + case cq.Message.EditDate != 0 || subtle.ConstantTimeCompare([]byte(hash(cq.Message.Text)), []byte(q.TextHash)) != 1: + return nil, "", "the message text was changed" + case !l.now().Before(q.Expires): + q.Code = "" + return nil, "", "expired" + } + return q, action, "" +} diff --git a/internal/listener/listener_test.go b/internal/listener/listener_test.go new file mode 100644 index 0000000..9f83287 --- /dev/null +++ b/internal/listener/listener_test.go @@ -0,0 +1,213 @@ +package listener + +import ( + "context" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/code6x/pullgate/internal/config" + "github.com/code6x/pullgate/internal/spool" + "github.com/code6x/pullgate/internal/telegram" + "github.com/code6x/pullgate/internal/telegram/faketg" + "github.com/code6x/pullgate/internal/testgit" +) + +const ( + owner = 111 + chat = 222 + inst = "config-repo" + token = "123:test-token-made-by-the-test" + code = "abcdefghijklmnopqrstuv" +) + +func TestMain(m *testing.M) { + syscall.Umask(0o077) + os.Exit(m.Run()) +} + +type lfix struct { + t *testing.T + tg *faketg.Server + l *Listener + d spool.Dirs + now time.Time +} + +func newL(t *testing.T) *lfix { + base := testgit.PrivateDir(t) + f := &lfix{t: t, tg: faketg.New(t, token), now: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)} + f.d = spool.Dirs{Base: filepath.Join(base, "spool"), Name: inst} + uid := uint32(os.Geteuid()) + if err := f.d.Ensure(uid, spool.Owners{ListenerUID: os.Geteuid(), ListenerGID: os.Getegid()}); err != nil { + t.Fatal(err) + } + cfg := &config.Listener{OwnerID: owner, ChatID: chat, Instances: []string{inst}, SpoolDir: f.d.Base, StateFile: filepath.Join(base, "listener.json")} + f.l = &Listener{Cfg: cfg, TG: telegram.New(f.tg.URL, token), Log: slog.New(slog.NewTextHandler(io.Discard, nil)), Now: func() time.Time { return f.now }} + return f +} + +// ask puts a question in the outbox and lets the listener send it. +func (f *lfix) ask(id int64, c string) int64 { + f.t.Helper() + if err := f.d.Send(spool.Message{Kind: spool.KindAsk, Question: id, Code: c, Expires: f.now.Add(time.Hour), Text: "pullgate on example-host\nApply this commit?"}); err != nil { + f.t.Fatal(err) + } + f.l.Outbox(context.Background()) + ms := f.tg.Messages() + m := ms[len(ms)-1] + if m.Method != "sendMessage" || len(m.Buttons) != 2 || m.Buttons[0].CallbackData != "a:"+c || m.Buttons[1].CallbackData != "s:"+c { + f.f("the question was not sent with two buttons: %+v", m) + } + return m.ID +} + +func (f *lfix) f(format string, a ...any) { f.t.Helper(); f.t.Fatalf(format, a...) } + +// tap sends one tap through getUpdates. +func (f *lfix) tap(from, chatID, msg int64, data string) { + f.t.Helper() + f.tg.Tap(from, chatID, msg, data) + ups, err := f.l.TG.GetUpdates(context.Background(), f.l.st.Offset, 0) + if err != nil { + f.t.Fatal(err) + } + for _, u := range ups { + f.l.Update(context.Background(), u) + } +} + +func (f *lfix) approvals() []spool.Entry { + f.t.Helper() + es, err := f.d.TakeApprovals(os.Geteuid(), 0) + if err != nil { + f.t.Fatal(err) + } + return es +} + +func TestValidTapWritesOnlyTheCode(t *testing.T) { + f := newL(t) + msg := f.ask(1, code) + f.tap(owner, chat, msg, "a:"+code) + es := f.approvals() + if len(es) != 1 || es[0].Err != nil || es[0].Approval != (spool.Approval{Code: code, Action: "apply"}) { + t.Fatalf("approvals %+v", es) + } + // A second tap does nothing. + f.tap(owner, chat, msg, "a:"+code) + if es := f.approvals(); len(es) != 0 { + t.Fatal("a second tap must do nothing") + } + last := f.tg.Messages()[len(f.tg.Messages())-1] + if last.Method != "editMessageText" || !strings.Contains(last.Text, "Apply tapped") || len(last.Buttons) != 0 { + t.Fatalf("the question must say so and lose its buttons: %+v", last) + } +} + +func TestSkipTap(t *testing.T) { + f := newL(t) + msg := f.ask(1, code) + f.tap(owner, chat, msg, "s:"+code) + if es := f.approvals(); len(es) != 1 || es[0].Approval.Action != "skip" { + t.Fatalf("approvals %+v", es) + } +} + +func TestTapsThatDoNotCount(t *testing.T) { + cases := map[string]func(f *lfix, msg int64){ + "another user": func(f *lfix, msg int64) { f.tap(owner+1, chat, msg, "a:"+code) }, + "another chat": func(f *lfix, msg int64) { f.tap(owner, chat+1, msg, "a:"+code) }, + "another message": func(f *lfix, msg int64) { f.tap(owner, chat, msg+5, "a:"+code) }, + "unknown code": func(f *lfix, msg int64) { f.tap(owner, chat, msg, "a:zzzzzzzzzzzzzzzzzzzzzz") }, + "bad data": func(f *lfix, msg int64) { f.tap(owner, chat, msg, "x:"+code) }, + "expired": func(f *lfix, msg int64) { + f.now = f.now.Add(2 * time.Hour) + f.tap(owner, chat, msg, "a:"+code) + }, + "closed question": func(f *lfix, msg int64) { + if err := f.d.Send(spool.Message{Kind: spool.KindEdit, Question: 1, Text: "closed"}); err != nil { + f.t.Fatal(err) + } + f.l.Outbox(context.Background()) + f.tap(owner, chat, msg, "a:"+code) + }, + "text edited by a token thief": func(f *lfix, msg int64) { + f.tg.EditByThief(msg, "harmless text") + f.tap(owner, chat, msg, "a:"+code) + }, + } + for n, c := range cases { + t.Run(n, func(t *testing.T) { + f := newL(t) + msg := f.ask(1, code) + c(f, msg) + if es := f.approvals(); len(es) != 0 { + t.Fatalf("the tap must be ignored, got %+v", es) + } + if a := f.tg.Answers; len(a) == 0 || a[len(a)-1] != Closed { + t.Fatalf("the tap must get the answer %q, got %v", Closed, a) + } + }) + } +} + +func TestExpiredAskIsNotSent(t *testing.T) { + f := newL(t) + if err := f.d.Send(spool.Message{Kind: spool.KindAsk, Question: 1, Code: code, Expires: f.now.Add(-time.Minute), Text: "old"}); err != nil { + t.Fatal(err) + } + f.l.Outbox(context.Background()) + ms := f.tg.Messages() + if len(ms) != 1 || len(ms[0].Buttons) != 0 || !strings.Contains(ms[0].Text, "expired before it could be sent") { + t.Fatalf("an expired question must not be sent, only a notice: %+v", ms) + } +} + +func TestStateSurvivesRestart(t *testing.T) { + f := newL(t) + msg := f.ask(1, code) + l2 := &Listener{Cfg: f.l.Cfg, TG: f.l.TG, Log: f.l.Log, Now: f.l.Now} + if err := l2.Load(); err != nil { + t.Fatal(err) + } + f.l = l2 + f.tap(owner, chat, msg, "a:"+code) + if es := f.approvals(); len(es) != 1 { + t.Fatal("a restarted listener must know the open question") + } +} + +func TestReplyAndNotice(t *testing.T) { + f := newL(t) + msg := f.ask(1, code) + _ = f.d.Send(spool.Message{Kind: spool.KindReply, Question: 1, Text: "applied"}) + _ = f.d.Send(spool.Message{Kind: spool.KindNotice, Text: "notice"}) + f.l.Outbox(context.Background()) + ms := f.tg.Messages() + if len(ms) != 3 || ms[1].ReplyTo != msg || ms[2].Text != "notice" { + t.Fatalf("messages %+v", ms) + } + if left, _ := f.d.ReadOutbox(); len(left) != 0 { + t.Fatal("sent messages must be removed from the outbox") + } +} + +func TestTokenNeverInErrors(t *testing.T) { + c := telegram.New("http://127.0.0.1:1", token) + _, err := c.SendMessage(context.Background(), 1, "x", nil, 0) + if err == nil || strings.Contains(err.Error(), token) || strings.Contains(err.Error(), "test-token") { + t.Fatalf("the error must not hold the token: %v", err) + } + tg := faketg.New(t, "other-token") + c = telegram.New(tg.URL, token) + _, err = c.SendMessage(context.Background(), 1, "x", nil, 0) + if err == nil || strings.Contains(err.Error(), token) { + t.Fatalf("the error must not hold the token: %v", err) + } +} diff --git a/internal/message/message.go b/internal/message/message.go new file mode 100644 index 0000000..9df9cad --- /dev/null +++ b/internal/message/message.go @@ -0,0 +1,148 @@ +// Package message builds the text of the Telegram messages (DESIGN.md +// section 12): it cleans text from the commit, matches the watch list, and +// cuts the question to fit one message. +package message + +import ( + "fmt" + "html" + "path" + "strings" + "unicode" + "unicode/utf16" + "unicode/utf8" +) + +// Limit is the most characters (UTF-16 units) of one message. Telegram +// allows 4096; the rest is room for the cut line. +const Limit = 3800 + +// MaxLine is the most characters of one cleaned line. +const MaxLine = 200 + +// Clean makes text from the commit safe to show: control and invisible +// characters become "?", and the text is cut to MaxLine characters. It +// does not escape HTML; Esc does. +func Clean(s string) string { + var b strings.Builder + n := 0 + for _, r := range s { + if n >= MaxLine { + b.WriteString("…") + break + } + if r == utf8.RuneError || unicode.IsControl(r) || isInvisible(r) { + r = '?' + } + b.WriteRune(r) + n++ + } + return b.String() +} + +// isInvisible reports characters that change how text looks or reads +// without being seen: format characters (bidi controls, zero-width), and +// odd spaces. +func isInvisible(r rune) bool { + if unicode.Is(unicode.Cf, r) || unicode.Is(unicode.Zl, r) || unicode.Is(unicode.Zp, r) { + return true + } + if unicode.IsSpace(r) && r != ' ' { + return true + } + switch r { + case 'ᅟ', 'ᅠ', 'ㅤ', 'ᅠ', '⠀': + return true + } + return unicode.Is(unicode.Variation_Selector, r) +} + +// Esc cleans and HTML-escapes text from the commit. +func Esc(s string) string { return html.EscapeString(Clean(s)) } + +// Len is the length of s in UTF-16 units, as Telegram counts. +func Len(s string) int { return len(utf16.Encode([]rune(s))) } + +// Match reports whether p matches the watch pattern. "*" and the other +// path.Match patterns work inside one path part; "**" matches any number +// of parts. A pattern without "/" matches the last part (the file name) +// too, so "*.toml" matches "a/b.toml". +func Match(pattern, p string) bool { + if !strings.Contains(pattern, "/") && pattern != "**" { + if ok, _ := path.Match(pattern, path.Base(p)); ok { + return true + } + } + return matchParts(strings.Split(pattern, "/"), strings.Split(p, "/")) +} + +func matchParts(pat, parts []string) bool { + for len(pat) > 0 { + if pat[0] == "**" { + if len(pat) == 1 { + return true + } + for i := 0; i <= len(parts); i++ { + if matchParts(pat[1:], parts[i:]) { + return true + } + } + return false + } + if len(parts) == 0 { + return false + } + if ok, err := path.Match(pat[0], parts[0]); err != nil || !ok { + return false + } + pat, parts = pat[1:], parts[1:] + } + return len(parts) == 0 +} + +// Watched returns the files that match any watch pattern. +func Watched(patterns, files []string) []string { + var out []string + for _, f := range files { + for _, p := range patterns { + if Match(p, f) { + out = append(out, f) + break + } + } + } + return out +} + +// Fit joins head and body lines with newlines. The head always stays. Body +// lines are added while the text fits Limit; when some are left out, the +// last line says how many. The lines must be escaped already; the cut is on +// whole lines, so it never splits a tag. preOpen is the index of a body +// line that opens a
 block that ends with a line "
"; when the +// cut falls inside it, the block is closed (-1: none). +func Fit(head, body []string, preOpen int) string { + text := strings.Join(head, "\n") + used := Len(text) + reserve := Len("\n
\n… 99999 more lines left out") + for i, l := range body { + if used+1+Len(l)+reserve > Limit { + left := len(body) - i + if preOpen >= 0 && i > preOpen && !closedBefore(body, preOpen, i) { + text += "\n" + } + return text + fmt.Sprintf("\n… %d more lines left out", left) + } + text += "\n" + l + used += 1 + Len(l) + } + return text +} + +func closedBefore(body []string, open, i int) bool { + for j := open + 1; j < i; j++ { + if body[j] == "" { + return true + } + } + return false +} diff --git a/internal/message/message_test.go b/internal/message/message_test.go new file mode 100644 index 0000000..dd57d77 --- /dev/null +++ b/internal/message/message_test.go @@ -0,0 +1,152 @@ +package message + +import ( + "fmt" + "strings" + "testing" +) + +func TestClean(t *testing.T) { + for in, want := range map[string]string{ + "plain": "plain", + "a\nb": "a?b", + "bidi\u202eevil": "bidi?evil", + "zero\u200bwidth": "zero?width", + "tab\there": "tab?here", + "esc\x1b[31m": "esc?[31m", + string([]byte{0xff, 'x'}): "?x", + strings.Repeat("x", 300): strings.Repeat("x", 200) + "…", + } { + if got := Clean(in); got != want { + t.Errorf("Clean(%q) = %q, want %q", in, got, want) + } + } + if got := Esc("&"); got != "<b>&</b>" { + t.Errorf("Esc: %q", got) + } +} + +func TestMatch(t *testing.T) { + yes := [][2]string{ + {"policy/**", "policy/a.toml"}, {"policy/**", "policy/x/y"}, {"*.toml", "a/b.toml"}, + {"*.toml", "c.toml"}, {"etc/*/conf", "etc/x/conf"}, {"**/units/*.service", "a/b/units/x.service"}, + {"**", "anything/at/all"}, {"README.md", "README.md"}, + } + no := [][2]string{ + {"policy/**", "other/a"}, {"*.toml", "a/b.yaml"}, {"etc/*/conf", "etc/x/y/conf"}, + {"a/b", "a/b/c"}, {"[", "x"}, + } + for _, c := range yes { + if !Match(c[0], c[1]) { + t.Errorf("%q must match %q", c[0], c[1]) + } + } + for _, c := range no { + if Match(c[0], c[1]) { + t.Errorf("%q must not match %q", c[0], c[1]) + } + } +} + +func baseQuestion() *Question { + return &Question{ + Host: "example-host", Instance: "config-repo", + SHA: strings.Repeat("a", 40), + Applied: strings.Repeat("b", 40), + Watch: []string{"policy/**"}, + } +} + +func TestWatchWarningsAppear(t *testing.T) { + q := baseQuestion() + q.Files = []string{"README.md", "policy/main.toml", "policy/.toml"} + q.TotalCommits = 1 + q.Subjects = []string{"change policy"} + text := q.Text() + for _, want := range []string{ + "⚠ Watched path changed: policy/main.toml", + "⚠ Watched path changed: policy/<x>.toml", + "" + q.SHA + "", + } { + if !strings.Contains(text, want) { + t.Errorf("missing %q in\n%s", want, text) + } + } + if strings.Contains(text, "Watched path changed: README.md") { + t.Error("README.md is not watched") + } + // The warnings come before the lists. + if strings.Index(text, "⚠ Watched") > strings.Index(text, "Changed files") { + t.Error("the warnings must come before the file list") + } +} + +func TestSummaryIsCutToFit(t *testing.T) { + q := baseQuestion() + for i := 0; i < 2000; i++ { + q.Files = append(q.Files, fmt.Sprintf("dir/file-%04d.txt", i)) + } + q.TotalCommits = 60 + for i := 0; i < 50; i++ { + q.Subjects = append(q.Subjects, strings.Repeat("<&>", 100)) + } + q.Summary = strings.Repeat("line of summary <>\n", 500) + text := q.Text() + if n := Len(text); n > 4096 || n > Limit { + t.Fatalf("the message has %d characters", n) + } + if !strings.Contains(text, "more lines left out") { + t.Fatal("the cut must say how much was left out") + } + if !strings.Contains(text, q.SHA) || !strings.HasPrefix(text, "pullgate on example-host") { + t.Fatal("the head must stay") + } + // No broken entity or tag at the cut. + if strings.Count(text, "") != strings.Count(text, "") { + t.Fatal("a tag is not closed") + } +} + +func TestSummaryPreClosedAtCut(t *testing.T) { + q := baseQuestion() + q.Files = []string{"a"} + q.Summary = strings.Repeat("x\n", 3000) + text := q.Text() + if strings.Count(text, "
") != strings.Count(text, "
") { + t.Fatalf("the
 block must be closed after a cut:\n%s", text[len(text)-200:])
+	}
+	if Len(text) > Limit {
+		t.Fatal("too long")
+	}
+}
+
+func TestManyWatchWarningsStillFit(t *testing.T) {
+	q := baseQuestion()
+	for i := 0; i < 500; i++ {
+		q.Files = append(q.Files, fmt.Sprintf("policy/%03d-%s", i, strings.Repeat("y", 150)))
+	}
+	text := q.Text()
+	if Len(text) > Limit {
+		t.Fatalf("too long: %d", Len(text))
+	}
+	if !strings.Contains(text, "more warnings") || !strings.Contains(text, q.SHA) {
+		t.Fatal("the head must keep the SHA and say how many warnings were left out")
+	}
+}
+
+func TestFirstApplyAndHeld(t *testing.T) {
+	q := baseQuestion()
+	q.Applied = ""
+	q.Held = strings.Repeat("c", 40)
+	q.Files = []string{"a", "b"}
+	text := q.Text()
+	if !strings.Contains(text, "First apply: there is no applied commit yet. All 2 files are listed.") {
+		t.Error("the first question must say so")
+	}
+	if !strings.Contains(text, "⚠ The last apply ("+q.Held+") failed.") {
+		t.Error("the held SHA warning is missing")
+	}
+	if strings.Index(text, "The last apply") > strings.Index(text, "Apply this commit?") {
+		t.Error("the held warning must be at the top")
+	}
+}
diff --git a/internal/message/question.go b/internal/message/question.go
new file mode 100644
index 0000000..51443e1
--- /dev/null
+++ b/internal/message/question.go
@@ -0,0 +1,118 @@
+package message
+
+import (
+	"fmt"
+	"html"
+	"strings"
+)
+
+// Symlink is a symlink of the tree that points out of it.
+type Symlink struct{ Path, Target string }
+
+// Question holds what the question shows.
+type Question struct {
+	Host, Instance string
+	SHA, Applied   string // Applied is "" on the first apply
+	Held           string // the held SHA, if any
+	NotDescendant  bool   // with allow_older
+	Subjects       []string
+	TotalCommits   int
+	Files          []string
+	Watch          []string // the watch patterns
+	Symlinks       []Symlink
+	Submodules     []string
+	Summary        string // the output of the summary command
+	SummaryFailed  string // why the summary command failed, if it did
+}
+
+// Head is the first line of every message.
+func Head(host, instance string) string {
+	return fmt.Sprintf("pullgate on %s, instance %s", html.EscapeString(host), html.EscapeString(instance))
+}
+
+// Short is the first 12 characters of a SHA, for reports.
+func Short(sha string) string {
+	if len(sha) > 12 {
+		return sha[:12]
+	}
+	return sha
+}
+
+// Text builds the question text, cut to fit one message.
+func (q *Question) Text() string {
+	head := []string{Head(q.Host, q.Instance)}
+	if q.Held != "" {
+		head = append(head, fmt.Sprintf("⚠ The last apply (%s) failed.", q.Held))
+	}
+	if q.NotDescendant {
+		head = append(head, "⚠ This commit is not a descendant of the applied commit.")
+	}
+	head = append(head, "Apply this commit?", ""+q.SHA+"")
+	if q.Applied == "" {
+		head = append(head, fmt.Sprintf("First apply: there is no applied commit yet. All %d files are listed.", len(q.Files)))
+	} else {
+		head = append(head, "Since "+q.Applied+":")
+	}
+	for _, f := range Watched(q.Watch, q.Files) {
+		head = append(head, "⚠ Watched path changed: "+Esc(f)+"")
+	}
+	for _, s := range q.Symlinks {
+		head = append(head, "⚠ Symlink out of the tree: "+Esc(s.Path)+" → "+Esc(s.Target)+"")
+	}
+	for _, s := range q.Submodules {
+		head = append(head, "⚠ Submodule skipped: "+Esc(s)+"")
+	}
+	// The head holds text from the commit too (watched paths); keep it
+	// within the limit on its own.
+	if Len(strings.Join(head, "\n")) > Limit-200 {
+		head = cutHead(head)
+	}
+	var body []string
+	body = append(body, "", fmt.Sprintf("Commits (%d):", q.TotalCommits))
+	for _, s := range q.Subjects {
+		body = append(body, "• "+Esc(s))
+	}
+	if q.TotalCommits > len(q.Subjects) {
+		body = append(body, fmt.Sprintf("… and %d more", q.TotalCommits-len(q.Subjects)))
+	}
+	body = append(body, fmt.Sprintf("Changed files (%d):", len(q.Files)))
+	for _, f := range q.Files {
+		body = append(body, "• "+Esc(f))
+	}
+	pre := -1
+	switch {
+	case q.SummaryFailed != "":
+		body = append(body, "⚠ The summary command failed: "+Esc(q.SummaryFailed))
+	case strings.TrimSpace(q.Summary) != "":
+		body = append(body, "Summary:")
+		pre = len(body)
+		lines := strings.Split(strings.TrimRight(q.Summary, "\n"), "\n")
+		body = append(body, "
"+Esc(lines[0]))
+		for _, l := range lines[1:] {
+			body = append(body, Esc(l))
+		}
+		body = append(body, "
") + } + return Fit(head, body, pre) +} + +// cutHead keeps the fixed lines and as many warnings as fit. +func cutHead(head []string) []string { + var out []string + used := 0 + left := 0 + for _, l := range head { + if strings.HasPrefix(l, "⚠ Watched") || strings.HasPrefix(l, "⚠ Symlink") || strings.HasPrefix(l, "⚠ Submodule") { + if used+Len(l)+1 > Limit-400 { + left++ + continue + } + } + out = append(out, l) + used += Len(l) + 1 + } + if left > 0 { + out = append(out, fmt.Sprintf("⚠ … and %d more warnings", left)) + } + return out +} diff --git a/internal/safedir/fifo_test.go b/internal/safedir/fifo_test.go new file mode 100644 index 0000000..85ac06b --- /dev/null +++ b/internal/safedir/fifo_test.go @@ -0,0 +1,5 @@ +package safedir + +import "syscall" + +func mkfifo(p string) error { return syscall.Mkfifo(p, 0o600) } diff --git a/internal/safedir/safedir.go b/internal/safedir/safedir.go new file mode 100644 index 0000000..1c45010 --- /dev/null +++ b/internal/safedir/safedir.go @@ -0,0 +1,140 @@ +// Package safedir checks that no other user can write a directory or the +// files in it (rule 6, DESIGN.md section 6). +package safedir + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "syscall" +) + +// ownerOf returns the uid of a file. +func ownerOf(fi fs.FileInfo) (uint32, error) { + st, ok := fi.Sys().(*syscall.Stat_t) + if !ok { + return 0, errors.New("no owner data for this file") + } + return st.Uid, nil +} + +// ErrSymlink is part of the error for a symlink on the way. +var ErrSymlink = errors.New("symlink on the way") + +// CheckOwner checks that fi is owned by uid or by root. +func CheckOwner(fi fs.FileInfo, uid uint32) error { + own, err := ownerOf(fi) + if err != nil { + return err + } + if own != uid && own != 0 { + return fmt.Errorf("owned by uid %d, not by uid %d or root", own, uid) + } + return nil +} + +// CheckParents checks dir and each directory above it up to "/". Each one +// must be a real directory (not a symlink), owned by uid or by root, and +// not writable by group or others. A directory that others can write is +// allowed only when it is owned by root and has the sticky bit (like /tmp). +func CheckParents(dir string, uid uint32) error { + abs, err := filepath.Abs(dir) + if err != nil { + return fmt.Errorf("check %s: %w", dir, err) + } + p := abs + for { + fi, err := os.Lstat(p) + if err != nil { + return fmt.Errorf("check %s: %w", p, err) + } + if fi.Mode()&fs.ModeSymlink != 0 { + return fmt.Errorf("%s is a symlink; root uses only real directories here. Fix: use a path without symlinks: %w", p, ErrSymlink) + } + if !fi.IsDir() { + return fmt.Errorf("%s is not a directory", p) + } + own, err := ownerOf(fi) + if err != nil { + return fmt.Errorf("check %s: %w", p, err) + } + if own != uid && own != 0 { + return fmt.Errorf("%s is owned by uid %d, not by uid %d or root; another user could change git's files. Fix: chown root %s", p, own, uid, p) + } + if fi.Mode().Perm()&0o022 != 0 { + sticky := fi.Mode()&fs.ModeSticky != 0 + if p == abs || !sticky || own != 0 { + return fmt.Errorf("%s can be written by group or others (mode %04o); another user could change git's files. Fix: chmod go-w %s", p, fi.Mode().Perm(), p) + } + } + parent := filepath.Dir(p) + if parent == p { + return nil + } + p = parent + } +} + +// CheckTree checks dir with CheckParents, and then every file and +// directory inside it: each must be owned by uid, not writable by group or +// others, and not a symlink or a special file. +func CheckTree(dir string, uid uint32) error { + if err := CheckParents(dir, uid); err != nil { + return err + } + return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return fmt.Errorf("check %s: %w", p, err) + } + fi, err := d.Info() + if err != nil { + return fmt.Errorf("check %s: %w", p, err) + } + if !fi.Mode().IsRegular() && !fi.IsDir() { + return fmt.Errorf("%s is not a regular file or a directory (mode %s); root runs git only in a clean directory. Fix: remove it, or remove %s and let pullgate clone again", p, fi.Mode(), dir) + } + own, err := ownerOf(fi) + if err != nil { + return fmt.Errorf("check %s: %w", p, err) + } + if own != uid { + return fmt.Errorf("%s is owned by uid %d, not by uid %d; another user could change git's files. Fix: remove %s and let pullgate clone again", p, own, uid, dir) + } + if fi.Mode().Perm()&0o022 != 0 { + return fmt.Errorf("%s can be written by group or others (mode %04o). Fix: chmod -R go-w %s", p, fi.Mode().Perm(), dir) + } + return nil + }) +} + +// CheckFile checks a config, key or known hosts file: a regular file (not a +// symlink), owned by uid, not writable by group or others. With private, +// it must not be readable by group or others either. +func CheckFile(path string, uid uint32, private bool) error { + fi, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("check %s: %w", path, err) + } + if !fi.Mode().IsRegular() { + return fmt.Errorf("%s is not a regular file (mode %s). Fix: use a regular file, not a symlink", path, fi.Mode()) + } + own, err := ownerOf(fi) + if err != nil { + return fmt.Errorf("check %s: %w", path, err) + } + if own != uid { + return fmt.Errorf("%s is owned by uid %d, not by uid %d. Fix: chown root %s", path, own, uid, path) + } + mask := fs.FileMode(0o022) + fix := "chmod go-w" + if private { + mask = 0o077 + fix = "chmod 600" + } + if fi.Mode().Perm()&mask != 0 { + return fmt.Errorf("%s has mode %04o, which other users can change or read. Fix: %s %s", path, fi.Mode().Perm(), fix, path) + } + return nil +} diff --git a/internal/safedir/safedir_test.go b/internal/safedir/safedir_test.go new file mode 100644 index 0000000..16dac62 --- /dev/null +++ b/internal/safedir/safedir_test.go @@ -0,0 +1,147 @@ +package safedir + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func uid() uint32 { return uint32(os.Geteuid()) } + +func TestCheckTreeOK(t *testing.T) { + d := filepath.Join(tempDir(t), "repo.git") + if err := os.MkdirAll(filepath.Join(d, "hooks"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "config"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := CheckTree(d, uid()); err != nil { + t.Fatalf("want ok, got %v", err) + } +} + +func TestCheckTreeDenies(t *testing.T) { + cases := map[string]func(t *testing.T, d string){ + "dir writable by others": func(t *testing.T, d string) { + chmod(t, d, 0o777) + }, + "dir writable by group": func(t *testing.T, d string) { + chmod(t, d, 0o770) + }, + "config writable by others": func(t *testing.T, d string) { + chmod(t, filepath.Join(d, "config"), 0o666) + }, + "hooks dir writable by others": func(t *testing.T, d string) { + chmod(t, filepath.Join(d, "hooks"), 0o777) + }, + "symlink inside": func(t *testing.T, d string) { + if err := os.Symlink("/etc/passwd", filepath.Join(d, "hooks", "pre-commit")); err != nil { + t.Fatal(err) + } + }, + "parent writable by others": func(t *testing.T, d string) { + chmod(t, filepath.Dir(d), 0o777) + }, + "fifo inside": func(t *testing.T, d string) { + if err := mkfifo(filepath.Join(d, "fifo")); err != nil { + t.Skip("no mkfifo:", err) + } + }, + } + for name, breakIt := range cases { + t.Run(name, func(t *testing.T) { + base := tempDir(t) + parent := filepath.Join(base, "state") + d := filepath.Join(parent, "repo.git") + if err := os.MkdirAll(filepath.Join(d, "hooks"), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(d, "config"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + chmod(t, parent, 0o700) + breakIt(t, d) + err := CheckTree(d, uid()) + if err == nil { + t.Fatal("want an error, got nil") + } + if !strings.Contains(err.Error(), "Fix") && !strings.Contains(err.Error(), "check ") { + t.Errorf("error does not say how to fix it: %v", err) + } + }) + } +} + +func TestCheckTreeOtherOwner(t *testing.T) { + d := tempDir(t) + if err := CheckTree(d, uid()+1); err == nil { + t.Fatal("a directory of another user must be refused") + } +} + +func TestCheckParentsSymlink(t *testing.T) { + base := tempDir(t) + real := filepath.Join(base, "real") + if err := os.Mkdir(real, 0o700); err != nil { + t.Fatal(err) + } + link := filepath.Join(base, "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + if err := CheckParents(link, uid()); err == nil { + t.Fatal("a symlink must be refused") + } +} + +func TestCheckFile(t *testing.T) { + d := tempDir(t) + f := filepath.Join(d, "key") + if err := os.WriteFile(f, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := CheckFile(f, uid(), true); err != nil { + t.Fatalf("want ok: %v", err) + } + chmod(t, f, 0o644) + if err := CheckFile(f, uid(), true); err == nil { + t.Fatal("a private file readable by others must be refused") + } + if err := CheckFile(f, uid(), false); err != nil { + t.Fatalf("a public file readable by others is fine: %v", err) + } + chmod(t, f, 0o664) + if err := CheckFile(f, uid(), false); err == nil { + t.Fatal("a file writable by group must be refused") + } + l := filepath.Join(d, "link") + if err := os.Symlink(f, l); err != nil { + t.Fatal(err) + } + if err := CheckFile(l, uid(), false); err == nil { + t.Fatal("a symlink must be refused") + } +} + +func chmod(t *testing.T, p string, m os.FileMode) { + t.Helper() + if err := os.Chmod(p, m); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chmod(p, 0o700) }) +} + +// tempDir returns a test directory that only this user can write, and +// whose parents only this user can write too, whatever the umask is. +func tempDir(t *testing.T) string { + t.Helper() + d := t.TempDir() + for _, p := range []string{filepath.Dir(d), d} { + if err := os.Chmod(p, 0o700); err != nil { + t.Fatal(err) + } + } + return d +} diff --git a/internal/spool/spool.go b/internal/spool/spool.go new file mode 100644 index 0000000..255bf3b --- /dev/null +++ b/internal/spool/spool.go @@ -0,0 +1,355 @@ +// Package spool holds the files between root and the listener (DESIGN.md +// section 8): the outbox (root to listener) and the inbox (listener to +// root). Root never follows a path that the listener can change: it works +// through os.Root, and creates files with O_EXCL|O_NOFOLLOW. +package spool + +import ( + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" + + "github.com/code6x/pullgate/internal/safedir" +) + +// Message kinds of the outbox. +const ( + KindAsk = "ask" + KindEdit = "edit" + KindNotice = "notice" + KindReply = "reply" +) + +// Message is one outbox file. +type Message struct { + Kind string `json:"kind"` + Question int64 `json:"question,omitempty"` + Code string `json:"code,omitempty"` + Expires time.Time `json:"expires,omitzero"` + Text string `json:"text"` +} + +// Approval is one inbox file. +type Approval struct { + Code string `json:"code"` + Action string `json:"action"` // "apply" or "skip" +} + +// MaxApproval is the largest inbox file. +const MaxApproval = 1024 + +// MaxMessage is the largest outbox file. +const MaxMessage = 64 << 10 + +// Owners are the users of the spool. +type Owners struct { + ListenerUID int // owns the inbox + ListenerGID int // the group of the outbox +} + +// Dirs is the spool of one instance: Base/NAME/{outbox,inbox}. +type Dirs struct { + Base string // /var/spool/pullgate + Name string +} + +func (d Dirs) inst() string { return filepath.Join(d.Base, d.Name) } + +// Outbox is the path of the outbox. +func (d Dirs) Outbox() string { return filepath.Join(d.inst(), "outbox") } + +// Inbox is the path of the inbox. +func (d Dirs) Inbox() string { return filepath.Join(d.inst(), "inbox") } + +// Ensure makes the spool of the instance with the owners and modes of +// DESIGN.md section 4. Base and Base/NAME belong to root (uid) and must +// pass the check of rule 6; outbox and inbox are fixed through an open +// directory, never through a path. +func (d Dirs) Ensure(uid uint32, o Owners) error { + for _, p := range []string{d.Base, d.inst()} { + if err := os.Mkdir(p, 0o755); err != nil && !errors.Is(err, fs.ErrExist) { + return fmt.Errorf("make %s: %w", p, err) + } + } + for _, p := range []string{d.Base, d.inst()} { + if err := os.Chmod(p, 0o755); err != nil { + return fmt.Errorf("fix %s: %w", p, err) + } + } + if err := safedir.CheckParents(d.inst(), uid); err != nil { + return fmt.Errorf("the spool is not safe: %w", err) + } + root, err := os.OpenRoot(d.inst()) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + for _, s := range []struct { + name string + uid, gid int + mode os.FileMode + }{ + {"outbox", int(uid), o.ListenerGID, 0o770 | os.ModeSetgid}, + {"inbox", o.ListenerUID, o.ListenerGID, 0o700}, + } { + if err := root.Mkdir(s.name, 0o700); err != nil && !errors.Is(err, fs.ErrExist) { + return fmt.Errorf("make %s: %w", s.name, err) + } + f, err := root.OpenFile(s.name, os.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW, 0) + if err != nil { + return fmt.Errorf("open %s/%s: %w", d.inst(), s.name, err) + } + err = f.Chown(s.uid, s.gid) + if err == nil { + err = f.Chmod(s.mode) + } + _ = f.Close() + if err != nil { + return fmt.Errorf("fix %s/%s: %w", d.inst(), s.name, err) + } + } + return nil +} + +func randName() string { + b := make([]byte, 8) + _, _ = rand.Read(b) + return ".tmp-" + hex.EncodeToString(b) +} + +// seqName is a name that sorts by time. +func seqName() string { + return fmt.Sprintf("%020d-%s.json", time.Now().UnixNano(), randName()[5:]) +} + +// writeIn writes data as a new file in dir, through os.Root: a random +// temporary name with O_EXCL|O_NOFOLLOW, then a rename in the same dir. +func writeIn(dir string, data []byte, mode os.FileMode) error { + root, err := os.OpenRoot(dir) + if err != nil { + return fmt.Errorf("open %s: %w", dir, err) + } + defer func() { _ = root.Close() }() + tmp := randName() + f, err := root.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL|syscall.O_NOFOLLOW, 0o600) + if err != nil { + return fmt.Errorf("write in %s: %w", dir, err) + } + _, err = f.Write(data) + if err == nil { + err = f.Chmod(mode) + } + if err == nil { + err = f.Sync() + } + if cerr := f.Close(); err == nil { + err = cerr + } + if err == nil { + err = root.Rename(tmp, seqName()) + } + if err != nil { + _ = root.Remove(tmp) + return fmt.Errorf("write in %s: %w", dir, err) + } + return nil +} + +// Send writes a message to the outbox (root). +func (d Dirs) Send(m Message) error { + b, err := json.Marshal(m) + if err != nil { + return err + } + return writeIn(d.Outbox(), b, 0o640) +} + +// WriteApproval writes an approval to the inbox (the listener). +func (d Dirs) WriteApproval(a Approval) error { + b, err := json.Marshal(a) + if err != nil { + return err + } + return writeIn(d.Inbox(), b, 0o600) +} + +// list returns the names in dir, sorted, without names that start with ".". +// It removes temporary files older than a minute. +func list(root *os.Root) ([]string, error) { + f, err := root.Open(".") + if err != nil { + return nil, err + } + names, err := f.Readdirnames(-1) + _ = f.Close() + if err != nil { + return nil, err + } + var out []string + for _, n := range names { + if strings.HasPrefix(n, ".tmp-") { + if fi, err := root.Lstat(n); err == nil && time.Since(fi.ModTime()) > 10*time.Second { + _ = root.Remove(n) + } + continue + } + if strings.HasPrefix(n, ".") { + continue + } + out = append(out, n) + } + sort.Strings(out) + return out, nil +} + +// readOne reads a regular file of at most max bytes through root, with +// O_NOFOLLOW. With uid >= 0 the file must be owned by uid. +func readOne(root *os.Root, name string, max int64, uid int) ([]byte, error) { + f, err := root.OpenFile(name, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + fi, err := f.Stat() + if err != nil { + return nil, err + } + if !fi.Mode().IsRegular() { + return nil, errors.New("not a regular file") + } + if st, ok := fi.Sys().(*syscall.Stat_t); uid >= 0 && (!ok || int(st.Uid) != uid) { + return nil, errors.New("not owned by the listener user") + } + if fi.Size() > max { + return nil, fmt.Errorf("larger than %d bytes", max) + } + return io.ReadAll(io.LimitReader(f, max+1)) +} + +// Entry is one approval read from the inbox, or the error of a bad file. +type Entry struct { + Name string + Approval Approval + Err error +} + +// TakeApprovals reads the approvals in the inbox in name order, at most max +// of them (0: all). Each file is removed before it is returned, so it is +// used once. A bad file is removed and comes back with Err set. +func (d Dirs) TakeApprovals(listenerUID int, max int) ([]Entry, error) { + root, err := os.OpenRoot(d.Inbox()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, fmt.Errorf("open the inbox: %w", err) + } + defer func() { _ = root.Close() }() + names, err := list(root) + if err != nil { + return nil, fmt.Errorf("read the inbox: %w", err) + } + var out []Entry + for _, n := range names { + if max > 0 && len(out) >= max { + break + } + e := Entry{Name: n} + b, err := readOne(root, n, MaxApproval, listenerUID) + if rerr := root.RemoveAll(n); rerr != nil { + // It cannot be used, and it stays: stop, so nobody loops on it. + return out, fmt.Errorf("remove %s/%s: %w. Fix: remove it as root", d.Inbox(), n, rerr) + } + if err == nil { + e.Approval, err = ParseApproval(b) + } + e.Err = err + out = append(out, e) + } + return out, nil +} + +// ParseApproval parses an approval strictly. +func ParseApproval(b []byte) (Approval, error) { + var a Approval + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&a); err != nil { + return Approval{}, fmt.Errorf("bad approval: %w", err) + } + if dec.More() { + return Approval{}, errors.New("bad approval: data after the object") + } + if a.Action != "apply" && a.Action != "skip" { + return Approval{}, errors.New("bad approval: unknown action") + } + if len(a.Code) != 22 { + return Approval{}, errors.New("bad approval: bad code") + } + return a, nil +} + +// OutboxFile is one message read by the listener. +type OutboxFile struct { + Name string + Message Message + Err error + Age time.Duration +} + +// ReadOutbox reads the outbox in name order (the listener). It does not +// remove the files; call Done when a message is handled. +func (d Dirs) ReadOutbox() ([]OutboxFile, error) { + root, err := os.OpenRoot(d.Outbox()) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + return nil, err + } + defer func() { _ = root.Close() }() + names, err := list(root) + if err != nil { + return nil, err + } + var out []OutboxFile + for _, n := range names { + of := OutboxFile{Name: n} + if ts, _, ok := strings.Cut(n, "-"); ok { + if ns, err := strconv.ParseInt(ts, 10, 64); err == nil { + of.Age = time.Since(time.Unix(0, ns)) + } + } + b, err := readOne(root, n, MaxMessage, -1) + if err == nil { + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + err = dec.Decode(&of.Message) + } + of.Err = err + out = append(out, of) + } + return out, nil +} + +// Done removes a handled outbox file. +func (d Dirs) Done(name string) error { + root, err := os.OpenRoot(d.Outbox()) + if err != nil { + return err + } + defer func() { _ = root.Close() }() + return root.Remove(name) +} diff --git a/internal/spool/spool_test.go b/internal/spool/spool_test.go new file mode 100644 index 0000000..92493b0 --- /dev/null +++ b/internal/spool/spool_test.go @@ -0,0 +1,141 @@ +package spool + +import ( + "os" + "path/filepath" + "syscall" + "testing" + "time" + + "github.com/code6x/pullgate/internal/testgit" +) + +func TestMain(m *testing.M) { + syscall.Umask(0o077) + os.Exit(m.Run()) +} + +func dirs(t *testing.T) Dirs { + t.Helper() + d := Dirs{Base: filepath.Join(testgit.PrivateDir(t), "spool"), Name: "x"} + if err := d.Ensure(uint32(os.Geteuid()), Owners{ListenerUID: os.Geteuid(), ListenerGID: os.Getegid()}); err != nil { + t.Fatal(err) + } + return d +} + +func TestApprovalRoundTrip(t *testing.T) { + d := dirs(t) + a := Approval{Code: "abcdefghijklmnopqrstuv", Action: "apply"} + if err := d.WriteApproval(a); err != nil { + t.Fatal(err) + } + es, err := d.TakeApprovals(os.Geteuid(), 0) + if err != nil || len(es) != 1 || es[0].Err != nil || es[0].Approval != a { + t.Fatalf("%+v %v", es, err) + } + if es, _ := d.TakeApprovals(os.Geteuid(), 0); len(es) != 0 { + t.Fatal("an approval is used once") + } +} + +func TestBadApprovals(t *testing.T) { + cases := map[string]func(t *testing.T, d Dirs){ + "unknown field": func(t *testing.T, d Dirs) { + put(t, d, `{"code":"abcdefghijklmnopqrstuv","action":"apply","sha":"x"}`) + }, + "bad action": func(t *testing.T, d Dirs) { put(t, d, `{"code":"abcdefghijklmnopqrstuv","action":"run"}`) }, + "bad code": func(t *testing.T, d Dirs) { put(t, d, `{"code":"x","action":"apply"}`) }, + "too big": func(t *testing.T, d Dirs) { put(t, d, string(make([]byte, 2000))) }, + "two objects": func(t *testing.T, d Dirs) { + put(t, d, `{"code":"abcdefghijklmnopqrstuv","action":"apply"}{}`) + }, + "symlink": func(t *testing.T, d Dirs) { + if err := os.Symlink("/etc/hostname", filepath.Join(d.Inbox(), "1.json")); err != nil { + t.Fatal(err) + } + }, + "directory": func(t *testing.T, d Dirs) { + if err := os.MkdirAll(filepath.Join(d.Inbox(), "1.json", "x"), 0o700); err != nil { + t.Fatal(err) + } + }, + "fifo": func(t *testing.T, d Dirs) { + if err := syscall.Mkfifo(filepath.Join(d.Inbox(), "1.json"), 0o600); err != nil { + t.Skip(err) + } + }, + } + for n, c := range cases { + t.Run(n, func(t *testing.T) { + d := dirs(t) + c(t, d) + es, err := d.TakeApprovals(os.Geteuid(), 0) + if err != nil { + t.Fatal(err) + } + if len(es) != 1 || es[0].Err == nil { + t.Fatalf("must be refused: %+v", es) + } + if left, _ := os.ReadDir(d.Inbox()); len(left) != 0 { + t.Fatal("a bad file must be removed") + } + }) + } + d := dirs(t) + if err := d.WriteApproval(Approval{Code: "abcdefghijklmnopqrstuv", Action: "apply"}); err != nil { + t.Fatal(err) + } + if es, _ := d.TakeApprovals(os.Geteuid()+1, 0); len(es) != 1 || es[0].Err == nil { + t.Fatal("a file of another owner must be refused") + } +} + +func put(t *testing.T, d Dirs, s string) { + t.Helper() + if err := os.WriteFile(filepath.Join(d.Inbox(), "1.json"), []byte(s), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestSendRefusesSymlinkedOutboxAndUnsafeSpool(t *testing.T) { + d := dirs(t) + // The listener plants a symlink at a name: root's write must not follow it. + target := filepath.Join(filepath.Dir(d.Base), "target") + if err := os.Symlink(target, filepath.Join(d.Outbox(), "00000000000000000001-x.json")); err != nil { + t.Fatal(err) + } + if err := d.Send(Message{Kind: KindNotice, Text: "x"}); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(target); err == nil { + t.Fatal("root followed a symlink in the outbox") + } + // Ensure refuses a spool under a directory that others can write. + if err := os.Chmod(filepath.Dir(d.Base), 0o777); err != nil { + t.Fatal(err) + } + defer func() { _ = os.Chmod(filepath.Dir(d.Base), 0o700) }() + if err := d.Ensure(uint32(os.Geteuid()), Owners{ListenerUID: os.Geteuid(), ListenerGID: os.Getegid()}); err == nil { + t.Fatal("a spool that others can write must be refused") + } +} + +func TestOldTempFilesAreRemoved(t *testing.T) { + d := dirs(t) + p := filepath.Join(d.Inbox(), ".tmp-old") + put(t, d, "{}") + if err := os.Rename(filepath.Join(d.Inbox(), "1.json"), p); err != nil { + t.Fatal(err) + } + old := time.Now().Add(-time.Hour) + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + if es, _ := d.TakeApprovals(os.Geteuid(), 0); len(es) != 0 { + t.Fatal("a temp file is not an approval") + } + if _, err := os.Lstat(p); err == nil { + t.Fatal("an old temp file must be removed") + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..839567e --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,307 @@ +// Package state keeps root's state of one instance: the applied SHA, the +// questions, the marks of SHAs, an apply in progress (DESIGN.md sections 9 +// and 10). It also holds the lock and the pause file. +package state + +import ( + "bytes" + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "syscall" + "time" + + "github.com/code6x/pullgate/internal/safedir" +) + +// Marks of a SHA. +const ( + MarkAsked = "asked" + MarkExpired = "expired" + MarkSkipped = "skipped" + MarkCheckFailed = "check-failed" + MarkRefused = "refused" + MarkFailed = "failed" +) + +// Question is one question that root asked. +type Question struct { + ID int64 `json:"id"` + Code string `json:"code"` + SHA string `json:"sha"` + Base string `json:"base"` + Asked time.Time `json:"asked"` + Expires time.Time `json:"expires"` + Open bool `json:"open"` + Auto bool `json:"auto,omitempty"` +} + +// Mark is what pullgate knows about one SHA. +type Mark struct { + Mark string `json:"mark"` + Tries int `json:"tries,omitempty"` + At time.Time `json:"at"` +} + +// InProgress is an apply or a rollback that started and has not ended. +type InProgress struct { + Phase string `json:"phase"` // "apply" or "rollback" + SHA string `json:"sha"` + OldSHA string `json:"old_sha"` + Started time.Time `json:"started"` +} + +// State is the content of state.json. +type State struct { + Version int `json:"version"` + Applied string `json:"applied"` + AppliedAt time.Time `json:"applied_at"` + Held string `json:"held,omitempty"` + LastTip string `json:"last_tip,omitempty"` + Questions []Question `json:"questions"` + Marks map[string]*Mark `json:"marks"` + InProgress *InProgress `json:"in_progress,omitempty"` + AskTimes []time.Time `json:"ask_times,omitempty"` + LimitNote time.Time `json:"limit_note,omitempty"` + NextID int64 `json:"next_id"` +} + +// MaxMarks is the most SHA marks kept; the oldest go first. +const MaxMarks = 500 + +// Open returns the open question, or nil. +func (s *State) Open() *Question { + for i := range s.Questions { + if s.Questions[i].Open { + return &s.Questions[i] + } + } + return nil +} + +// FindCode returns the index of the question with code, or -1. The compare +// takes the same time for every question. +func (s *State) FindCode(code string) int { + found := -1 + for i := range s.Questions { + if subtle.ConstantTimeCompare([]byte(s.Questions[i].Code), []byte(code)) == 1 { + found = i + } + } + return found +} + +// Remove removes the question at index i. +func (s *State) Remove(i int) Question { + q := s.Questions[i] + s.Questions = append(s.Questions[:i], s.Questions[i+1:]...) + return q +} + +// SetMark sets the mark of sha, and keeps the try count. +func (s *State) SetMark(sha, mark string, now time.Time) *Mark { + if s.Marks == nil { + s.Marks = map[string]*Mark{} + } + m := s.Marks[sha] + if m == nil { + m = &Mark{} + s.Marks[sha] = m + } + m.Mark = mark + m.At = now + s.prune() + return m +} + +func (s *State) prune() { + if len(s.Marks) <= MaxMarks { + return + } + type kv struct { + k string + t time.Time + } + var all []kv + for k, m := range s.Marks { + all = append(all, kv{k, m.At}) + } + sort.Slice(all, func(i, j int) bool { return all[i].t.Before(all[j].t) }) + for _, e := range all[:len(all)-MaxMarks] { + delete(s.Marks, e.k) + } +} + +// NewCode returns a random one-time code: 128 bits, 22 characters. +func NewCode() (string, error) { + b := make([]byte, 16) + if _, err := rand.Read(b); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// Dir is the state directory of one instance. +type Dir struct { + Path string +} + +func (d Dir) file(n string) string { return filepath.Join(d.Path, n) } + +// StateFile is the path of state.json. +func (d Dir) StateFile() string { return d.file("state.json") } + +// PauseFile is the path of the pause file. +func (d Dir) PauseFile() string { return d.file("paused") } + +// AutoFile is the path of the auto approval. +func (d Dir) AutoFile() string { return d.file("auto-approval") } + +// Repo is the path of the bare clone. +func (d Dir) Repo() string { return d.file("repo.git") } + +// Trees is the directory of the trees. +func (d Dir) Trees() string { return d.file("trees") } + +// Ensure makes the state directory (0711) and the trees directory. The +// directories above must pass the check of rule 6. +func (d Dir) Ensure() error { + if err := safedir.CheckParents(filepath.Dir(d.Path), uint32(os.Geteuid())); err != nil { //nolint:gosec // a uid fits in uint32 + return fmt.Errorf("the state directory is not safe: %w", err) + } + for _, p := range []string{d.Path, d.Trees()} { + if err := os.Mkdir(p, 0o711); err != nil && !errors.Is(err, fs.ErrExist) { + return fmt.Errorf("make %s: %w", p, err) + } + fi, err := os.Lstat(p) + if err != nil { + return err + } + if !fi.IsDir() { + return fmt.Errorf("%s is not a directory. Fix: remove it", p) + } + if err := os.Chmod(p, 0o711); err != nil { + return err + } + } + return nil +} + +// Paused reports whether the pause file exists. +func (d Dir) Paused() bool { + _, err := os.Lstat(d.PauseFile()) + return err == nil +} + +// Pause writes the pause file with a reason. +func (d Dir) Pause(reason string) error { + return WriteFileAtomic(d.PauseFile(), []byte(reason+"\n"), 0o644) +} + +// Resume removes the pause file. +func (d Dir) Resume() error { + err := os.Remove(d.PauseFile()) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +// Lock takes the lock of the instance. With wait false it fails at once +// when another process holds it. Close the file to free it. +func (d Dir) Lock(wait bool) (*os.File, error) { + f, err := os.OpenFile(d.file("lock"), os.O_RDWR|os.O_CREATE|syscall.O_NOFOLLOW, 0o600) + if err != nil { + return nil, fmt.Errorf("open the lock: %w", err) + } + how := syscall.LOCK_EX + if !wait { + how |= syscall.LOCK_NB + } + if err := syscall.Flock(int(f.Fd()), how); err != nil { + _ = f.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, ErrBusy + } + return nil, fmt.Errorf("take the lock: %w", err) + } + return f, nil +} + +// ErrBusy means another process holds the lock. +var ErrBusy = errors.New("another pullgate process holds the lock of this instance") + +// Load reads the state. A missing file is an empty state. +func (d Dir) Load() (*State, error) { + b, err := os.ReadFile(d.StateFile()) + if errors.Is(err, fs.ErrNotExist) { + return &State{Version: 1, Marks: map[string]*Mark{}, NextID: 1}, nil + } + if err != nil { + return nil, fmt.Errorf("read the state: %w", err) + } + s := &State{} + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(s); err != nil { + return nil, fmt.Errorf("the state %s is broken: %w. Fix: check the host, then remove the file (pullgate starts again as on the first run)", d.StateFile(), err) + } + if s.Version != 1 { + return nil, fmt.Errorf("the state %s has version %d, not 1", d.StateFile(), s.Version) + } + if s.Marks == nil { + s.Marks = map[string]*Mark{} + } + return s, nil +} + +// Save writes the state atomically and syncs it. +func (d Dir) Save(s *State) error { + b, err := json.MarshalIndent(s, "", " ") + if err != nil { + return err + } + return WriteFileAtomic(d.StateFile(), append(b, '\n'), 0o600) +} + +// WriteFileAtomic writes a file in a directory that only root can write: +// a temporary file, sync, rename, sync of the directory. +func WriteFileAtomic(path string, data []byte, mode os.FileMode) error { + dir := filepath.Dir(path) + f, err := os.CreateTemp(dir, ".tmp-") + if err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + tmp := f.Name() + defer func() { _ = os.Remove(tmp) }() + if _, err := f.Write(data); err != nil { + _ = f.Close() + return fmt.Errorf("write %s: %w", path, err) + } + if err := f.Chmod(mode); err != nil { + _ = f.Close() + return err + } + if err := f.Sync(); err != nil { + _ = f.Close() + return fmt.Errorf("sync %s: %w", path, err) + } + if err := f.Close(); err != nil { + return err + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + if df, err := os.Open(dir); err == nil { //nolint:gosec // our own directory + _ = df.Sync() + _ = df.Close() + } + return nil +} diff --git a/internal/telegram/faketg/faketg.go b/internal/telegram/faketg/faketg.go new file mode 100644 index 0000000..c01009a --- /dev/null +++ b/internal/telegram/faketg/faketg.go @@ -0,0 +1,185 @@ +// Package faketg is a fake Telegram Bot API server for tests. +package faketg + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/code6x/pullgate/internal/telegram" +) + +// Sent is one message that the bot sent or edited. +type Sent struct { + Method string + Chat int64 + ID int64 + Text string + Buttons []telegram.Button + ReplyTo int64 +} + +// Server is the fake server. +type Server struct { + *httptest.Server + Token string + + mu sync.Mutex + nextID int64 + updates []telegram.Update + nextUp int64 + msgs map[int64]*telegram.Message + Sent []Sent + Answers []string + wake chan struct{} +} + +// New starts a fake server for token. +func New(t testing.TB, token string) *Server { + s := &Server{Token: token, nextID: 100, nextUp: 1, msgs: map[int64]*telegram.Message{}, wake: make(chan struct{}, 1)} + s.Server = httptest.NewServer(http.HandlerFunc(s.handle)) + t.Cleanup(s.Close) + return s +} + +func (s *Server) handle(w http.ResponseWriter, r *http.Request) { + prefix := "/bot" + s.Token + "/" + if !strings.HasPrefix(r.URL.Path, prefix) { + writeJSON(w, map[string]any{"ok": false, "error_code": 401, "description": "Unauthorized"}) + return + } + method := strings.TrimPrefix(r.URL.Path, prefix) + var req map[string]any + _ = json.NewDecoder(r.Body).Decode(&req) + num := func(k string) int64 { f, _ := req[k].(float64); return int64(f) } + str := func(k string) string { v, _ := req[k].(string); return v } + switch method { + case "deleteWebhook": + writeJSON(w, map[string]any{"ok": true, "result": true}) + case "getUpdates": + s.mu.Lock() + off := num("offset") + var out []telegram.Update + for _, u := range s.updates { + if u.UpdateID >= off { + out = append(out, u) + } + } + s.mu.Unlock() + if len(out) == 0 && num("timeout") > 0 { + select { + case <-s.wake: + case <-r.Context().Done(): + } + s.mu.Lock() + for _, u := range s.updates { + if u.UpdateID >= off { + out = append(out, u) + } + } + s.mu.Unlock() + } + if out == nil { + out = []telegram.Update{} + } + writeJSON(w, map[string]any{"ok": true, "result": out}) + case "sendMessage", "editMessageText": + s.mu.Lock() + sent := Sent{Method: method, Chat: num("chat_id"), Text: str("text")} + if rm, ok := req["reply_markup"].(map[string]any); ok { + if kb, ok := rm["inline_keyboard"].([]any); ok && len(kb) > 0 { + for _, b := range kb[0].([]any) { + bm := b.(map[string]any) + sent.Buttons = append(sent.Buttons, telegram.Button{Text: bm["text"].(string), CallbackData: bm["callback_data"].(string)}) + } + } + } + if rp, ok := req["reply_parameters"].(map[string]any); ok { + f, _ := rp["message_id"].(float64) + sent.ReplyTo = int64(f) + } + var m *telegram.Message + if method == "sendMessage" { + s.nextID++ + m = &telegram.Message{MessageID: s.nextID, Chat: telegram.Chat{ID: sent.Chat}, Text: sent.Text} + s.msgs[m.MessageID] = m + } else { + m = s.msgs[num("message_id")] + if m == nil { + s.mu.Unlock() + writeJSON(w, map[string]any{"ok": false, "error_code": 400, "description": "message not found"}) + return + } + m.Text = sent.Text + m.EditDate = 1 + } + sent.ID = m.MessageID + s.Sent = append(s.Sent, sent) + cp := *m + s.mu.Unlock() + writeJSON(w, map[string]any{"ok": true, "result": cp}) + case "answerCallbackQuery": + s.mu.Lock() + s.Answers = append(s.Answers, str("text")) + s.mu.Unlock() + writeJSON(w, map[string]any{"ok": true, "result": true}) + default: + writeJSON(w, map[string]any{"ok": false, "error_code": 404, "description": "Not Found"}) + } +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(v) +} + +// Tap queues a button tap by user from on message msg in chat, with data. +func (s *Server) Tap(from, chat, msg int64, data string) { + s.mu.Lock() + m := s.msgs[msg] + var cp *telegram.Message + if m != nil { + c := *m + c.Chat = telegram.Chat{ID: chat} + cp = &c + } else { + cp = &telegram.Message{MessageID: msg, Chat: telegram.Chat{ID: chat}} + } + s.updates = append(s.updates, telegram.Update{UpdateID: s.nextUp, CallbackQuery: &telegram.CallbackQuery{ + ID: "cq", From: telegram.User{ID: from}, Message: cp, Data: data, + }}) + s.nextUp++ + s.mu.Unlock() + select { + case s.wake <- struct{}{}: + default: + } +} + +// EditByThief changes the text of a message as a token thief would. +func (s *Server) EditByThief(msg int64, text string) { + s.mu.Lock() + defer s.mu.Unlock() + if m := s.msgs[msg]; m != nil { + m.Text = text + m.EditDate = 2 + } +} + +// Messages returns a copy of what was sent. +func (s *Server) Messages() []Sent { + s.mu.Lock() + defer s.mu.Unlock() + return append([]Sent(nil), s.Sent...) +} + +// Pending reports how many taps getUpdates has not confirmed yet is not +// tracked; Updates returns the number of queued updates. +func (s *Server) Updates() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.updates) +} diff --git a/internal/telegram/telegram.go b/internal/telegram/telegram.go new file mode 100644 index 0000000..fe65435 --- /dev/null +++ b/internal/telegram/telegram.go @@ -0,0 +1,195 @@ +// Package telegram is a small Bot API client. Only the listener uses it +// (rule 4). The token is part of every URL, so no error from here ever +// holds a URL or the token. +package telegram + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Client calls the Bot API. +type Client struct { + base string + token string + http *http.Client +} + +// New returns a client for apiURL (https://api.telegram.org). +func New(apiURL, token string) *Client { + return &Client{base: strings.TrimRight(apiURL, "/"), token: token, http: &http.Client{Timeout: 90 * time.Second}} +} + +// String hides the token. +func (c *Client) String() string { return "telegram.Client{token: hidden}" } + +// User is a Telegram user. +type User struct { + ID int64 `json:"id"` +} + +// Chat is a Telegram chat. +type Chat struct { + ID int64 `json:"id"` +} + +// Message is a Telegram message. +type Message struct { + MessageID int64 `json:"message_id"` + Chat Chat `json:"chat"` + Text string `json:"text"` + EditDate int64 `json:"edit_date,omitempty"` +} + +// CallbackQuery is a button tap. +type CallbackQuery struct { + ID string `json:"id"` + From User `json:"from"` + Message *Message `json:"message,omitempty"` + Data string `json:"data"` +} + +// Update is one update of getUpdates. +type Update struct { + UpdateID int64 `json:"update_id"` + CallbackQuery *CallbackQuery `json:"callback_query,omitempty"` +} + +// Button is an inline keyboard button. +type Button struct { + Text string `json:"text"` + CallbackData string `json:"callback_data"` +} + +// APIError is an error answer of the Bot API. +type APIError struct { + Method string + Code int + Desc string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("telegram %s: error %d: %s", e.Method, e.Code, e.Desc) +} + +// Permanent reports whether retrying cannot help (a 4xx other than 429). +func (e *APIError) Permanent() bool { return e.Code >= 400 && e.Code < 500 && e.Code != 429 } + +type answer struct { + OK bool `json:"ok"` + Result json.RawMessage `json:"result"` + ErrorCode int `json:"error_code"` + Description string `json:"description"` +} + +func (c *Client) call(ctx context.Context, method string, req, resp any) error { + body, err := json.Marshal(req) + if err != nil { + return err + } + hr, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base+"/bot"+c.token+"/"+method, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("telegram %s: bad api_url", method) + } + hr.Header.Set("Content-Type", "application/json") + res, err := c.http.Do(hr) + if err != nil { + var ue *url.Error + if errors.As(err, &ue) { + err = ue.Err // the url.Error holds the URL, and the URL holds the token + } + return fmt.Errorf("telegram %s: %s", method, c.hide(err.Error())) + } + defer func() { _ = res.Body.Close() }() + b, err := io.ReadAll(io.LimitReader(res.Body, 4<<20)) + if err != nil { + return fmt.Errorf("telegram %s: read: %s", method, c.hide(err.Error())) + } + var a answer + if err := json.Unmarshal(b, &a); err != nil { + return &APIError{Method: method, Code: res.StatusCode, Desc: "the answer is not JSON"} + } + if !a.OK { + code := a.ErrorCode + if code == 0 { + code = res.StatusCode + } + return &APIError{Method: method, Code: code, Desc: c.hide(Clip(a.Description))} + } + if resp != nil { + if err := json.Unmarshal(a.Result, resp); err != nil { + return fmt.Errorf("telegram %s: bad result: %w", method, err) + } + } + return nil +} + +func (c *Client) hide(s string) string { + if c.token == "" { + return s + } + return strings.ReplaceAll(s, c.token, "") +} + +// Clip cuts a text from Telegram for a log line. +func Clip(s string) string { + if len(s) > 200 { + return s[:200] + } + return s +} + +// DeleteWebhook removes a webhook, so getUpdates works. +func (c *Client) DeleteWebhook(ctx context.Context) error { + return c.call(ctx, "deleteWebhook", map[string]any{"drop_pending_updates": false}, nil) +} + +// GetUpdates long-polls for button taps. +func (c *Client) GetUpdates(ctx context.Context, offset int64, timeout int) ([]Update, error) { + var ups []Update + err := c.call(ctx, "getUpdates", map[string]any{ + "offset": offset, "timeout": timeout, "allowed_updates": []string{"callback_query"}, + }, &ups) + return ups, err +} + +// SendMessage sends an HTML message, with buttons and as a reply when set. +func (c *Client) SendMessage(ctx context.Context, chat int64, text string, buttons []Button, replyTo int64) (*Message, error) { + req := map[string]any{ + "chat_id": chat, "text": text, "parse_mode": "HTML", + "link_preview_options": map[string]bool{"is_disabled": true}, + } + if len(buttons) > 0 { + req["reply_markup"] = map[string]any{"inline_keyboard": [][]Button{buttons}} + } + if replyTo != 0 { + req["reply_parameters"] = map[string]any{"message_id": replyTo, "allow_sending_without_reply": true} + } + var m Message + if err := c.call(ctx, "sendMessage", req, &m); err != nil { + return nil, err + } + return &m, nil +} + +// EditMessageText replaces the text of a message and removes its buttons. +func (c *Client) EditMessageText(ctx context.Context, chat, msg int64, text string) error { + return c.call(ctx, "editMessageText", map[string]any{ + "chat_id": chat, "message_id": msg, "text": text, "parse_mode": "HTML", + "link_preview_options": map[string]bool{"is_disabled": true}, + "reply_markup": map[string]any{"inline_keyboard": [][]Button{}}, + }, nil) +} + +// AnswerCallbackQuery answers a tap. +func (c *Client) AnswerCallbackQuery(ctx context.Context, id, text string) error { + return c.call(ctx, "answerCallbackQuery", map[string]any{"callback_query_id": id, "text": text}, nil) +} diff --git a/internal/testgit/testgit.go b/internal/testgit/testgit.go new file mode 100644 index 0000000..ae172a8 --- /dev/null +++ b/internal/testgit/testgit.go @@ -0,0 +1,102 @@ +// Package testgit makes local git repos for tests: a bare remote and a +// work tree that pushes to it. Only tests use it. +package testgit + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// NeedGit skips the test when git is missing, except on CI, where it fails. +func NeedGit(t testing.TB) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + if os.Getenv("CI") != "" { + t.Fatal("git is missing; CI must have git") + } + t.Skip("git is missing") + } +} + +// PrivateDir returns a test directory that only this user can write, and +// whose parent only this user can write, whatever the umask is. +func PrivateDir(t testing.TB) string { + t.Helper() + d := t.TempDir() + for _, p := range []string{filepath.Dir(d), d} { + if err := os.Chmod(p, 0o700); err != nil { + t.Fatal(err) + } + } + return d +} + +// Remote is a bare repo and a work tree that pushes to it. +type Remote struct { + t testing.TB + Bare string + Work string +} + +// New makes an empty bare repo with branch main, and a work tree. +func New(t testing.TB) *Remote { + t.Helper() + NeedGit(t) + d := t.TempDir() + r := &Remote{t: t, Bare: filepath.Join(d, "remote.git"), Work: filepath.Join(d, "work")} + r.git(d, "init", "--quiet", "--bare", "--initial-branch=main", r.Bare) + r.git(d, "init", "--quiet", "--initial-branch=main", r.Work) + return r +} + +func (r *Remote) git(dir string, args ...string) string { + r.t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", + "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com", + "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com") + out, err := cmd.CombinedOutput() + if err != nil { + r.t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +// Git runs git in the work tree and returns its output. +func (r *Remote) Git(args ...string) string { r.t.Helper(); return r.git(r.Work, args...) } + +// Commit writes files (path -> content; an empty content removes the +// file), commits them with the subject, pushes to main, and returns the SHA. +func (r *Remote) Commit(subject string, files map[string]string) string { + r.t.Helper() + for p, c := range files { + full := filepath.Join(r.Work, p) + if c == "" { + if err := os.Remove(full); err != nil { + r.t.Fatal(err) + } + continue + } + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + r.t.Fatal(err) + } + if err := os.WriteFile(full, []byte(c), 0o644); err != nil { + r.t.Fatal(err) + } + } + r.Git("add", "-A") + r.Git("commit", "--quiet", "--allow-empty", "-m", subject) + r.Git("push", "--quiet", "--force", r.Bare, "HEAD:refs/heads/main") + return r.Git("rev-parse", "HEAD") +} + +// Push force-pushes rev to main of the bare repo. +func (r *Remote) Push(rev string) { + r.t.Helper() + r.Git("push", "--quiet", "--force", r.Bare, rev+":refs/heads/main") +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go new file mode 100644 index 0000000..66816fd --- /dev/null +++ b/test/e2e/e2e_test.go @@ -0,0 +1,180 @@ +//go:build e2e + +// Package e2e is the end-to-end test (DESIGN.md section 14): the real +// binary, a local bare repo with two commits, the listener against a fake +// Telegram server, a tap, and the apply of the second commit. +package e2e + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/user" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/code6x/pullgate/internal/telegram/faketg" + "github.com/code6x/pullgate/internal/testgit" +) + +const ( + owner = 4242 + chat = 4242 + token = "123:e2e-token-made-by-the-test" +) + +func TestEndToEnd(t *testing.T) { + syscall.Umask(0o077) + testgit.NeedGit(t) + base := testgit.PrivateDir(t) + bin := filepath.Join(base, "pullgate") + build := exec.Command("go", "build", "-o", bin, "github.com/code6x/pullgate/cmd/pullgate") + build.Env = append(os.Environ(), "CGO_ENABLED=0") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + u, err := user.Current() + if err != nil { + t.Fatal(err) + } + etc, state, spool, live := filepath.Join(base, "etc"), filepath.Join(base, "state"), filepath.Join(base, "spool"), filepath.Join(base, "live") + for _, d := range []string{etc, state, spool, live} { + if err := os.Mkdir(d, 0o700); err != nil { + t.Fatal(err) + } + } + apply := filepath.Join(etc, "apply.sh") + write(t, apply, "#!/bin/sh\nset -e\ncp \"$PULLGATE_TREE/policy.toml\" "+live+"/policy.toml\necho \"$PULLGATE_SHA\" > "+live+"/sha\n", 0o700) + + r := testgit.New(t) + write(t, filepath.Join(etc, "config-repo.toml"), fmt.Sprintf(`host = "example-host" +on_failure = "rollback" +[repo] +url = %q +[commands] +apply = [%q] +check_user = %q +[listener] +user = %q +[watch] +paths = ["policy.toml"] +`, r.Bare, apply, u.Username, u.Username), 0o600) + + tg := faketg.New(t, token) + write(t, filepath.Join(etc, "token"), token+"\n", 0o600) + write(t, filepath.Join(etc, "listener.toml"), fmt.Sprintf(`token_file = %q +owner_id = %d +chat_id = %d +instances = ["config-repo"] +spool_dir = %q +state_file = %q +api_url = %q +`, filepath.Join(etc, "token"), owner, chat, spool, filepath.Join(base, "listener.json"), tg.URL), 0o600) + + pg := func(args ...string) string { + t.Helper() + all := append([]string{"--config-dir", etc, "--state-dir", state, "--spool-dir", spool}, args...) + out, err := exec.Command(bin, all...).CombinedOutput() + if err != nil { + t.Fatalf("pullgate %v: %v\n%s", args, err, out) + } + return string(out) + } + + r.Commit("first policy", map[string]string{"policy.toml": "allow = 1\n"}) + second := r.Commit("second policy", map[string]string{"policy.toml": "allow = 2\n"}) + + // The first run asks about the tip: the second commit. + if out := pg("run", "config-repo"); !strings.Contains(out, "result=asked") { + t.Fatalf("run: %s", out) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + listen := exec.CommandContext(ctx, bin, "listen", "--config", filepath.Join(etc, "listener.toml"), "--trust-own-config") + var logs strings.Builder + listen.Stdout, listen.Stderr = &logs, &logs + listen.Cancel = func() error { return listen.Process.Signal(syscall.SIGTERM) } + if err := listen.Start(); err != nil { + t.Fatal(err) + } + stopped := false + stop := func() { + if !stopped { + stopped = true + cancel() + _ = listen.Wait() + } + } + defer stop() + + var q faketg.Sent + wait(t, "the question", func() bool { + for _, m := range tg.Messages() { + if m.Method == "sendMessage" && len(m.Buttons) == 2 { + q = m + return true + } + } + return false + }) + if !strings.Contains(q.Text, ""+second+"") || !strings.Contains(q.Text, "Watched path changed") { + t.Fatalf("the question:\n%s", q.Text) + } + if strings.Contains(q.Buttons[0].CallbackData, second[:10]) { + t.Fatal("the button must carry a code, not the SHA") + } + // A tap from someone else does nothing; then the owner taps Apply. + tg.Tap(owner+1, chat, q.ID, q.Buttons[0].CallbackData) + tg.Tap(owner, chat, q.ID, q.Buttons[0].CallbackData) + inbox := filepath.Join(spool, "config-repo", "inbox") + wait(t, "the approval", func() bool { + es, _ := os.ReadDir(inbox) + return len(es) == 1 + }) + + if out := pg("apply", "config-repo"); !strings.Contains(out, "result=applied") { + t.Fatalf("apply: %s", out) + } + got, _ := os.ReadFile(filepath.Join(live, "policy.toml")) + gotSHA, _ := os.ReadFile(filepath.Join(live, "sha")) + if string(got) != "allow = 2\n" || strings.TrimSpace(string(gotSHA)) != second { + t.Fatalf("the second commit was not applied: %q %q", got, gotSHA) + } + if out := pg("status", "config-repo"); !strings.Contains(out, "applied: "+second) { + t.Fatalf("status: %s", out) + } + wait(t, "the report", func() bool { + for _, m := range tg.Messages() { + if strings.Contains(m.Text, "✅ Applied "+second) { + return true + } + } + return false + }) + stop() + if strings.Contains(logs.String(), token) { + t.Fatal("the listener logged the token") + } +} + +func write(t *testing.T, p, s string, mode os.FileMode) { + t.Helper() + if err := os.WriteFile(p, []byte(s), mode); err != nil { + t.Fatal(err) + } +} + +func wait(t *testing.T, what string, ok func() bool) { + t.Helper() + for end := time.Now().Add(30 * time.Second); time.Now().Before(end); time.Sleep(100 * time.Millisecond) { + if ok() { + return + } + } + t.Fatalf("timed out waiting for %s", what) +}