1
0
mirror of synced 2026-10-23 07:08:55 +03:00
Files
Dave Page bccef24bf7 Drop from= from the authorized_keys templates, which do not carry it
The deployed files are generated rather than hand written, and that option is
not one we can set there, so the templates described a restriction production
does not have. What a key may do is still bounded by restrict and the forced
command, which is the part that matters; what is lost is the second answer to
where it may be used from.
2026-09-22 13:07:19 +01:00
..

Publishing

Everything that puts a pgAdmin build in front of a user lives here: the scripts that index and sign the APT and YUM repositories, the README that sits at the top of each of them, and the wrapper that lets a GitHub Actions runner ask for those things to happen without being given a shell on the servers.

Two servers are involved, and which one a build goes to depends on what kind of build it is. The staging server holds release candidates at /var/www/html/builds/<date> and serves them over HTTP for testing, until they are promoted and copied across. The download server holds the published tree at /var/ftp/pgadmin4, which the world mirrors, and it is also where nightly snapshots go directly, under snapshots/<date>: snapshots are not staged and promoted, they are simply published somewhere nobody mirrors.

That last point is why the snapshots tree is the confinement root for the upload key on the download server. It is the one part of the download site that sync-ftp-to-s3.py excludes, so nothing written through that key can reach the archive bucket or the PostgreSQL mirrors.

Each server runs the same wrapper with a different role, and the role decides which verbs exist.

Files

File What it is
rebuild-apt-repo.sh Index and sign one APT tree
rebuild-yum-repo.sh Index and sign one YUM tree, and make the EL compatibility links
install-repo-readme.py Write the README at the top of an APT or YUM tree
sync-ftp-to-s3.py Copy the download tree to the archive bucket, build its indexes and invalidate the CDN
purge-pgadmin-releases.sh Delete superseded releases from the download tree and from both repositories
README.apt.in, README.yum.in The prose those READMEs are built from
aptftp.conf apt-ftparchive settings: Origin, Label, Suite, Description
CURRENT_MAINTAINER The marker the PostgreSQL mirror network expects in each published directory
pga-publish The forced-command wrapper
authorized_keys.staging, authorized_keys.download Annotated templates
selftest.sh Exercises the wrapper's parser without touching anything

All five scripts work standalone. Rebuilding a repository by hand after purging old releases is what rebuild-apt-repo.sh bookworm has always been for, and that has not changed; the wrapper calls the same script rather than carrying its own copy of the logic.

sync-ftp-to-s3.py and purge-pgadmin-releases.sh used to live in the pgadmin-org/pgaweb checkout under tools/, which was never really where they belonged: neither of them touches Django, the site database or Varnish, and both work on the download tree at /var/ftp/pgadmin4, so they are part of publishing rather than part of the website. They have moved here, and the wrapper's sync-s3 verb now runs the copy installed from this directory rather than the one in the website checkout.

That move has an order to it. The wrapper and the copy of sync-ftp-to-s3.py that it names must both be installed on the download server before the pgaweb side deletes its own copy, because until the new wrapper is in place the one running there is still looking for the script under /var/www/pgaweb/tools. The other three verbs that reach into that directory, create-release, load-docs and purge-cache, are genuinely website operations and stay exactly where they are.

purge-pgadmin-releases.sh has no verb and is not meant to get one. It is run by hand, on the download server, by somebody who has decided that a release is old enough to go; it deletes things, and the whole point of the wrapper is that a compromised runner cannot delete anything that is already published. Its default is a dry run, so the shape of the work is to read what it proposes and then re-run it with --live, which is a conversation rather than a command, and nothing a workflow could sensibly have. Rebuilding the repositories afterwards is the standalone use of rebuild-apt-repo.sh and rebuild-yum-repo.sh described above.

Installing

On both servers, as root:

install -d -m 755 /usr/local/lib/pga-publish
install -m 755 rebuild-apt-repo.sh rebuild-yum-repo.sh install-repo-readme.py \
    sync-ftp-to-s3.py purge-pgadmin-releases.sh \
    /usr/local/lib/pga-publish/
install -m 644 aptftp.conf README.apt.in README.yum.in CURRENT_MAINTAINER \
    /usr/local/lib/pga-publish/
install -m 755 pga-publish /usr/local/sbin/pga-publish

Then the role, which the wrapper refuses to start without, since defaulting it would mean a misconfigured host quietly accepting the other one's verbs:

echo staging  > /etc/pga-publish.role     # on the staging server
echo download > /etc/pga-publish.role     # on the download server

On the download server only, the CloudFront distribution that sync-s3 invalidates. It is an identifier rather than a credential, but it is site configuration, so it lives here rather than in the source tree:

echo '<distribution id>' > /etc/pga-publish.cloudfront

