Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ request and the merge queue.
| `api/controllers/general/` | The generic CRUD and DataTables endpoints |
| `api/controllers/pages/` | Page controllers for the EJS views |
| `api/helpers/` | Shared helpers: forms, DataTables, image locks and stream, search |
| `api/hooks/` | `fog-version`, `plugins` (loads `plugins.json`), `watchdog` |
| `api/hooks/` | `fog-version`, `plugins` (loads `plugins.json`), `watchdog`, `agent-tls` (the fog-agent mTLS listener) |
| `plugins/<name>/` | Plugins: `ad`, `printer`, `snapin`, `wol` |
| `tools/setup/` | The production installer (`index.js`) and the dev setup (`dev.js`) |
| `views/` | EJS layouts and pages |
17 changes: 2 additions & 15 deletions api/controllers/agent/enroll.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,20 +21,11 @@
* request is the one that is signed. No session: the route is public, as boot.
*/
const net = require('net');
const {leafInfo} = require('../../lib/agent-pki');
const {leafInfo, jsonBody, MAX_CSR, text, version} = require('../../lib/agent-pki');

const RETRY_AFTER = 300;
const MAC = /^(?:[0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i;

// The field limits of the 1.6 columns, so a public caller cannot store more.
const text = (value, max) => (typeof value === 'string' ? value.slice(0, max) : '');

// Update::normalize: trim, and strip one leading v or V.
const version = (value) => {
const v = text(value, 50).trim();
return /^[vV]/.test(v) ? v.slice(1) : v;
};

// The valid MACs in the Host `macs` form (12 lower-case hex digits), no repeats.
const macsOf = (list) => {
const out = [];
Expand All @@ -45,10 +36,6 @@ const macsOf = (list) => {
return out;
};

// A CSR is about 500 characters (ECDSA P-256) and an RSA-4096 CSR about 1800.
// 16384 leaves room for any real key, and stops a caller from making the
// public route parse and store a huge value.
const MAX_CSR = 16384;
const MAX_MACS = 32;
// The caps for the stored identity fields and the enroll token. No real
// firmware string or minted token (48 hex) comes near them.
Expand Down Expand Up @@ -86,7 +73,7 @@ module.exports = {
const req = this.req;
const res = this.res;
const Enrollment = sails.models.agentenrollment;
const body = (req.body && typeof req.body === 'object' && !Array.isArray(req.body)) ? req.body : {};
const body = jsonBody(req);

const pending = (row) => res.status(202).json({status: 'pending', reason: row.reason, retry_after: RETRY_AFTER});
const issued = (result) => res.status(200).json({
Expand Down
54 changes: 54 additions & 0 deletions api/controllers/agent/poll.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* POST /agent/v1/poll (minimal; contract 1.6)
*
* The heartbeat of an enrolled fog-agent. The policy agentPrincipal proved the
* certificate and set req.agentHost. It writes agentCheckin (now) and, when the
* body has `agent_version`, agentVersion (normalized as at enroll). It answers
* 200 with the 1.6 shape. The `state` object is required: without it a fresh
* agent logs "poll: the revision moved but the server sent no state". With an
* empty capability list it reconciles nothing and sends no /result.
*
* It returns no tasks, payloads or facts requests; those are later briefs.
* This route writes no audit row.
*/
const {jsonBody, version} = require('../../lib/agent-pki');

// The 1.6 default of FOG_CLIENT_CHECKIN_TIME, in seconds.
const DEFAULT_POLL_INTERVAL = 60;

module.exports = {
friendlyName: 'Agent poll',
description: 'Record the check-in of a fog-agent and answer with the minimal poll state.',
exits: {
success: {description: 'The answer is in the response.'}
},
fn: async function () {
const req = this.req;
const res = this.res;
const host = req.agentHost;
const body = jsonBody(req);

const set = {agentCheckin: Date.now()};
const agentVersion = version(body.agent_version);
if (agentVersion) { set.agentVersion = agentVersion; }
await sails.models.host.updateOne({id: host.id}).set(set);

const setting = await sails.models.setting.findOne({name: 'FOG_CLIENT_CHECKIN_TIME'});
// An integer, because the Go agent's PollInterval is an int and fails to
// decode 90.5. 1.6 casts with (int) and passes 0 or a negative number on:
// the agent then keeps its own default. A missing or non-numeric value
// gives 60.
const interval = parseInt(setting && setting.value, 10);

return res.status(200).json({
status: 'ok',
protocol: 1,
host: {id: host.agentId, name: host.name},
revision: '',
state: {revision: '', capabilities: []},
poll_interval: Number.isNaN(interval) ? DEFAULT_POLL_INTERVAL : interval,
// ISO 8601 with an offset, as 1.6 sends it (Route.php:3207, format('c')).
server_time: new Date().toISOString().replace(/\.\d{3}Z$/, '+00:00')
});
}
};
71 changes: 71 additions & 0 deletions api/controllers/agent/renew.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
/**
* POST /agent/v1/renew
*
* An enrolled fog-agent sends a CSR for its own key and gets a new certificate
* (contract 1.3). The policy agentPrincipal proved the old certificate and set
* req.agentHost. The same agent binary works against FOG 1.6, so the statuses,
* the JSON keys and the strings are 1.6's:
*
* 200 {status: issued, host_id, certificate_pem, not_after}
* 400 {status: error, error} the CSR is bad, or it is for another key
* 503 {status: error, error} agent certificate issuance is not configured
*
* The fingerprint does not change and the old certificate stays valid: the
* binding is the key. It writes the 1.6 audit row (Enrollment.php:581-595):
* agent.enroll, authSource agent, "certificate renewed to <notAfter>, key <16>".
* The actor is (system), as in 1.6: an agent request has no signed-in user.
*/
const {MAX_CSR, jsonBody, leafInfo} = require('../../lib/agent-pki');

module.exports = {
friendlyName: 'Agent renew',
description: 'Sign a new certificate for the key of an enrolled fog-agent.',
exits: {
success: {description: 'The answer is in the response.'}
},
fn: async function () {
const req = this.req;
const res = this.res;
const host = req.agentHost;
const body = jsonBody(req);
const fail = (status, error) => res.status(status).json({status: 'error', error});

const csr = (typeof body.csr_pem === 'string' && body.csr_pem.length <= MAX_CSR) ? body.csr_pem : '';
let fingerprint;
try {
await sails.helpers.agent.verifyCsr(csr);
fingerprint = sails.helpers.agent.keyFingerprint(csr);
} catch (unused) {
return fail(400, 'csr_pem is not a certificate request');
}
if (fingerprint !== host.agentFingerprint) {
return fail(400, 'the request is not for the key this certificate proved');
}

let signed;
try {
signed = await sails.helpers.agent.signCsr.with({csrPem: csr, hostId: String(host.agentId)});
} catch (err) {
sails.log.warn(`agent renew: signing for host ${host.id} failed: ${err.exit || err.message}`);
return fail(503, 'agent certificate issuance is not configured');
}
const {notAfter} = leafInfo(signed.leafPem);
const updated = await sails.models.host.updateOne({id: host.id}).set({agentNotAfter: notAfter, agentCheckin: Date.now()});
await sails.helpers.audit.record.with({req, row: {
type: 'agent.enroll',
subjectType: 'host',
subjectId: host.id,
subjectLabel: host.name,
renderable: true,
outcome: 'allowed',
text: `certificate renewed to ${updated.agentNotAfter}, key ${fingerprint.slice(0, 16)}`,
authSource: 'agent'
}});
return res.status(200).json({
status: 'issued',
host_id: host.agentId,
certificate_pem: signed.leafPem + signed.chainPem,
not_after: updated.agentNotAfter
});
}
};
123 changes: 123 additions & 0 deletions api/hooks/agent-tls/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/**
* agent-tls hook: the HTTPS listener of the fog-agent (mTLS).
*
* After Sails lifts, it starts an HTTPS server on custom.agentTlsPort (default
* 8443; 0 turns it off). The server runs the same Express app as the main
* port, so every route answers on both ports and an agent uses one base URL,
* https://<host>:<port>, for ca.cert.pem, enroll, poll and renew. It closes the
* server when Sails lowers.
*
* TLS: the server certificate is pki/server.pem (made by api/lib/agent-pki-files.js,
* signed by the root). The server REQUESTS a client certificate and never
* requires one (rejectUnauthorized false), so a browser is never asked for one.
* `ca` is the agent CA, then the root: Node sends their subjects as the
* CertificateRequest list, and Go's client sends its certificate only if one of
* them issued it (contract 5.1). The policy agentPrincipal re-verifies the
* certificate in code (contract 5.4); the TLS verdict is not trusted.
*
* At lift, before it listens, the hook calls ensureServerCert with
* custom.agentTlsSans. It makes server.pem and server.key from the existing
* root when they are missing, and makes them again when the SAN set changed,
* fewer than 30 days remain, or the root no longer verifies them. It never
* makes a root or an agent CA: a new agent CA orphans every enrolled agent,
* so only setup (npm run setup, npm run setup:dev) makes one. When root.pem or
* agent-ca.pem is missing, or the root does not verify the agent CA, the hook
* logs one warning that names npm run setup and starts no listener. When the
* server certificate cannot be made (root.key missing), it logs one warning.
* It then starts the listener with the existing server.pem and server.key, or
* starts none when either is missing.
*/
const fs = require('fs');
const https = require('https');
const path = require('path');
// The module object, not a destructured function: a test replaces
// ensureServerCert on it to hold the lift at the await.
const pkiFiles = require('../../lib/agent-pki-files');

/**
* Start the agent listener on `app`. Returns the server, or null when `port`
* is 0 or the PKI files are missing (one warning in that case). A port that is
* in use logs one warning and fog-node keeps running without the listener; the
* server then emits 'error' once, before 'listening'.
*/
const startAgentListener = (app, {port, pkiDir}) => {
if (!port) { return null; }
const notStarted = (err) => sails.log.warn(`Agent listener not started: cannot listen on port ${port} (${err.code || err.message}). fog-node runs without it.`);
let server;
try {
const read = (name) => fs.readFileSync(path.join(pkiDir, name));
server = https.createServer({
key: read('server.key'),
cert: read('server.pem'),
ca: [read('agent-ca.pem'), read('root.pem')],
requestCert: true,
rejectUnauthorized: false,
minVersion: 'TLSv1.2'
}, app);
} catch (err) {
sails.log.warn(`Agent listener not started: the PKI files in ${pkiDir} are missing or unreadable (${err.code || err.message}).`);
return null;
}
// A listen failure (EADDRINUSE) arrives as an 'error' after listen() returns
// and before 'listening'. Once it listens, an error is only logged.
server.once('error', notStarted);
server.once('listening', () => {
server.removeListener('error', notStarted);
server.on('error', (err) => sails.log.warn(`Agent listener error: ${err.code || err.message}`));
});
try {
server.listen(port);
} catch (err) {
notStarted(err);
return null;
}
return server;
};

module.exports = function defineAgentTlsHook(sails) {
return {
initialize: function () {
const hook = this;
hook.server = null;
// 'lower' can fire while 'lifted' awaits ensureServerCert. Without this
// flag the listener would start after the lower and keep the port.
let lowered = false;
sails.on('lifted', async () => {
const {agentTlsPort: port, agentTlsSans: sans, pkiDir} = sails.config.custom;
if (!port) { return; }
try {
await pkiFiles.ensureServerCert(pkiDir, {sans});
} catch (err) {
if (err.code === 'E_AGENT_PKI_MISSING') {
sails.log.warn(`Agent listener not started: ${err.message} Run npm run setup to make the agent CAs.`);
return;
}
// Without a server pair there is nothing to listen with: stop here, so
// startAgentListener does not log a second warning.
const pair = ['server.pem', 'server.key'].every((name) => fs.existsSync(path.join(pkiDir, name)));
sails.log.warn(`Agent listener${pair ? '' : ' not started'}: the server certificate could not be checked or made: ${err.message}`);
if (!pair) { return; }
}
if (lowered) { return; }
const server = startAgentListener(sails.hooks.http.app, {port, pkiDir});
hook.server = server;
// A failed listen leaves no listener: do not keep the server.
if (server) {
const clear = () => { if (hook.server === server) { hook.server = null; } };
server.once('error', clear);
server.once('listening', () => server.removeListener('error', clear));
}
});
sails.on('lower', () => {
lowered = true;
if (!hook.server) { return; }
hook.server.close();
// Keep-alive connections would hold the server open.
hook.server.closeAllConnections();
hook.server = null;
});
}
};
};

module.exports.startAgentListener = startAgentListener;
Loading
Loading