Skip to content

feat(agent): serve fog-agent over mTLS (listener, principal, renew, poll) - #189

Merged
fog-workflows[bot] merged 1 commit into
masterfrom
feat/agent-mtls
Nov 10, 2026
Merged

fog-workflows[bot] merged 1 commit into
masterfrom
feat/agent-mtls

Conversation

@mastacontrola

Copy link
Copy Markdown
Member

What

An enrolled fog-agent now proves itself on every request with its client certificate, renews it, and polls. It does this against fog-node exactly as it does against FOG 1.6, with the same agent binary. Brief: 2026-11-10-agent-mtls. Wire reference: the 1.6 enrollment contract, sections 1.3, 1.5, 1.6, 4.4 and 5.

  • Agent listener (api/hooks/agent-tls): fog-node's own HTTPS listener on custom.agentTlsPort (default 8443; 0 turns it off). It serves the same Express app as port 1337.
    • It requests a client certificate but never requires one.
    • It sends the agent CA and the root as the acceptable CA list, which Go needs before it will send its certificate.
    • A busy port or missing PKI logs one warning, and fog-node keeps running without the listener.
    • At lift it makes or renews only the server certificate. It never creates or rotates an agent CA, which would orphan every enrolled agent. That stays with npm run setup.
  • agentPrincipal policy: verifies the leaf against the agent CA and the root in code, checks the dates of the whole chain and checks clientAuth, as 1.6 openssl_x509_checkpurpose does. A failure gets the 1.6 401 {reason} bodies. A plain-HTTP request is never an agent.
  • POST /agent/v1/renew: the 1.6 statuses and bodies. It writes the 1.6 agent.enroll audit row "certificate renewed to …, key …" (authSource agent).
  • POST /agent/v1/poll: the minimal 1.6 answer. poll_interval is an integer ((int) in 1.6; the Go agent declares int). server_time is ISO 8601, as 1.6 format('c') sends it. It writes no audit row (1.6 has none).
  • Sessions are off for /agent/v1/, so polls create no Redis sessions.
  • The installer asks for the port and the server certificate SANs. docs/install.md has an "Agent port" section.
  • ensurePki moved to api/lib/agent-pki-files.js, so runtime code no longer requires tools/.

Test

  • npm test: 421 passing. The new tests are test/integration/agent-mtls.test.js, test/hooks/agent-tls.test.js (including the lift/lower races and the missing-PKI path) and the server-certificate tests. Each was made to fail with its fix removed.
  • npm run test:ui and npm run test:install: green.
  • openssl s_client: the chain verifies against root.pem, and the acceptable client CA names are "FOG Agent CA" and "FOG Server CA".

The real fog-agent, end to end

Commands and verbatim log excerpts (enroll, poll, renew, interval, re-enroll)

Real fog-agent end to end against feat/agent-mtls

Run on 2026-11-10 against the current uncommitted tree in ~/fog-node-mtls,
after the server_time change and the lift-time ensureServerCert change.
Agent: ~/fog-agent/bin/fog-agent. The database is the worktree's dev database (config/local.js).

Every block below headed <file>.log is copied from that file in
/e2e/. Each line is verbatim. A line left out is
named in the text above its block. The previous run is in prev-run/.

Setup

cd ~/fog-node-mtls
sails_port=1350 sails_custom__agentTlsPort=18443 sails_hooks__grunt=false nohup node app.js > $E/server.log 2>&1 &

E=<lab>/e2e
A=~/fog-agent/bin/fog-agent
ROOT=~/fog-node-mtls/pki/root.pem
D=$E/agent       # the agent --dir, new for this run
H=$E/admin.js    # signs a JWT in-process with the config secret and calls the admin API on 127.0.0.1:1350; prints no secret or token

Before the run, node $H setting get printed null: FOG_CLIENT_CHECKIN_TIME did not exist.
After the lift, the pki folder held only the seven base files, with no timestamped file:
the lift made no agent CA and did not re-issue the server certificate.

(a) enroll: pending, then issued after an admin approve

$A enroll --server https://127.0.0.1:18443 --ca $ROOT --dir $D --once > $E/enroll1.log 2>&1    # exit 0

enroll1.log:

generated a new key for this machine's identity
pending approval on the server (unknown-host); waiting
{
  "status": "pending",
  "reason": "unknown-host",
  "retry_after": 300
}
node $H pending > $E/pending.log 2>&1
node $H approve > $E/approve.log 2>&1

pending.log:

[{"id":"6af353029e0787e4760ca178","hostID":"6af353029e0787e4760ca179","hostname":"<host>","reason":"unknown-host"}]

approve.log:

approve 200 {
  "id": "6af353029e0787e4760ca178",
  "hostID": "6af353029e0787e4760ca179",
  "state": "issued"
}
$A enroll --server https://127.0.0.1:18443 --ca $ROOT --dir $D --once > $E/enroll2.log 2>&1    # exit 0

enroll2.log (the certificate_pem line, a public certificate chain, is left out for length):

