Skip to content

Salt control services after VPN enrollment

For queued SSH installation, verified backups and recovery to an empty target, see master automation. The steps below describe the underlying Salt role and remain useful for explicit maintenance.

Apply control_master separately after roles.control_plane and Headscale node enrollment. It is deliberately excluded from initial bootstrap: missing VPN or credentials must prevent startup rather than expose public Salt ports.

Prerequisites: onedir salt-master/salt-api 3006, the system user salt, checkout at /srv/oduflow, and 100.64.0.1 assigned to tailscale0. External pillar uses https://100.64.0.2/oduflow/pillar/. Salt's bundled Python must trust the gateway CA and validate its correct IP SAN.

files/master.conf is generated by salt/master/render.py with --vpn-ip 100.64.0.1 --odoo-url https://100.64.0.2. It contains secret paths, not secret values. When changing addresses, regenerate it and run scripts/validate-salt-master.py.

Secret provisioning

Prepare these files outside Git:

Server file Content Ownership / mode
/etc/salt/oduflow-api.htpasswd oduflow-api:<SHA512-crypt hash> root:salt, 0640
/etc/salt/oduflow-pillar.token Token matching Odoo ODUFLOW_PILLAR_TOKEN root:salt, 0640
/etc/salt/pki/api/server.crt Full TLS chain for headscale.example.com root:salt, 0640
/etc/salt/pki/api/server.key Matching private TLS key root:salt, 0640

Generate the password hash using Passlib 1.7.4: sha512_crypt.using(rounds=200000).hash(password). Keep the random cleartext API password in the Odoo secret configuration. It is separate from the Unix salt account. Never put credentials in pillar, command arguments or state sources. Install already prepared files without printing them:

install -o root -g salt -m 0640 /run/credentials/oduflow-api.htpasswd /etc/salt/oduflow-api.htpasswd
install -o root -g salt -m 0640 /run/credentials/oduflow-pillar.token /etc/salt/oduflow-pillar.token

The installed /usr/local/sbin/oduflow-sync-salt-certificate copies the initial TLS certificate from Traefik. Its canonical source is salt/states/control_master/files/sync-certificate.py. Let's Encrypt must have issued the certificate first; the helper creates neither certificates nor ACME accounts. With missing TLS files, Salt installs the helper but file.exists checks block service startup. Sync the certificate and apply again.

Apply and operate

salt-call --local state.apply control_master

The state installs passlib==1.7.4 and CherryPy==18.10.0 into /opt/saltstack/salt/bin/python3, manages configuration and removes persistent and runtime masks from salt-master/salt-api. Secret files are checked for existence without being printed or overwritten.

Both services depend on tailscaled. Before startup, a helper waits up to 120 seconds for 100.64.0.1 on tailscale0; missing addresses cause failure and a systemd retry. Salt API starts after the master.

oduflow-sync-salt-certificate.timer runs hourly with Persistent=true and up to five minutes of jitter. It updates private TLS files from /var/lib/traefik/acme.json and restarts a running salt-api only when the certificate/key changed. It never changes DNS or calls ACME.

After application, check systemctl is-active salt-master salt-api, systemctl list-timers oduflow-sync-salt-certificate.timer, VPN-only listeners, successful allowed file-authenticated runners and rejection of forbidden runners. Connect using TLS hostname headscale.example.com while routing to 100.64.0.1; using the numeric VPN destination must not disable certificate-name verification.

Local tests cover generated-config equality and correct VPN wait/timeout behavior; Salt 3006 compilation is separate from a live apply. Run: python3 -m unittest discover -s salt/states/control_master -p test_control_master.py.