Move the publishing wrapper into pkg/publish and correct it

It was drafted from the Jenkins job definitions before anybody had
looked at the servers, and most of what it assumed about them was wrong.

The apt layout is per distribution release, with the packages sitting
directly in binary-<arch> and no shared pool, so packages-fetch and
stage-create were writing to paths that do not exist. The allowlists
were stale in both directions: plucky and RHEL 8 are not built, whilst
resolute, Fedora 43 and 44 were missing. sync-ftp-to-s3.py takes the
CloudFront distribution to invalidate rather than a choice of what to
sync, so that verb loses its argument entirely and reads the identifier
from the server, since the client has nothing to choose.

The indexing is no longer implemented here. It calls the scripts in this
directory instead, which are the same ones an administrator runs by hand
after purging old releases, so there is one implementation rather than
the four the buildfarm had. That also settles the doubled signing the
draft worried about: the scripts sign as part of indexing.

New verb: stage-sign. With the GPG key staying on the servers rather
than moving into Actions secrets, the workflow uploads unsigned
artefacts and asks for them to be signed here. A compromised workflow
can therefore ask for a signature over something it just uploaded, which
is real and acknowledged; what it cannot do is hold the key, sign out of
band, or sign with any other key.

The self-test covers 56 cases and passes.
This commit is contained in:
Dave Page committed 2026-09-17 13:10:13 +01:00
1 parent 3c038f8fd5
commit 596396acaf
4 files changed
+1359

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
# ~pgaupload/.ssh/authorized_keys on paxsor (ftp.pgadmin.org)
#
# The same two keys as on procyon, with the same options and for the same
# reasons; read the annotations in authorized_keys.procyon first, since they
# are not repeated here. What differs is the blast radius, because this host
# serves the packages that end users install, and the yum and apt signing keys
# live here.
#
# Placeholders in angle brackets must be substituted at install time:
#
# <ROADIE-IPV4>, <ROADIE-IPV6> the runner's addresses
# <...KEY...> the public key, one line, no line breaks
#
# Note that these are the same two key pairs as on procyon, not four. One
# publishing key and one upload key, each trusted by both hosts, each confined
# differently on each host by the options below. Two more key pairs would mean
# two more things to rotate and no reduction in what a compromised runner can
# reach, since a compromised runner has both of them anyway.
# ---------------------------------------------------------------------------
# Key 1: publishing. Verbs only.
# ---------------------------------------------------------------------------
# What a holder of this key can do on paxsor: create a v<VERSION> or snapshot
# directory (refusing if it already exists), pull the matching staging content
# from procyon, rebuild and sign the apt and yum metadata for an allowlisted
# distribution, and run the four existing publication scripts with arguments
# that have been validated or allowlisted first.
#
# What it cannot do, and this is the point of the exercise: delete anything,
# name a file, name a path, name a host, name a signing key, choose which
# binary runs, or supply a single character that reaches a shell.
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/local/sbin/pga-publish" ssh-ed25519 <...PUBLISH-KEY...> pga-publish@roadie
# ---------------------------------------------------------------------------
# Key 2: upload. Bytes only, and into a landing area rather than the live tree.
# ---------------------------------------------------------------------------
# On procyon the upload key writes into the staging root, because staging is
# what it is for. Here it writes into a separate landing directory that nothing
# serves, so a compromised runner cannot put a file where a user's apt or dnf
# would find it: only the wrapper's verbs move content into the published tree,
# and only from a validated staging directory.
#
# In the normal release flow this key is not used at all, since publication
# pulls from procyon. It is here for the occasional out-of-band artefact that
# has no staging build behind it, and if review concludes that case does not
# exist, the right answer is to delete this line rather than to keep it warm.
#
# SITE: create /var/ftp/incoming owned by pgaupload, outside the document root
# and outside /var/ftp/pgadmin4, and confirm that nothing serves it.
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/bin/rrsync -wo -no-del -munge /var/ftp/incoming" ssh-ed25519 <...UPLOAD-KEY...> pga-upload@roadie
+89
View File
@@ -0,0 +1,89 @@
# ~pgaupload/.ssh/authorized_keys on procyon (developer.pgadmin.org)
#
# Two keys, two jobs, two blast radii. Neither of them can give you a shell.
#
# Placeholders in angle brackets must be substituted at install time. Do not
# commit the substituted file to a public repository: the addresses are not
# secret, but publishing the exact shape of the firewall is free reconnaissance
# for somebody who has not earned it.
#
# <ROADIE-IPV4>, <ROADIE-IPV6> the runner's addresses
# <PAXSOR-IPV4> the download server's address
# <...KEY...> the public key, one line, no line breaks
#
# Options used, and why:
#
# restrict Refuse everything optional, then add back nothing. This is
# the modern shorthand for no-agent-forwarding, no-pty,
# no-port-forwarding, no-X11-forwarding and no-user-rc, and
# crucially it is future-proof: when OpenSSH grows a new
# forwarding feature, `restrict` disables that too, whereas an
# explicit list of no-* options silently would not.
#
# no-pty matters more than it looks. Without it, a client can
# ask for a terminal, and a terminal is an interactive channel
# into whatever the forced command turns out to be.
#
# from= Defence in depth behind the firewall, not instead of it. The
# firewall can be misconfigured during maintenance; this line
# is in the same file as the key it constrains, so the two are
# reviewed together and rotated together.
#
# command= The forced command. Whatever the client asked to run is put
# in SSH_ORIGINAL_COMMAND and this runs instead. The wrapper
# parses that variable itself and never lets a shell near it.
#
# There is deliberately no environment= option on either key. The wrapper
# builds its children's environment from scratch, but not offering the client
# an input channel at all is better than filtering one.
# ---------------------------------------------------------------------------
# Key 1: publishing. Verbs only. No file content passes through this key.
# ---------------------------------------------------------------------------
# What a holder of this key can do on procyon: create a staging directory from
# a fixed template, list staging directories, and rebuild and sign the apt and
# yum metadata inside one. That is the entire capability set; it is enumerated
# in the VERBS table in pga-publish, on one screen, on purpose.
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/local/sbin/pga-publish" ssh-ed25519 <...PUBLISH-KEY...> pga-publish@roadie
# ---------------------------------------------------------------------------
# Key 2: upload. Bytes only. No verbs, no metadata, no signing.
# ---------------------------------------------------------------------------
# rrsync ships with rsync (on Debian and Ubuntu, /usr/share/doc/rsync/scripts,
# or /usr/bin/rrsync on newer packages: check before writing this line). It is
# itself a forced-command wrapper, written and reviewed by the rsync
# maintainers, whose whole job is to inspect the rsync argument list the client
# sent and refuse anything outside the subtree it was given.
#
# -wo write-only. The client may send files and may not fetch them.
# A compromised runner therefore cannot read back whatever else
# lives under the staging root, and cannot use this key to
# exfiltrate anything.
# -no-del no deletion. Uploads add and overwrite; they never remove. A
# wrong --delete in a workflow, or a deliberate one from an
# attacker, stops here rather than emptying developer.pgadmin.org.
# -munge munge incoming symlinks so that they cannot be followed. Without
# it, an upload can drop a symlink pointing at, say, the account's
# ~/.ssh, and the next thing that writes through that path writes
# somewhere it should not.
#
# The trailing path is the root the client is confined to. Every path the
# client names is resolved relative to it, and rrsync refuses anything that
# climbs out, so the client never sees an absolute path at all.
restrict,from="<ROADIE-IPV4>,<ROADIE-IPV6>",command="/usr/bin/rrsync -wo -no-del -munge /var/www/html/builds" ssh-ed25519 <...UPLOAD-KEY...> pga-upload@roadie
# ---------------------------------------------------------------------------
# Key 3: the download server's read-only pull.
# ---------------------------------------------------------------------------
# paxsor fetches staging content from procyon during publication, so it holds a
# key here. It is read-only and confined to the same staging root, which is a
# tree that developer.pgadmin.org already serves over plain HTTP; this edge
# therefore grants paxsor nothing it could not have fetched with curl, and it
# is worth saying so explicitly in review, because "the download server can SSH
# to the staging server" sounds worse than it is.
#
# If that reasoning is ever rejected, the alternative is for roadie to pull the
# content down and push it up again over its own two keys, at the cost of
# moving every byte twice. The wrapper would lose release-fetch,
# packages-fetch and snapshot-fetch, and gain nothing else.
restrict,from="<PAXSOR-IPV4>",command="/usr/bin/rrsync -ro /var/www/html/builds" ssh-ed25519 <...PULL-KEY...> pga-pull@paxsor
+1087
View File
File diff suppressed because it is too large. Load diff
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
#
# selftest.sh: exercise the pga-publish request parser without touching
# anything. Every case runs the wrapper in --dry-run mode, which prints the
# argv it would have executed rather than executing it, so this is safe to run
# on a workstation, on a staging host, or on the download server itself.
#
# The interesting half of the table is the rejections. A parser that accepts
# the right things is table stakes; a parser that refuses a semicolon, a
# backtick, a newline and a `..` is the reason this wrapper exists, and those
# cases should be extended every time somebody thinks of a new way in.
#
# Usage: ./selftest.sh [path-to-pga-publish]
set -u
WRAPPER="${1:-$(dirname "$0")/pga-publish}"
pass=0
fail=0
# check <expected-exit> <role> <description> <request...>
check() {
local expected="$1"; shift
local role="$1"; shift
local description="$1"; shift
local output
output="$("$WRAPPER" --dry-run --role "$role" "$@" 2>&1)"
local rc=$?
if [ "$rc" = "$expected" ]; then
pass=$((pass + 1))
printf 'ok %-3s %s\n' "$rc" "$description"
else
fail=$((fail + 1))
printf 'FAIL %-3s (wanted %s) %s\n' "$rc" "$expected" "$description"
printf ' %s\n' "$output"
fi
}
echo "== accepted requests: staging role (procyon) =="
check 0 staging "hello" hello
check 0 staging "stage-create" stage-create 2026-09-17
check 0 staging "stage-create with -N suffix" stage-create 2026-09-17-2
check 0 staging "stage-list" stage-list
check 0 staging "stage-exists" stage-exists 2026-09-17
check 0 staging "stage-index-apt" stage-index-apt 2026-09-17
check 0 staging "stage-index-yum" stage-index-yum 2026-09-17
echo
echo "== accepted requests: download role (paxsor) =="
check 0 download "release-exists" release-exists 9.18
check 0 download "release-create" release-create 9.18
check 0 download "release-create with patch" release-create 9.18.1
check 0 download "release-fetch" release-fetch 9.18 2026-09-17
check 0 download "packages-fetch" packages-fetch 2026-09-17
check 0 download "snapshot-create" snapshot-create 2026-09-17
check 0 download "rebuild-apt" rebuild-apt bookworm
check 0 download "rebuild-yum" rebuild-yum redhat rhel 9 x86_64
check 0 download "create-release" create-release 9.18
check 0 download "load-docs" load-docs 9.18
check 0 download "purge-cache" purge-cache
check 0 download "sync-s3" sync-s3
echo
echo "== rejected: shell metacharacters (exit 64) =="
check 64 staging "semicolon" "stage-create 2026-09-17; id"
check 64 staging "ampersands" "stage-create 2026-09-17 && id"
check 64 staging "pipe" "stage-create 2026-09-17 | sh"
check 64 staging "command substitution" 'stage-create $(id)'
check 64 staging "backticks" 'stage-create `id`'
check 64 staging "variable expansion" 'stage-create $HOME'
check 64 staging "newline" "$(printf 'stage-create 2026-09-17\nrebuild-apt bookworm')"
check 64 staging "tab" "$(printf 'stage-create\t2026-09-17')"
check 64 staging "single quotes" "stage-create '2026-09-17'"
check 64 staging "redirection" "stage-create 2026-09-17 > /etc/passwd"
check 64 staging "NUL-adjacent control char" "$(printf 'stage-create 2026-09-17\r')"
echo
echo "== rejected: path traversal (exit 64) =="
check 64 staging "absolute path" "stage-create /etc"
check 64 staging "relative traversal" "stage-create ../../etc"
check 64 staging "bare dot-dot" "stage-create .."
check 64 staging "dot-dot inside a name" "stage-create 2026-09-17..x"
check 64 staging "hidden directory" "stage-create .ssh"
check 64 download "traversal in version" "release-create ../../etc"
echo
echo "== rejected: malformed arguments (exit 64) =="
check 64 staging "unknown verb" "no-such-verb"
check 64 staging "empty request" ""
check 64 staging "missing argument" "stage-create"
check 64 staging "extra argument" "stage-create 2026-09-17 2026-09-18"
check 64 staging "impossible date" "stage-create 2026-13-45"
check 64 staging "date before the build farm" "stage-create 1999-01-01"
check 64 staging "date far in the future" "stage-create 2099-01-01"
check 64 staging "datestamp that is not a date" "stage-create latest"
check 64 download "version with four parts" "release-create 9.18.1.2"
check 64 download "version with letters" "release-create 9.18beta"
check 64 staging "over-long request" "stage-create $(printf '2%.0s' $(seq 1 300))"
echo
echo "== rejected: not permitted (exit 77) =="
check 77 staging "download verb on staging" "release-create 9.18"
check 77 download "staging verb on download" "stage-create 2026-09-17"
check 77 download "codename not allowlisted" "rebuild-apt sarge"
check 77 download "yum target not allowlisted" "rebuild-yum redhat rhel 7 x86_64"
check 77 download "mismatched yum tuple" "rebuild-yum fedora rhel 9 x86_64"
check 64 download "sync-s3 takes no argument" "sync-s3 release"
check 77 download "unpublished yum arch" "rebuild-yum redhat rhel 9 aarch64"
check 77 download "unpublished apt codename" "rebuild-apt plucky"
echo
echo "== the dry-run flag is not reachable from a client =="
# With SSH_ORIGINAL_COMMAND set, our own argv is ignored entirely, so a client
# that somehow contrived to influence it still cannot turn on dry-run, cannot
# choose a role, and cannot smuggle in a second request. On a host with no
# /etc/pga-publish.role this exits 69; on a real server it runs `hello`. What
# matters is that release-create does not appear in the output either way.
output="$(SSH_ORIGINAL_COMMAND=hello "$WRAPPER" --dry-run --role download \
release-create 9.18 2>&1)"
if printf '%s' "$output" | grep -q "would create"; then
fail=$((fail + 1))
echo "FAIL client reached --dry-run and release-create"
else
pass=$((pass + 1))
echo "ok argv ignored when SSH_ORIGINAL_COMMAND is set"
fi
echo
echo "passed $pass, failed $fail"
[ "$fail" = 0 ]