enrolled as host 254, certificate valid until 2027-11-10 17:23:14
{
  "status": "issued",
  "host_id": 254,
  "not_after": "2027-11-10 17:23:14"
}

(b) one clean poll; server_time matches +00:00

$A run --dir $D --once > $E/poll1.log 2>&1    # exit 0

poll1.log:

facts: sending inventory=true software=2849 directory=true printers=1 network=6
host 254 (<host>), server capabilities: []
{
  "directory_hold": false,
  "status": "ok",
  "protocol": 1,
  "host": {
    "id": 254,
    "name": "<host>"
  },
  "revision": "",
  "poll_interval": 60,
  "server_time": "2026-11-10T17:23:18+00:00",
  "state": {
    "revision": "",
    "capabilities": []
  }
}

Checks on the logs:

grep -ciE "401|decode|revision moved|no state|error" poll1.log poll-905.log    # poll1.log:0, poll-905.log:0
grep -E '"server_time": "[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\+00:00"' poll1.log poll-905.log    # one match in each
grep -niE "error|warn|404|500" server.log    # no match

(c) renew, and its audit row

$A renew --dir $D > $E/renew.log 2>&1    # exit 0
node $H audit > $E/audit.log 2>&1

renew.log:

certificate renewed, valid until 2027-11-10 17:23:18
{
  "enrolled": true,
  "host_id": 254
}

audit.log (this run's row; the second line of the file, a row from the previous run, is left out):

{"type":"agent.enroll","text":"certificate renewed to 2027-11-10 17:23:18, key cb02ef337caf0205","authSource":"agent","createdBy":"(system)","subjectLabel":"<host>","renderable":true,"outcome":"allowed"}

(e) FOG_CLIENT_CHECKIN_TIME = "90.5" gives poll_interval 90

node $H setting 90.5 > $E/setting-set.log 2>&1
$A run --dir $D --once > $E/poll-905.log 2>&1    # exit 0
node $H setting unset > $E/setting-unset.log 2>&1
node $H setting get > $E/setting-get.log 2>&1

setting-set.log:

set 90.5 inserted

poll-905.log:

host 254 (<host>), server capabilities: []
{
  "directory_hold": false,
  "status": "ok",
  "protocol": 1,
  "host": {
    "id": 254,
    "name": "<host>"
  },
  "revision": "",
  "poll_interval": 90,
  "server_time": "2026-11-10T17:23:28+00:00",
  "state": {
    "revision": "",
    "capabilities": []
  }
}

setting-unset.log:

removed 1

setting-get.log:

null

(d) host deleted: the next poll re-enrolls

node $H delete-host > $E/delete-host.log 2>&1
$A run --dir $D --once > $E/poll-deleted.log 2>&1    # exit 1 (--once stops after the message)

delete-host.log:

delete host <host> 200

poll-deleted.log:

server no longer recognizes this certificate; enrolling again
fog-agent: server does not recognize this agent's certificate

Cleanup

kill $(cat $E/server.pid)    # then: nothing listens on 1350 or 18443
node $E/cleanup.js > $E/cleanup.log 2>&1

cleanup.log:

enrollment rows removed 1 ; hosts named <host> left 0

The Setting is absent again, as it was before the run (setting-get.log). The audit
rows and the agentId counter stay, because the trail is append-only.

A grep for a PEM private-key header gives 0 for every log in this folder and for this file.
The words token and secret appear only in two Sails route names in server.log and in the
description of admin.js above, never with a value.

🤖 Generated with Claude Code

…new, poll)

An enrolled fog-agent now proves itself on every request with its client
certificate, renews it, and polls, as it does against FOG 1.6. The same
agent binary works against both.

- api/hooks/agent-tls: fog-node's own HTTPS listener on
  custom.agentTlsPort (default 8443; 0 turns it off). It serves the same
  Express app, requests a client certificate but never requires one, and
  sends the agent CA and root as the acceptable CA list that Go needs.
  A busy port or missing PKI logs one warning, and fog-node keeps
  running. At lift it makes or renews only the server certificate. It
  never creates or rotates an agent CA (that is setup's job).
- api/policies/agentPrincipal: verifies the leaf against the agent CA and
  the root in code, plus every date in the chain and clientAuth. Failures
  get the 1.6 401 bodies. A plain-HTTP request is never an agent.
- POST /agent/v1/renew writes the 1.6 "certificate renewed to ..."
  audit row (authSource agent). POST /agent/v1/poll is the minimal 1.6
  answer: integer poll_interval, and ISO server_time.
- Sessions are off for /agent/v1/. The installer asks for the port and
  the server certificate SANs. ensurePki moved to api/lib/agent-pki-files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@fog-workflows
fog-workflows Bot enabled auto-merge November 10, 2026 17:34
@fog-workflows
fog-workflows Bot added this pull request to the merge queue Nov 10, 2026
Merged via the queue into master with commit 8d8689f Nov 10, 2026
6 checks passed
@fog-workflows
fog-workflows Bot deleted the feat/agent-mtls branch November 10, 2026 17:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant