Repository navigation
Open a PostgreSQL service to declared sources over TLS (S-006-8, 10) - #141
Conversation
A team that runs only its database on Cloudfall needs a way in for an app elsewhere. With spec.postgresql.remoteAccess, cloudfall.conf listens on 127.0.0.1 and the declared address with ssl on (the ssl-cert snakeoil pair; a changed file restarts the cluster), pg_hba.conf gets one hostssl SCRAM line per owned database and source between Cloudfall markers, reloaded only after pg_hba_file_rules reports no error, and the host's nftables ruleset gets one source-restricted rule per CIDR, rendered as an allowedInbound entry with a new optional sources list. Closed stays the default: without remoteAccess the listener is loopback only, the block is removed, and no rule is rendered, so an earlier opening never lingers. Entries without sources render as before (golden files). S-006-9 is left for the live proving run on two hosts. Fixes #123 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@romamo Decided without waiting: when the new Why: spec 006 says closed is the default, and it doesn't say what happens when the block is rejected; failing closed keeps remote access shut rather than leaving a half-applied file. Reply with a comment starting with |
|
@romamo Decided without waiting: the Why: S-006-8 and S-006-10 ask for this, and the Reply with a comment starting with |
A remoteAccess block line takes the database and owner bare, so a database named all (or sameuser, samerole, samegroup, replication) or an owner named all would open every database or every role to the declared sources, application databases included. The role's remote access guard now refuses those names before anything is written. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Implements S-006's way in for an outside app (
docs/specs/006-standalone-postgresql.md, "A way in for an outside app"):spec.postgresql.remoteAccesswithlistenAddressandallowedSources, criteria S-006-8 and S-006-10. S-006-9 (psqlacross a proving pair of hosts) needs the live run, so the spec staysapproved; its Verification section now names what ran and what didn't.cloudfall.conf.j2: withremoteAccess,listen_addresses = '127.0.0.1,<listenAddress>',ssl = on, andssl_cert_file/ssl_key_filepointing at the Debianssl-certsnakeoil pair. Without it the file renders byte for byte as on main (pinned by the S-006-12 goldens)listen_addressesandsslonly change at server start, so the role keeps its existingRestart PostgreSQLhandler on thecloudfall.conftemplate, flushed before the readiness wait. WithremoteAccessthe role installsssl-certand addspostgresto thessl-certgroup (that task also notifies the restart, since the key is read at start).pg_hba.confchanges only need a reload, done after the restartpg_hba.conf: oneblockinfilebetween# BEGIN/END CLOUDFALL MANAGED REMOTE ACCESSmarkers, from the newpg_hba-remote-access.j2: onehostssl <db> <owner> <cidr> scram-sha-256line per database with apasswordRefand per source. Databases named byapplicationget no line. WithoutremoteAccessthe same task runs withstate: absent, so a block left by an earlier declaration is removed (S-006-10). Everything outside the markers stays untouched. When the file changed, the role queriespg_hba_file_rulesfor errors (PostgreSQL's own parse of the file on disk) and only then runsSELECT pg_reload_conf(), failing unless it returnst. If either step fails, arescueremoves the Cloudfall block (fail closed, so a broken block can't take effect at a later restart) and fails the play without reloadingansible_inventory.pyadds onecloudfall_firewall.allowedInboundentry per PostgreSQL service withremoteAccesson the host:{port, protocol: tcp, sources: [...], description: "<service> remoteAccess"}.nftables.conf.j2renders an entry withsourcesas oneip saddr <cidr>orip6 saddr <cidr>rule per source; entries withoutsourcesrender exactly as today. The firewall role's argument spec takes the optionalsourceslistconfig validate(S-006-11, built in Validate PostgreSQL databases owned by a declared role (S-006-1..4, S-006-11) #121):/0source, or onremoteAccesswith no firewall on the server type*,0.0.0.0,::,localhost, or a comma-smuggled list, that the sources are non-empty and contain no0.0.0.0/0or::/0, and that an owned database existssourceslist that is empty or contains0.0.0.0/0or::/0, and no longer counts a source-restricted rule as the SSH control portcloudfall_postgresqlargument spec now acceptsremoteAccessThe validation codes the issue asked about (
service_remote_access_unused,_unfirewalled,_port_open,_open) were already built and tested by #121 (S-006-11), so this PR doesn't touchvalidation.py.Fixes #123
Part of #81
Test plan
engine/tests/test_postgresql_remote_access.py(new):config/exampleswithbilling(owner) added topostgresql-mainnext to the application databasecrm, renders it throughrender_ansible_inventory, and renderscloudfall.conf.j2,pg_hba-remote-access.j2andnftables.conf.j2with Ansible's whitespace rules. Checkslisten_addresses = '127.0.0.1,10.0.0.2',ssl = on, the snakeoil paths, and a block of exactlyhostssl billing billing 10.0.0.0/24 scram-sha-256(no line forcrm). The only rule for port 5432 isip saddr 10.0.0.0/24 tcp dport 5432 accept ..., and the unrestricted entries are still 22, 80 and 443ip6 saddr, and an IPv6 listen address joins the loopbackremoteAccessrenderscloudfall.confas exactly the three lines from main, no rule for port 5432, and theblockinfilestate templates toabsentpg_hba.confblock, written out fromtasks/main.yml, runs underansible-playbookon localhost against a copy of Debian'spg_hba.conf, with a stand-inpsqlthat answerspg_hba_file_rulesand logs reloads. Opening writes exactly the block after Debian's lines and reloads once. A second converge changes nothing and doesn't reload. Closing removes the block, and the file reads byte-identical to Debian's. Operator lines after the block survive its removal. Whenpg_hba_file_rulesreports an error, the play fails naming it, never reloads, and the block is removed/0sources refused; a narrowed declaration accepted), of the firewall role's sources guard, and of the SSH-port check ignoring restricted rules0.0.0.0/0sourceengine/tests/test_postgresql_owner_role.py: the argument-spec test now expectsremoteAccessengine/tests/test_examples_render_unchanged.py(S-006-12, goldens from Converge PostgreSQL databases owned by a declared role (S-006-5, 6, 7, 12, 13) #139) still passes: the example inventory,nftables.confandcloudfall.confare byte-identicalspecs.py coverage --spec 006lists tests for S-006-8 and S-006-10; S-006-9 has none, as intended.specs.py checkpassesuv run ruff check .,uv run mypy,uv run cloudfall config validate --project config/examples,cloudfall-engine inventory render --project config/examples --output tmp/ansible-inventory.json, thebaseline.ymlandservices.ymlsyntax checks,ansible-lint engine/ansible(withANSIBLE_CONFIG=engine/ansible/ansible.cfg), andengine/testsplussdk/tests/test_owned_postgresql.py,test_validation.py,test_inventory.py(244 passed, 1 skipped). The full suite is left to CIssl-certgroup membership), andnftloading the source-restricted rules (nft --checkruns only on a host)Plan
sourcesentries in the renderer and template, the role's listener and TLS, thepg_hba.confblock with validate-then-reload, the guards, and tests per criterion (see the spec's "A way in for an outside app")specs.py coverage --spec 006); S-006-9 is left for the live proving run on two hosts🤖 Generated with Claude Code