Skip to content

Commit d5e71ec

Browse files
groupskyclaude
andauthored
feat: run, listen, apply and status (#3)
* feat: run, listen, apply and status Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test: every rule, end-to-end test, units, examples, README Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: findings of the security and correctness reviews Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: run unit score --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 2bc846d commit d5e71ec

47 files changed

Lines changed: 7044 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/ci.yml‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,43 @@ jobs:
5454
CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -trimpath -o "/tmp/pullgate-$arch" ./cmd/pullgate
5555
done
5656
57+
e2e:
58+
name: end-to-end test
59+
runs-on: ubuntu-latest
60+
timeout-minutes: 15
61+
steps:
62+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
63+
with:
64+
persist-credentials: false
65+
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
66+
with:
67+
go-version-file: go.mod
68+
- run: git --version
69+
- run: go vet -tags e2e ./test/e2e/...
70+
- name: End-to-end test
71+
# A local bare repo with two commits, the real binary, the listener
72+
# against a fake Telegram server, a tap, and the apply of the
73+
# second commit (DESIGN.md section 14).
74+
run: go test -race -tags e2e ./test/e2e/...
75+
76+
units:
77+
name: example units
78+
runs-on: ubuntu-latest
79+
steps:
80+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
81+
with:
82+
persist-credentials: false
83+
- name: Check the example scripts and units
84+
run: |
85+
set -eu
86+
for f in contrib/examples/*/*.sh; do sh -n "$f"; done
87+
# The binary is not installed here; every other message fails.
88+
out="$(systemd-analyze verify --man=no contrib/systemd/* 2>&1 || true)"
89+
echo "$out"
90+
if echo "$out" | grep -v "Command /usr/local/bin/pullgate is not executable" | grep -q .; then
91+
exit 1
92+
fi
93+
5794
lint:
5895
runs-on: ubuntu-latest
5996
steps:

‎.golangci.yml‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ linters:
3838
msg: library code must not use the default logger; take a *slog.Logger
3939
# Match on types, so an import alias or a *log.Logger cannot hide a call.
4040
analyze-types: true
41+
gosec:
42+
excludes:
43+
# G301/G302: pullgate sets the mode of each directory and file on
44+
# purpose (DESIGN.md sections 4 and 6), and the security review
45+
# checks them.
46+
- G301
47+
- G302
4148
nolintlint:
4249
require-explanation: true
4350
require-specific: true
@@ -50,6 +57,11 @@ linters:
5057
linters:
5158
- forbidigo
5259
text: library code must not
60+
# Tests and the test helpers make their own files and run git.
61+
- path: (_test\.go$|^internal/testgit/|^internal/telegram/faketg/|^test/)
62+
linters:
63+
- gosec
64+
- noctx
5365

5466
formatters:
5567
enable:

‎DESIGN.md‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,9 @@ The paths can be changed with `--config-dir`, `--state-dir` and
108108
config, the deploy key, the known hosts file, or the program (the first
109109
word) of a command is a symlink, is not owned by root, or can be written by
110110
group or others; or when a directory above one of them fails the check of
111-
section 6. The deploy key must also not be readable by group or others. The
111+
section 6. A symlink on the way to a program (such as `/bin -> usr/bin`)
112+
is allowed only when it is owned by root and the directory that holds it
113+
passes the check too. The deploy key must also not be readable by group or others. The
112114
listener checks `listener.toml` and its token file the same way (the token
113115
file must be owned by the listener user). In tests, "root" means the user
114116
that runs the test.
@@ -399,8 +401,9 @@ The steps, with the state synced to disk at each arrow:
399401
2. Export fresh trees (section 6). Run `apply` as root in the tree of the
400402
SHA (environment in section 11). Then `health`, if it is set.
401403
3. Both succeed: the applied SHA becomes this SHA, `refs/pullgate/applied`
402-
moves, `in_progress` and the held SHA are cleared, all marks of older
403-
SHAs are dropped → report "applied".
404+
moves, `in_progress` and the held SHA are cleared, all SHA marks are
405+
dropped (a SHA that is not a descendant is refused again anyway) →
406+
report "applied".
404407
4. One fails: count a failed try for this SHA →. Then by `on_failure`:
405408
- `rollback`, with an applied SHA: write `in_progress = {phase:
406409
"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
459462
just failed and its tree; `PULLGATE_CHANGED_FILES` lists the paths that
460463
differ between them. So `apply` must be safe to run again on an older tree.
461464

462-
Their working directory is `PULLGATE_TREE`. Each has a timeout
465+
Their working directory is `PULLGATE_TREE`. Their umask is 022. Each has a timeout
463466
(`check_timeout`, `apply_timeout`); on timeout the whole process group is
464467
killed and the command counts as failed. pullgate keeps at most 64 KiB of
465468
their output. The output of `apply` and `health` goes to the journal; the
@@ -469,7 +472,9 @@ section 12.
469472

470473
**check and summary (rule 3)** run as `check_user`, with its own group and
471474
no other groups. When pullgate runs as root, `check_user` must not be root
472-
(uid 0): pullgate refuses such a config. When pullgate does not run as root
475+
(uid 0), and must not be the listener user or share its group, because a
476+
check that is taken over could then write approvals: pullgate refuses such
477+
a config. When pullgate does not run as root
473478
(tests, a trial), it cannot change user, so `check_user` must be the user
474479
that runs it. A process of `check_user` could leave its process group and
475480
live on. So after each `check` and `summary`, root runs `kill -KILL -1` as

‎README.md‎

Lines changed: 196 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,197 @@
11
# pullgate
2-
Pull a git repo, ask one person on Telegram to apply one exact commit, then apply it. Small Go tool for plain Linux hosts.
2+
3+
pullgate lets a Linux host change only through its own updates. The host
4+
pulls a git repo by itself. It applies a new commit only after its owner
5+
taps **Apply** on a Telegram question that names that exact commit. Then
6+
root applies it, with a health check and a rollback or a hold.
7+
8+
- **The gate lives on the host.** What runs, who may approve and which paths
9+
get a warning come from `/etc/pullgate`, never from the repo. A commit
10+
cannot change the gate.
11+
- **One exact commit.** The question shows the full SHA. Apply applies only
12+
that SHA, even when a newer commit arrives in between.
13+
- **No code from the new commit runs before Apply.** Optional `check` and
14+
`summary` commands read the commit as data, as an unprivileged user.
15+
- **Only the listener holds the bot token.** It runs as its own user and
16+
opens no port.
17+
- **Forward only** by default. A force push or an older commit is refused.
18+
- **Root never runs git in a directory another user can write.**
19+
20+
It is a small Go program for plain Linux with systemd. It needs `git` and
21+
`ssh`. No server, no web UI.
22+
23+
Read [DESIGN.md](DESIGN.md) for how it works and
24+
[THREAT-MODEL.md](THREAT-MODEL.md) for what it protects and where it stops.
25+
26+
## How it works
27+
28+
```
29+
timer ──> pullgate run NAME (root)
30+
fetch ─> read the commit ─> check, summary (check_user)
31+
─> question into the outbox
32+
│
33+
pullgate listen (own user, holds the token) ─> Telegram ─> owner taps Apply
34+
│
35+
approval (only a one-time code) into the inbox
36+
│
37+
path unit ─> pullgate apply NAME (root)
38+
looks the code up in its own state ─> apply ─> health
39+
─> success: report; failure: rollback or hold, and report
40+
```
41+
42+
## Install
43+
44+
pullgate has no release yet. Build it with Go 1.26 or newer:
45+
46+
```sh
47+
CGO_ENABLED=0 go build -trimpath -o pullgate ./cmd/pullgate
48+
```
49+
50+
Then, as root:
51+
52+
```sh
53+
install -m 0755 pullgate /usr/local/bin/pullgate
54+
useradd --system --no-create-home --shell /usr/sbin/nologin pullgate
55+
# One check user per instance, never the listener user:
56+
useradd --system --no-create-home --shell /usr/sbin/nologin pullgate-check-config-repo
57+
install -d -m 0755 /etc/pullgate /var/spool/pullgate
58+
install -d -m 0711 /var/lib/pullgate
59+
cp contrib/systemd/* /etc/systemd/system/
60+
```
61+
62+
1. Make a Telegram bot with BotFather. Put its token in
63+
`/etc/pullgate/bot-token` (owner root, mode 0600).
64+
2. Find your Telegram user ID and the chat ID of your private chat with the
65+
bot. Write `/etc/pullgate/listener.toml` from
66+
[contrib/examples/listener.toml](contrib/examples/listener.toml).
67+
3. Make a **read-only** deploy key for the repo:
68+
`ssh-keygen -t ed25519 -N '' -f /etc/pullgate/NAME.key`. Add the public key
69+
to the repo as a read-only deploy key.
70+
4. Pin the host key of the git server in `/etc/pullgate/NAME.known_hosts`.
71+
Check the fingerprint against the one your git host publishes.
72+
5. Write `/etc/pullgate/NAME.toml` (owner root, mode 0600). Start from one of
73+
the examples below.
74+
6. Start it:
75+
76+
```sh
77+
systemctl daemon-reload
78+
systemctl enable --now pullgate-listen.service
79+
systemctl enable --now pullgate-apply@NAME.path pullgate-run@NAME.timer
80+
```
81+
82+
Every step logs to the journal: `journalctl -t pullgate`.
83+
84+
## Config
85+
86+
The instance config `/etc/pullgate/NAME.toml`:
87+
88+
```toml
89+
mode = "ask" # "ask" (default), "auto" (no question; for development hosts) or "off"
90+
on_failure = "rollback" # "rollback" or "pause"; the default is "pause"
91+
host = "example-host" # shown in each message
92+
93+
[repo]
94+
url = "ssh://git@git.example.com/owner/config-repo.git"
95+
branch = "main"
96+
deploy_key = "/etc/pullgate/NAME.key"
97+
known_hosts = "/etc/pullgate/NAME.known_hosts"
98+
99+
[commands]
100+
check = ["/path/to/check"] # optional; runs as check_user; a failure means no question
101+
summary = ["/path/to/summary"] # optional; runs as check_user; its output is in the question
102+
apply = ["/path/to/apply"] # required; runs as root after Apply
103+
health = ["/path/to/health"] # optional; runs as root after apply
104+
check_user = "pullgate-check"
105+
106+
[watch]
107+
paths = ["policy/**", "*.toml"] # a warning for each changed path that matches
108+
109+
[limits]
110+
questions_per_day = 10
111+
approval_ttl = "12h"
112+
max_failed_tries = 1
113+
allow_older = false
114+
```
115+
116+
The commands get these environment variables: `PULLGATE_SHA`,
117+
`PULLGATE_OLD_SHA`, `PULLGATE_TREE` (a clean, read-only copy of the commit),
118+
`PULLGATE_OLD_TREE` (the same for the applied commit),
119+
`PULLGATE_CHANGED_FILES` (a file with the changed paths, each ended by a NUL
120+
byte), `PULLGATE_FAILED_SHA`, `PULLGATE_ROLLBACK` and `PULLGATE_INSTANCE`.
121+
The full list of keys and what they do is in [DESIGN.md](DESIGN.md),
122+
sections 5 and 11.
123+
124+
**What happens when an apply fails** is your choice per instance:
125+
126+
- `on_failure = "rollback"`: pullgate runs `apply` again with the last good
127+
commit, then `health`. So `apply` must be safe to run again on an older
128+
tree. If the rollback fails too, the instance is paused.
129+
- `on_failure = "pause"`: no rollback. The failed commit is held and not
130+
tried again. A newer commit is still asked, with a warning at the top, so
131+
you can fix it forward.
132+
133+
`pullgate status NAME` shows the state. As root:
134+
`pullgate status NAME --pause` stops the instance,
135+
`pullgate status NAME --resume` starts it again, and
136+
`pullgate status NAME --ask-again` asks about the tip again.
137+
138+
## Example 1: a config file and a service reload
139+
140+
A service reads one policy file from a private repo. pullgate checks the
141+
file, shows a diff of it in the question, copies it into place and reloads
142+
the service. A failed health check rolls back to the last good file.
143+
144+
Files: [contrib/examples/config-file/](contrib/examples/config-file/)
145+
146+
- `config-repo.toml`: `on_failure = "rollback"`, a watch on `policy.toml`.
147+
- `check.sh`: the file must exist, be small and be plain text.
148+
- `summary.sh`: `diff -u` of the old and the new tree.
149+
- `apply.sh`: install the file and `systemctl reload`.
150+
- `health.sh`: `systemctl is-active`.
151+
152+
## Example 2: ansible-playbook on the tree
153+
154+
A server runs `ansible-playbook` locally on the approved commit. Running an
155+
older playbook again can do more harm than good, so this example holds a
156+
failed commit instead of rolling back.
157+
158+
Files: [contrib/examples/ansible/](contrib/examples/ansible/)
159+
160+
- `playbooks.toml`: `on_failure = "pause"`, watches on `ansible.cfg`,
161+
`site.yml` and sudoers files.
162+
- `apply.sh`: `ansible-playbook --connection local site.yml` in the tree.
163+
164+
There is no `check` here: `ansible-playbook --syntax-check` loads
165+
`ansible.cfg` and plugins from the tree, and that is code from the commit
166+
before Apply.
167+
168+
## The systemd units
169+
170+
[contrib/systemd/](contrib/systemd/) has the units. Their
171+
`systemd-analyze security` scores (systemd 255, 2026-11-11; lower is
172+
better):
173+
174+
| unit | score | why |
175+
|---|---|---|
176+
| `pullgate-listen.service` | 1.2 OK | own user, no capabilities, read-only system |
177+
| `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 |
178+
| `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`) |
179+
180+
The run unit sets `MemoryDenyWriteExecute=yes`, which also holds for
181+
`check` and `summary`. A command that needs a JIT (for example Node.js) does
182+
not work there.
183+
184+
## Development
185+
186+
```sh
187+
go test -race ./...
188+
go vet ./...
189+
golangci-lint run --build-tags e2e
190+
go test -race -tags e2e ./test/e2e/...
191+
```
192+
193+
The rules for changes are in [AGENTS.md](AGENTS.md).
194+
195+
## License
196+
197+
Apache-2.0. See [LICENSE](LICENSE).

0 commit comments

Comments
 (0)