Skip to content

Open a PostgreSQL service to declared sources over TLS (S-006-8, 10) - #141

Merged
shipmill-romamo[bot] merged 2 commits into
mainfrom
feat/123-postgresql-remote-access
Oct 27, 2026
Merged

shipmill-romamo[bot] merged 2 commits into
mainfrom
feat/123-postgresql-remote-access

Conversation

@shipmill-romamo

Copy link
Copy Markdown
Contributor

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.remoteAccess with listenAddress and allowedSources, criteria S-006-8 and S-006-10. S-006-9 (psql across a proving pair of hosts) needs the live run, so the spec stays approved; its Verification section now names what ran and what didn't.

  • cloudfall.conf.j2: with remoteAccess, listen_addresses = '127.0.0.1,<listenAddress>', ssl = on, and ssl_cert_file/ssl_key_file pointing at the Debian ssl-cert snakeoil pair. Without it the file renders byte for byte as on main (pinned by the S-006-12 goldens)
  • Restart vs reload: listen_addresses and ssl only change at server start, so the role keeps its existing Restart PostgreSQL handler on the cloudfall.conf template, flushed before the readiness wait. With remoteAccess the role installs ssl-cert and adds postgres to the ssl-cert group (that task also notifies the restart, since the key is read at start). pg_hba.conf changes only need a reload, done after the restart
  • pg_hba.conf: one blockinfile between # BEGIN/END CLOUDFALL MANAGED REMOTE ACCESS markers, from the new pg_hba-remote-access.j2: one hostssl <db> <owner> <cidr> scram-sha-256 line per database with a passwordRef and per source. Databases named by application get no line. Without remoteAccess the same task runs with state: 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 queries pg_hba_file_rules for errors (PostgreSQL's own parse of the file on disk) and only then runs SELECT pg_reload_conf(), failing unless it returns t. If either step fails, a rescue removes the Cloudfall block (fail closed, so a broken block can't take effect at a later restart) and fails the play without reloading
  • nftables: ansible_inventory.py adds one cloudfall_firewall.allowedInbound entry per PostgreSQL service with remoteAccess on the host: {port, protocol: tcp, sources: [...], description: "<service> remoteAccess"}. nftables.conf.j2 renders an entry with sources as one ip saddr <cidr> or ip6 saddr <cidr> rule per source; entries without sources render exactly as today. The firewall role's argument spec takes the optional sources list
  • Defence in depth beyond config validate (S-006-11, built in Validate PostgreSQL databases owned by a declared role (S-006-1..4, S-006-11) #121):
    • the renderer raises on a /0 source, or on remoteAccess with no firewall on the server type
    • the PostgreSQL role asserts the listen address isn't *, 0.0.0.0, ::, localhost, or a comma-smuggled list, that the sources are non-empty and contain no 0.0.0.0/0 or ::/0, and that an owned database exists
    • the firewall role refuses a sources list that is empty or contains 0.0.0.0/0 or ::/0, and no longer counts a source-restricted rule as the SSH control port
  • The cloudfall_postgresql argument spec now accepts remoteAccess

The 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 touch validation.py.

Fixes #123
Part of #81

Test plan

  • engine/tests/test_postgresql_remote_access.py (new):
    • S-006-8: validates a copy of config/examples with billing (owner) added to postgresql-main next to the application database crm, renders it through render_ansible_inventory, and renders cloudfall.conf.j2, pg_hba-remote-access.j2 and nftables.conf.j2 with Ansible's whitespace rules. Checks listen_addresses = '127.0.0.1,10.0.0.2', ssl = on, the snakeoil paths, and a block of exactly hostssl billing billing 10.0.0.0/24 scram-sha-256 (no line for crm). The only rule for port 5432 is ip saddr 10.0.0.0/24 tcp dport 5432 accept ..., and the unrestricted entries are still 22, 80 and 443
    • multiple sources and databases give one line and one rule each, an IPv6 source gives ip6 saddr, and an IPv6 listen address joins the loopback
    • S-006-10: the same service without remoteAccess renders cloudfall.conf as exactly the three lines from main, no rule for port 5432, and the blockinfile state templates to absent
    • local role run, S-006-8 and S-006-10: the role's pg_hba.conf block, written out from tasks/main.yml, runs under ansible-playbook on localhost against a copy of Debian's pg_hba.conf, with a stand-in psql that answers pg_hba_file_rules and 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. When pg_hba_file_rules reports an error, the play fails naming it, never reloads, and the block is removed
    • Templar replays of the role's guard (wildcard, smuggled list, empty and /0 sources refused; a narrowed declaration accepted), of the firewall role's sources guard, and of the SSH-port check ignoring restricted rules
    • the renderer refuses a 0.0.0.0/0 source
  • engine/tests/test_postgresql_owner_role.py: the argument-spec test now expects remoteAccess
  • engine/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.conf and cloudfall.conf are byte-identical
  • specs.py coverage --spec 006 lists tests for S-006-8 and S-006-10; S-006-9 has none, as intended. specs.py check passes
  • Ran locally: uv 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, the baseline.yml and services.yml syntax checks, ansible-lint engine/ansible (with ANSIBLE_CONFIG=engine/ansible/ansible.cfg), and engine/tests plus sdk/tests/test_owned_postgresql.py, test_validation.py, test_inventory.py (244 passed, 1 skipped). The full suite is left to CI
  • Not verified on a live Debian host: S-006-9 itself, PostgreSQL's real parse of the block, the restart onto the second listener, TLS with the snakeoil key (the ssl-cert group membership), and nft loading the source-restricted rules (nft --check runs only on a host)

Plan

  • Serves: quality bar
  • Files: engine/src/cloudfall_engine/ansible_inventory.py, engine/ansible/roles/cloudfall_postgresql/{defaults/main.yml,meta/argument_specs.yml,tasks/main.yml,templates/cloudfall.conf.j2,templates/pg_hba-remote-access.j2}, engine/ansible/roles/cloudfall_firewall/{meta/argument_specs.yml,tasks/main.yml,templates/nftables.conf.j2}, engine/README.md, docs/specs/006-standalone-postgresql.md (Verification), changelog.d/123-postgresql-remote-access.md, engine/tests/test_postgresql_remote_access.py, engine/tests/test_postgresql_owner_role.py
  • Order: confirm S-006-11 validation already exists (Validate PostgreSQL databases owned by a declared role (S-006-1..4, S-006-11) #121), then the firewall sources entries in the renderer and template, the role's listener and TLS, the pg_hba.conf block with validate-then-reload, the guards, and tests per criterion (see the spec's "A way in for an outside app")
  • Proof: S-006-8, S-006-10 (specs.py coverage --spec 006); S-006-9 is left for the live proving run on two hosts

🤖 Generated with Claude Code

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>
@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

@romamo Decided without waiting: when the new pg_hba.conf block fails PostgreSQL's own check (pg_hba_file_rules) or pg_reload_conf() doesn't return true, the role removes the Cloudfall block, doesn't reload, and fails the run; a change to listen_addresses or ssl restarts the cluster.

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. listen_addresses only takes effect on restart.
Not taken: leaving the rejected block in place for the operator to fix (the next reload could fail or open access unexpectedly).

Reply with a comment starting with veto before 2026-10-28 11:40 UTC to reverse it, adding what you want instead if you like. A later veto still counts and gets a follow-up.

@shipmill-romamo

Copy link
Copy Markdown
Contributor Author

@romamo Decided without waiting: the cloudfall_postgresql role accepts remoteAccess (with new defaults cloudfall_postgresql_ssl_cert_file, cloudfall_postgresql_ssl_key_file, cloudfall_postgresql_hba_marker), and the cloudfall_firewall role's allowedInbound entries take an optional sources list; entries without sources render exactly as before.

Why: S-006-8 and S-006-10 ask for this, and the config/examples renders stay byte-identical.
Not taken: none; S-006-9 (the live two-host run) is still owed, so spec 006 stays approved and #81 open.

Reply with a comment starting with veto before 2026-10-28 11:40 UTC to reverse it, adding what you want instead if you like. A later veto still counts and gets a follow-up.

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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

decided An agent decided at notify; the maintainer may veto

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Open a PostgreSQL service to declared sources over TLS (S-006-8, 9, 10)

0 participants