From 02f546a24451be6d6818291daab380118b1f41de Mon Sep 17 00:00:00 2001 From: Sandamond Date: Thu, 3 Sep 2026 02:05:25 +0300 Subject: [PATCH 1/3] Add CI: test on every PR, deploy on merge to master Adds two GitHub Actions workflows and the scripts/docs behind them: - .github/workflows/test.yml runs on GitHub-hosted runners for any pull request (forks included) and on push to master: gofmt, go vet, go test, go build for both the relay module and keys-panel, plus bash -n on every deploy script. - .github/workflows/deploy.yml runs deploy/ci-deploy.sh on a self-hosted runner on the production host, triggered only by push to master. This repository is public, and self-hosted runners are a well-known way for a public repo to turn "anyone can open a PR" into "anyone can execute code on whatever the runner sits on" - GitHub's own docs warn against exactly this combination. deploy/CI.md is the trust model for why this deployment doesn't have that problem: the deploy workflow only ever triggers on a push to master, master is branch-protected (PR + collaborator-only merge, enforced for the owner too), so nothing an outside PR does can produce a push event against this repository's master - not the "require approval" setting, the trigger topology itself. test.yml intentionally stays on GitHub-hosted runners so external PRs still get real CI signal without ever reaching production. Also, in support of that: - deploy/ci-deploy.sh: the one thing the self-hosted runner's unprivileged service account can run as root (via one exact, argument-free sudoers rule) - calls the existing deploy/update-relay.sh and the new keys-panel/deploy/update-keys-panel.sh, both test-build-validate-install -with-rollback, same shape as update-relay.sh already documented in README.md. Deliberately not redeployed by the pipeline it gates, so a bad merge can't widen its own privileges on the next run - see deploy/CI.md for the installation step this implies. - keys-panel/deploy/update-keys-panel.sh: the same safe-update pattern for tproxy-keys, which had no equivalent before this. Runner installation itself (creating the ghrunner account, registering the runner, the sudoers grant) is a manual step on the production host - not something this PR does, since it requires host access this repository's CI can't have yet. Documented in deploy/CI.md. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/deploy.yml | 29 ++++++ .github/workflows/test.yml | 72 +++++++++++++ README.md | 7 ++ deploy/CI.md | 136 +++++++++++++++++++++++++ deploy/ci-deploy.sh | 35 +++++++ keys-panel/deploy/update-keys-panel.sh | 101 ++++++++++++++++++ 6 files changed, 380 insertions(+) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/test.yml create mode 100644 deploy/CI.md create mode 100755 deploy/ci-deploy.sh create mode 100755 keys-panel/deploy/update-keys-panel.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..b284c76 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,29 @@ +name: deploy + +# Deliberately triggers on `push` to `master` only - never on `pull_request`, +# even from this repository itself. A push to master can only happen via a +# merge, which the branch protection on master restricts to repository +# collaborators (currently just the owner). This is what keeps the +# self-hosted runner below safe to use on a public repository: an outside +# PR can update this very workflow file in its own branch, but that changed +# file only ever runs (via pull_request, on a GitHub-hosted runner, see +# test.yml) against the fork it lives in - it can never cause a `push` event +# on *this* repository's `master`. See deploy/CI.md for the full writeup. +on: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: deploy-production + cancel-in-progress: false + +jobs: + deploy: + runs-on: [self-hosted, tproxy-deploy] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: deploy + run: sudo /usr/local/sbin/ci-deploy.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..ec58931 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,72 @@ +name: test + +# Runs on GitHub-hosted runners only, for any pull request including ones +# from forks - this job never touches production and needs no elevated +# trust. See deploy/CI.md for why the deploy workflow is kept strictly +# separate from this one. +on: + pull_request: + push: + branches: [master] + +permissions: + contents: read + +concurrency: + group: test-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + relay: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.5' + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [[ -n "$unformatted" ]]; then + echo "not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + - run: go vet ./... + - run: go test ./... + - run: go build -trimpath ./... + + keys-panel: + runs-on: ubuntu-latest + defaults: + run: + working-directory: keys-panel + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version: '1.26.5' + - name: gofmt + run: | + unformatted="$(gofmt -l .)" + if [[ -n "$unformatted" ]]; then + echo "not gofmt-clean:" + echo "$unformatted" + exit 1 + fi + - run: go vet ./... + - run: go build -trimpath ./... + + shellcheck-syntax: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: bash -n on every deploy script + run: | + set -euo pipefail + status=0 + while IFS= read -r -d '' script; do + echo "checking $script" + bash -n "$script" || status=1 + done < <(find deploy keys-panel/deploy -name '*.sh' -print0) + exit "$status" diff --git a/README.md b/README.md index 8302e43..36bbe90 100644 --- a/README.md +++ b/README.md @@ -571,6 +571,13 @@ when the old deployment was ready, backend readiness; a failure automatically ro back to the previous binary. Existing carrier sessions are invalidated; clients must obtain a fresh bridge page and relay session automatically. +This script (and the equivalent for `keys-panel/`) is what +[`deploy/CI.md`](deploy/CI.md) runs automatically on every merge to `master`, from a +self-hosted GitHub Actions runner on the production host. That document is also the +trust model for why a self-hosted runner is safe to use here despite this being a +public repository - read it before changing anything under `.github/workflows/` or +`deploy/ci-deploy.sh`. + This script intentionally does not replace configuration, systemd units, Caddy, MTProxy, firewall rules, or public-site files. Running the complete automated installer again preserves an existing site directory but replaces the single-profile diff --git a/deploy/CI.md b/deploy/CI.md new file mode 100644 index 0000000..fbff66f --- /dev/null +++ b/deploy/CI.md @@ -0,0 +1,136 @@ +# Continuous deployment + +`master` is built and deployed by two GitHub Actions workflows, +[`.github/workflows/test.yml`](../.github/workflows/test.yml) and +[`.github/workflows/deploy.yml`](../.github/workflows/deploy.yml). This +document is the trust model behind them: why a self-hosted runner sitting on +the production host is safe to use on a public repository, what it can and +cannot do, and how to operate it. + +## The problem a self-hosted runner normally creates + +GitHub's own documentation warns against using self-hosted runners with +public repositories: anyone can fork a public repo and open a pull request, +and if a workflow that pull request can influence executes on a self-hosted +runner, that PR gets arbitrary code execution on whatever host the runner +lives on. On this repository, that host is the production relay serving real +client keys - that would be a real compromise, not a hypothetical one. + +## Why this deployment doesn't have that problem + +The two workflows are split by trust level, not just by task: + +- **`test.yml`** runs on GitHub-hosted runners (`ubuntu-latest`) for both + `pull_request` (any branch, forks included) and `push` to `master`. Hosted + runners are ephemeral, sandboxed, and have no access to this host or its + secrets, so it's safe to run for anyone's PR. +- **`deploy.yml`** runs on the self-hosted runner (label `tproxy-deploy`) and + triggers **only** on `push` to `master` - never on `pull_request`, not even + for this repository's own PRs. + +A `push` event on `master` can only exist as the result of a merge, and +`master` is a GitHub branch-protection-protected branch: direct pushes are +refused (`enforce_admins` is on, so this applies to the repository owner +too), and merging requires being a repository collaborator. A fork can +change `deploy.yml` or add a malicious step in its own copy of the file, but +nothing a fork does can make GitHub emit a `push` event against *this* +repository's `master` - only an actual merge, performed by someone with +write access, does that. That is the entire safety property the self-hosted +runner rests on; it does not depend on GitHub's separate "require approval +for outside collaborators" setting for workflow runs, though that setting is +also enabled here as defense in depth for `test.yml`. + +Practical consequence: reviewing what's allowed to reach the runner reduces +to reviewing who can merge to `master` (currently: the repository owner +alone) and what `deploy.yml` and `ci-deploy.sh` actually execute (below) - +not to auditing every past or future community PR. + +## What actually runs on the runner, and as whom + +The self-hosted runner's systemd service (`actions-runner-tproxy-server`) +runs as an unprivileged, dedicated Linux user (`ghrunner`), not root. Its +only path to privilege is one exact, argument-free sudoers rule: + +``` +ghrunner ALL=(root) NOPASSWD: /usr/local/sbin/ci-deploy.sh +``` + +`ci-deploy.sh` ([`deploy/ci-deploy.sh`](ci-deploy.sh)) takes no arguments and +reads nothing attacker-influenced beyond the already-merged repository +checkout at a fixed path; it calls +[`deploy/update-relay.sh`](update-relay.sh) and +[`keys-panel/deploy/update-keys-panel.sh`](../keys-panel/deploy/update-keys-panel.sh), +the same test-build-validate-install-with-rollback scripts described in the +main [`README.md`](../README.md#operations-and-updates) and +[`keys-panel/README.md`](../keys-panel/README.md). It does not touch +`profiles.json`, systemd units, Caddy, MTProxy's config, or the public site, +matching those scripts' own documented scope. + +**`ci-deploy.sh` and its sudoers grant are not redeployed by the pipeline +they gate.** Installing `/usr/local/sbin/ci-deploy.sh` and the sudoers file +is a manual, one-time (or manually repeated) step on the runner host, +described below - never something `deploy.yml` writes to. If the pipeline +could rewrite the privileges it runs under, a single bad or malicious merge +could escalate itself on its very next run; keeping that file and its grant +outside the automated path is what prevents that. + +## Runner installation (reference / disaster recovery) + +Already done once on the production host; this is what to repeat if the +runner needs reinstalling. + +```bash +sudo useradd --system --create-home --home /home/ghrunner --shell /usr/sbin/nologin ghrunner +sudo install -d -o ghrunner -g ghrunner -m 0755 /home/ghrunner/actions-runner +cd /home/ghrunner/actions-runner +sudo -u ghrunner curl --fail --silent --show-error --location \ + --proto '=https' --proto-redir '=https' --tlsv1.2 \ + -o actions-runner-linux-x64.tar.gz \ + https://github.com/actions/runner/releases/download/v2.337.0/actions-runner-linux-x64-2.337.0.tar.gz +test "$(sha256sum actions-runner-linux-x64.tar.gz | awk '{print $1}')" = "70920811a4f8ad4328818682bca5c6469c1c942fab52448868071d0063816613" +sudo -u ghrunner tar xzf actions-runner-linux-x64.tar.gz + +# Registration tokens are single-use and expire in about an hour; generate +# one right before configuring the runner, from a machine with gh authorized +# against this repository (not necessarily this host): +# gh api -X POST repos/sandamond/tproxy-server/actions/runners/registration-token --jq .token + +sudo -u ghrunner ./config.sh --url https://github.com/sandamond/tproxy-server \ + --token "$REGISTRATION_TOKEN" \ + --name dev-landing-prod --labels tproxy-deploy --work _work --unattended --replace +sudo ./svc.sh install ghrunner +sudo ./svc.sh start +``` + +Install the deploy wrapper and its narrow sudo grant (also manual, see above +for why): + +```bash +sudo install -o root -g root -m 0700 deploy/ci-deploy.sh /usr/local/sbin/ci-deploy.sh +echo 'ghrunner ALL=(root) NOPASSWD: /usr/local/sbin/ci-deploy.sh' | sudo tee /etc/sudoers.d/tproxy-ci-runner >/dev/null +sudo chmod 0440 /etc/sudoers.d/tproxy-ci-runner +sudo visudo -c +``` + +`ci-deploy.sh` changes: edit the file in the repository, get it merged like +anything else, then manually re-run the `install` line above on the runner +host. `deploy.yml` will keep calling the old version until that's done - by +design, per the section above. + +## Required repository settings + +- Branch protection on `master`: PR required, `enforce_admins` on, force-push + and deletion disallowed (already configured). Once `test.yml` has run at + least once, its check should be added to `required_status_checks` so a red + test run blocks merging, not just informs it. +- Settings → Actions → General → "Fork pull request workflows from outside + collaborators" → require approval for all outside collaborators. Defense + in depth for `test.yml` on the hosted runner; not load-bearing for the + self-hosted runner's safety, which rests on the trigger topology above. + +## If the pipeline is down + +`deploy/update-relay.sh` and `keys-panel/deploy/update-keys-panel.sh` are +ordinary scripts; run them by hand over SSH exactly as before this pipeline +existed if the runner or GitHub Actions itself is unavailable. Nothing about +normal operation depends on the pipeline being up. diff --git a/deploy/ci-deploy.sh b/deploy/ci-deploy.sh new file mode 100755 index 0000000..ca2d32d --- /dev/null +++ b/deploy/ci-deploy.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Invoked as root via a single exact-match, no-argument sudoers rule from the +# unprivileged CI runner account (see deploy/CI.md). Nothing about this +# invocation is attacker-controllable through its arguments, because it takes +# none. The only externally-influenced input is the already fully tested +# repository checkout at the fixed path below, which only reaches this +# script by way of a `git push` to this repository's branch-protected +# `master` - i.e. only after a merge, which only a repository collaborator +# can perform. See deploy/CI.md for the full trust boundary this rests on. +# +# This script itself is NOT redeployed by the pipeline it drives: installing +# it and granting sudo access to it is a deliberate one-time (or manually +# repeated) step on the runner host, kept separate from the automatic update +# path below on purpose - see deploy/CI.md. + +repository=/home/ghrunner/actions-runner/_work/tproxy-server/tproxy-server + +if [[ "${EUID}" -ne 0 ]]; then + echo "run as root (this script expects to be invoked through sudo)" >&2 + exit 1 +fi +if [[ ! -d "$repository/.git" ]]; then + echo "expected repository checkout not found at $repository" >&2 + exit 1 +fi + +echo "== relay ==" +"$repository/deploy/update-relay.sh" + +echo "== tproxy-keys ==" +"$repository/keys-panel/deploy/update-keys-panel.sh" + +echo "deploy complete" diff --git a/keys-panel/deploy/update-keys-panel.sh b/keys-panel/deploy/update-keys-panel.sh new file mode 100755 index 0000000..66df029 --- /dev/null +++ b/keys-panel/deploy/update-keys-panel.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${EUID}" -ne 0 ]]; then + echo "run this updater as root from the uploaded repository" >&2 + exit 1 +fi + +module_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +installed=/usr/local/bin/tproxy-keys +next=/usr/local/bin/tproxy-keys.next +previous=/usr/local/bin/tproxy-keys.previous +service=tproxy-keys.service +health=http://127.0.0.1:9000/ +temporary="$(mktemp -d /tmp/tproxy-keys-update.XXXXXX)" +candidate="$temporary/tproxy-keys" +trap 'rm -rf -- "$temporary"' EXIT + +if [[ ! -f "$installed" ]]; then + echo "tproxy-keys is not installed; follow the manual install steps in keys-panel/README.md first" >&2 + exit 1 +fi +for required_command in curl flock install systemctl; do + if ! command -v "$required_command" >/dev/null 2>&1; then + echo "$required_command is required" >&2 + exit 1 + fi +done +exec 9>/run/lock/tproxy-keys-update.lock +if ! flock -n 9; then + echo "another tproxy-keys update is already running" >&2 + exit 1 +fi + +go_binary= +go_candidates=() +if command -v go >/dev/null 2>&1; then + go_candidates+=("$(command -v go)") +fi +for found in /opt/go*/bin/go; do + go_candidates+=("$found") +done +for found in "${go_candidates[@]}"; do + if [[ ! -x "$found" ]]; then + continue + fi + version="$("$found" env GOVERSION 2>/dev/null || true)" + if [[ "$version" =~ ^go1\.([0-9]+) ]] && (( BASH_REMATCH[1] >= 24 )); then + go_binary="$found" + break + fi +done +if [[ -z "$go_binary" ]]; then + echo "Go 1.24 or newer was not found in PATH or /opt/go*/bin/go" >&2 + exit 1 +fi + +wait_for() { + local url="$1" + for ((attempt = 0; attempt != 20; ++attempt)); do + if curl --fail --silent --output /dev/null "$url"; then + return 0 + fi + sleep 1 + done + return 1 +} + +rollback() { + echo "New tproxy-keys failed verification; restoring $previous" >&2 + install -o root -g root -m 0755 "$previous" "$next" + mv -f "$next" "$installed" + if ! systemctl restart "$service" || ! wait_for "$health"; then + echo "Rollback failed; inspect: journalctl -u $service -n 100 --no-pager" >&2 + return 1 + fi + echo "Previous tproxy-keys restored and healthy" >&2 +} + +echo "Vetting tproxy-keys source" +(cd "$module_root" && "$go_binary" vet ./...) + +echo "Building tproxy-keys candidate with $go_binary" +(cd "$module_root" && "$go_binary" build \ + -trimpath -ldflags='-s -w' -o "$candidate" .) + +echo "Installing tproxy-keys candidate" +backup_next="$temporary/tproxy-keys.previous" +cp -a "$installed" "$backup_next" +install -o root -g root -m 0755 "$backup_next" "$previous" +install -o root -g root -m 0755 "$candidate" "$next" +mv -f "$next" "$installed" + +echo "Restarting only $service" +if ! systemctl restart "$service" || ! wait_for "$health"; then + rollback + exit 1 +fi + +echo "tproxy-keys update complete" +echo "Existing panel sessions were invalidated; sign in again with the same token." From da142e79b0a8cb388aa6f346543b4238f00271d1 Mon Sep 17 00:00:00 2001 From: Sandamond Date: Thu, 3 Sep 2026 02:21:40 +0300 Subject: [PATCH 2/3] Document the verified fork-PR-approval API command Pinned down the actual endpoint and parameter values by hand (the docs returned by an assistant web fetch pointed at the wrong one) and applied it to the repository: fork-pr-contributor-approval, policy all_external_contributors. Co-Authored-By: Claude Sonnet 5 --- deploy/CI.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/deploy/CI.md b/deploy/CI.md index fbff66f..716b775 100644 --- a/deploy/CI.md +++ b/deploy/CI.md @@ -123,10 +123,20 @@ design, per the section above. and deletion disallowed (already configured). Once `test.yml` has run at least once, its check should be added to `required_status_checks` so a red test run blocks merging, not just informs it. -- Settings → Actions → General → "Fork pull request workflows from outside - collaborators" → require approval for all outside collaborators. Defense - in depth for `test.yml` on the hosted runner; not load-bearing for the - self-hosted runner's safety, which rests on the trigger topology above. +- "Fork pull request workflows from outside collaborators" set to require + approval for all external contributors (already applied): + + ```bash + gh api --method PUT repos/sandamond/tproxy-server/actions/permissions/fork-pr-contributor-approval \ + -f approval_policy=all_external_contributors + ``` + + Defense in depth for `test.yml` on the hosted runner; not load-bearing for + the self-hosted runner's safety, which rests on the trigger topology above. + This setting has three possible values + (`first_time_contributors_new_to_github`, `first_time_contributors`, + `all_external_contributors`) - GitHub's own naming, not something to guess + from the web UI label alone. ## If the pipeline is down From 08fd3fb52d924374ca392c0416d48d83d9b11fa8 Mon Sep 17 00:00:00 2001 From: Sandamond Date: Thu, 3 Sep 2026 02:37:38 +0300 Subject: [PATCH 3/3] test.yml: run go test in the keys-panel job too The keys-panel job only ran vet and build, so a future regression test added to that module could pass CI green without ever actually running - the relay job's go test ./... doesn't descend into keys-panel, since it's a separate Go module. No test files exist there yet, so this is a no-op today (`go test ./...` just reports "no test files"), but it's the correct steady state to be in when tests do get added. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ec58931..5c65c23 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -55,6 +55,7 @@ jobs: exit 1 fi - run: go vet ./... + - run: go test ./... - run: go build -trimpath ./... shellcheck-syntax: