Salt Master¶
The repository provides Salt 3006 configuration, authenticated external pillar, and restricted runner methods for client enrollment and execution. The control plane and a real client have been deployed and tested; see the deployment journal for evidence and remaining work. Generating configuration alone does not provision a VM or establish VPN/TLS.
Generate and install¶
The renderer and local unit tests require Python 3.10+:
python3 salt/master/render.py --vpn-ip 100.64.0.1 \
--odoo-url https://odoo.control.example \
--output /tmp/oduflow-master.conf
python3 -m unittest discover -s tests -p test_salt_master.py -v
With Python from Salt 3006, also run python3 scripts/validate-salt-master.py.
It checks actual loaders, eauth ACLs, and file authentication using a temporary
random password without starting services. Install passlib==1.7.4 in that
Python environment; onedir installations must use Salt's bundled Python.
Replace the example addresses. The renderer accepts a Headscale address in
100.64.0.0/10 and an HTTPS Odoo origin without a path or credentials. Its JSON
output is valid YAML and contains no secrets. Existing output is not overwritten.
On a prepared control VM with salt-master and salt-api 3006 onedir and
CherryPy installed in Salt's environment:
- Install the reviewed checkout at
/srv/oduflow, readable bysalt, including its pinnedclientdependency. The fileserver publishes only that client tree. Create/srv/oduflow/salt/pillareven if it is empty. Thesalt/master/extmodsdirectory contains trusted code and must not be writable by the API user or minions. Do not runsaltutil.sync_allover this directory: synchronization can remove manually installed extensions. - Install the generated file as
/etc/salt/master.d/oduflow.conf. Review the complete master configuration: other includes must not enable autosign, public binds, broad eauth permissions, caches, or reactors. The paths/etc/salt/oduflow-autosign-disabledand/etc/salt/oduflow-autosign-grains-disabledmust not exist. - Store a separate pillar token in
/etc/salt/oduflow-pillar.token, owned byroot:salt, mode0640, with at least 32 characters and no whitespace. Configure the sameODUFLOW_PILLAR_TOKENin Odoo. Client VMs never receive it. - Install the API certificate and private key at
/etc/salt/pki/api/server.crtandserver.key; the key must beroot:salt, mode0640. Odoo must verify the certificate normally. The default backend is Salt's built-infileeauth. Installpasslib==1.7.4in Salt's environment and create/etc/salt/oduflow-api.htpasswd(root:salt,0640) with a dedicatedoduflow-apilogin and a random password hash. Usepasslib.hash.sha512_crypt.using(rounds=200000).hash(password)and the file formatoduflow-api:<hash>. Keep the password in Odoo's secret storage, outside Git, process arguments, and logs. Test authentication assalt. - Start services only after the Headscale address appears on
tailscale0. A systemd dependency ontailscaledmust also check address readiness; a running daemon does not by itself guarantee that the VPN address exists.
Master ports 4505/4506 and HTTPS API port 8000 bind to the VPN address. A bind address does not replace access controls: clients may reach only 4505/4506, while only the Odoo node may reach 8000. Only the master may access Odoo's pillar endpoint over the VPN. Enforce these rules in Headscale ACLs and host firewalls. DNS/TLS configuration must preserve VPN routing; the renderer cannot verify it.
The file login is not a system account and needs no /etc/shadow access.
salt-master and salt-api run as salt. --auth-backend pam remains available
for existing, verified PAM installations, but ordinary pam_unix cannot let an
unprivileged Salt API authenticate another system user. Do not grant root or
sudo to work around that limitation. See
Salt file eauth.
PBKDF2-SHA256 is not used: Passlib 1.7.4's default HtpasswdFile does not include
that scheme, and Salt does not supply a custom CryptContext. Explicit
SHA512-crypt avoids an obsolete default hash. Actual salt.auth.file.auth tests
cover valid authentication, wrong passwords, and unknown users.
Enrollment without trusting grains¶
Each client has the permanent ID client-<canonical UUID>. Before creating its
VM, the trusted orchestrator generates a unique RSA key pair, retains the public
key and SHA256 fingerprint in the operation intent, and supplies the private key
only to that VM's bootstrap. Golden images must contain no shared identity keys.
Before the minion first starts, install that exact, previously known public
key at /etc/salt/pki/master/minions/<minion_id> (salt:salt, 0600). Treat an
existing file as an idempotent repeat only after exact key comparison. A mismatch
is an identity conflict, never permission to overwrite the key. Odoo uses the
restricted oduflow.bootstrap runner for this step. Do not accept pending
keys merely because a name, IP, grain, or minion event token matches.
Check fingerprints with Salt:
salt-key -F master --hash=sha256
salt-key -f client-683a74a5-9ef6-4512-995a-9d6338c008bc --hash=sha256
The first fingerprint becomes bootstrap's master_finger; the second must match
the operation's expected fingerprint. The format is 32 colon-separated hex pairs.
Salt hashes the PEM body including line endings; hashing DER or the entire PEM
file is not equivalent. Both sides require hash_type: sha256.
There is no autosign or reactor that configures clients in response to an
untrusted minion/start event. Odoo explicitly invokes an allowed runner after
enrollment. The API has no general wheel access. Operator key removal and
rotation use local salt-key; any future API method must verify exact ownership
rather than grant broad key.* access.
External pillar and results¶
salt/master/extmods/pillar/oduflow.py calls
GET /oduflow/pillar/client-<UUID> with a Bearer header. It validates HTTPS,
canonical identity, schema == 1, matching instance_uuid, HTTP 200, and a
response size of at most 1 MiB. It disables redirects and environment proxies,
uses Python/Salt's normal certificate trust store, and has a ten-second timeout.
client.expires_at = null is valid. Raw responses and exceptions are never logged.
Read, access, or schema failures abort pillar retrieval; client states must also
validate required fields before changing the host.
This replaces the initial specification's http_json proposal. Salt 3006.16's
implementation logs fields from erroneous HTTP responses and does not accept an
arbitrary timeout through ext_pillar. The small custom module makes error
handling explicit. See the
official 3006.16 implementation.
pillar_cache, minion_data_cache, and job result caching are disabled. JID
directories and job metadata can still remain; secrets still exist in memory,
the event bus, and on clients. Do not send private secrets in runner arguments,
retain raw highstate returns, or enable debug logs on production clients. Back up
PKI and the Odoo database, with the encryption key stored separately, and restrict
access to all of them as secret material.
Odoo API¶
Only the netapi runner client is enabled. The explicit allowlist covers
oduflow.ping, oduflow.bootstrap, legacy oduflow.apply, and the
start/status pairs for application configuration, production publication, volume
resize, and validated custom states. There is no arbitrary execution, target
pattern, wheel, or caller-supplied pillar override.
For example, /run accepts this JSON structure. Credentials are supplied by the
client from secret storage; the placeholder is not a usable password:
{
"client": "runner",
"fun": "oduflow.apply",
"minion_id": "client-683a74a5-9ef6-4512-995a-9d6338c008bc",
"eauth": "file",
"username": "oduflow-api",
"password": "<secret>"
}
ping returns reachable. The legacy synchronous apply runs only
state.apply roles.client_stack, waits up to 300 seconds, and returns
succeeded, failed, or unknown with state counts. Configuration now installs
and checks the client applications; its success alone does not prove production
publication or an LLM coding session.
Odoo's queue uses durable asynchronous start/status calls instead. Fixed roles
are roles.client_stack, roles.client_production, and
roles.client_storage_resize; custom execution receives a validated, frozen
highstate payload through its separate restricted method. Per-minion workers
serialize execution. Public results contain only identity, request/JID, status,
and counts, not state names, changes, or comments that could disclose secrets.
See asynchronous Salt for persistence and recovery.
A timeout or unknown result proves neither success nor absence of changes.
Reconcile existing receipts and job identity before retrying. General /jobs
access is not provided; ordinary Salt result caching remains disabled. Odoo
stores the sanitized summary, not raw Salt returns.
Prepare identity from Odoo¶
oduflow.bootstrap(minion_id, public_key) accepts only a canonical
client-<UUID> and an RSA3072 public key in PEM SubjectPublicKeyInfo format with a
final newline. Odoo creates and encrypts the private key; the master never
receives it. The runner atomically installs the exact accepted public key,
refuses conflicts, and uses the local Headscale API to issue a one-hour,
one-use tag:client preauth key with neither ephemeral nor reusable enabled.
The runner requires /var/cache/salt/oduflow-bootstrap (salt:salt, 0700)
and /etc/salt/oduflow-headscale-api.key (root:salt, 0640). The Headscale API
key never passes through the Odoo request. Private 0600 cache records contain
the public fingerprint, durable intent, and, after success, the VPN auth key.
This protected credential cache is separate from Salt job return caching.
Intent is persisted and fsynced before the HTTP POST. A repeat returns the cached result only when identity matches and the key has not expired. A lost response, incomplete save, or expired key requires manual reconciliation; there is no automatic second POST. A successful cache record does not authorize restoring an accepted identity that an operator removed. A file lock serializes calls for the same UUID.
The response contains minion_id, master_fingerprint, headscale_url,
headscale_auth_key, and expires_at. It contains a secret: Odoo must encrypt
it and exclude the entire response from logs. The secret also traverses Salt's
trusted event bus, so result caches and event returners must remain disabled.
Bootstrap success means only that Salt/VPN credentials are prepared, not that a
VM, applications, or production stack exists.
The HTTP contract follows the
official Headscale 0.29.3 schema:
POST /api/v1/preauthkey with aclTags, expiration, reusable, and ephemeral,
returning preAuthKey. Plain HTTP is used only on 127.0.0.1:8080; redirects and
environment proxies are disabled, timeout is ten seconds, and response size is
bounded.
Configuration was checked against the official Salt 3006 documentation for master settings, rest_cherrypy, and eauth ACLs. Local tests cover trust boundaries and errors; actual Salt loader, configuration, and file-authentication checks supplement them. Live onedir validation must also check file permissions, TLS, VPN ACLs, and real state execution.