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¶
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.