And the staging server it pulls from during a release, which is site configuration rather than code, and is deliberately not something the caller can name:

echo '<staging server>' > /etc/pga-publish.pull-host

aptftp.conf is installed with the scripts above rather than kept in ~pgaupload, which is where it lived before this: it is an input to what gets indexed and therefore to what gets signed, and nothing needs the publishing account to be able to write it.

Access

Three key pairs. A publishing key and an upload key belong to the runner; a pull key belongs to the download server, which uses it to fetch staged content from the staging server during publication. The pull key's private half must be at ~pgaupload/.ssh/pga-pull on the download server, which is where pga-publish looks for it; the name in the key management system is only a label, and the path is what matters.

authorized_keys.staging and authorized_keys.download are annotated templates: substitute the public keys, and read the annotations before changing an option, since each is there for a reason that is easier to write down than to rediscover.

The runner's two private keys live on its own filesystem, and the workflows reference them by path, taken from the PUBLISH_SSH_KEY and UPLOAD_SSH_KEY repository variables, rather than carrying copies in GitHub's secret store: they are already on the machine, and a second copy would mean a second place to rotate and a second place to leak from. The runner also needs the two servers in its ~/.ssh/known_hosts, since host key checking is on and there is nobody to answer a prompt.

The trade-off is that the boundary moves. An environment secret is gated by a deployment branch policy; a file on disk is gated by which workflows may run on that machine. This repository is public, so the runner belongs in a runner group restricted to these workflows, and nothing triggered by pull_request should target it.

The shape of it is that no key gets a shell. The publishing key runs pga-publish, which parses SSH_ORIGINAL_COMMAND itself and never passes it to one. The upload key runs rrsync, confined to the staging root, write-only, with deletion and symlink-following refused. restrict turns off everything optional, PTY allocation included, and keeps doing so as OpenSSH grows new options.

What the wrapper will not do, however it is asked

Neither key can give you a shell, but a key meant only to add something can still do damage if the verbs behind it will quietly change something that is already published. Three of them will not.

Publication is single-shot, over the whole promotion rather than over its first verb. release-create has always refused to make a v<VERSION> that exists, but release-fetch used to move a staging build's files over the contents of a release published months ago, and autoupdate-publish and sync-s3 would then carry the replacements to the auto-updater and into the archive bucket, which has no --delete and so no undo. It now refuses before moving anything if the release directory holds more than the maintainer markers release-create left, and refuses each individual file whose destination already exists. A release that needs correcting gets a new version, never a repair applied to the old one.

A live package is never replaced. packages-fetch pulls with --ignore-existing, so a staging build carrying a filename that is already published leaves the published bytes where they are rather than handing the rebuild something else to sign, and it names what it skipped rather than passing over it: during a genuine promotion, a collision means two builds have produced one filename and somebody needs to know which of them the mirrors have.

Snapshot retention is counted in days, not in directories. Keeping the newest five directories sounds equivalent, and is not, because a directory is something either key can create: snapshot-create makes one with the publishing key, and an upload under a name of its own choosing makes one with the upload key, since rrsync confines where a path lands but has nothing to say about what it is called. Five directories dated today with high suffixes filled the keep set and every real snapshot fell off the end of it. Keeping the newest five dates cannot be steered that way, because the newest date anybody can produce is today and today is kept however many directories carry it. The price, and it is the intended one rather than an accident, is that a day which built four times keeps all four.

Checking it

./selftest.sh                       # 70 cases, touches nothing
pga-publish --dry-run --role download rebuild-apt bookworm

--dry-run prints the argument vector each step would have run instead of running it, and is only honoured when SSH_ORIGINAL_COMMAND is unset, so a client cannot reach it.

What signs what, and where

The GPG key that signs packages and repository metadata stays on the servers. A workflow uploads unsigned artefacts and asks for stage-sign, so a compromised run can ask for a signature over something it has just uploaded, but never holds the key, signs out of band, or signs with a different one.

Be clear about how wide that is, because it is wider than "the packages it just built". The signing verbs select by file extension across the tree, so anything ending .tar.gz, .whl, .pdf or .epub gets a detached armoured signature, and every .rpm under yum/ is re-signed. The content is arbitrary, and a detached signature carries no context tying it to pgAdmin, so a signature obtained this way can be presented anywhere alongside the file it covers. Selecting by name instead would not change this: an attacker who can write to the tree can name a file whatever a filter expects. What actually bounds it is where the upload key can write, which on the download server is the snapshots tree alone, and on the staging server a build directory that nothing publishes without a promotion. Treat write access to either as equivalent to a signing oracle, and rotate the key if one is suspected.

The exceptions are the macOS notarisation and the Windows Authenticode signature, which happen on the runners because the Apple credentials and the Certum token cannot follow the GPG key onto these machines.