|
1 | 1 | # 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