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..5c65c23 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,73 @@ +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 test ./... + - 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..716b775 --- /dev/null +++ b/deploy/CI.md @@ -0,0 +1,146 @@ +# 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. +- "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 + +`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."