From 39b46f9cb4dbb58ac43b7103e12dea828585ff41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emirhan=20Durmu=C5=9F?= Date: Tue, 23 Jun 2026 22:02:47 +0300 Subject: [PATCH] Defer central local CA creation until first agent provision and Remove duplicate handshake_first from NATS leaf TLS config Stop creating default-router-local-ca and default-nats-local-ca at Controller boot so operators can import custom local CAs before deploying agents. Ensure the router local CA from the AMQP exec path, update PKI docs, and add regression tests. --- CHANGELOG.md | 1 + docs/pki.md | 19 +++++--- src/init.js | 4 -- src/services/certificate-service.js | 45 ++++++++++++------- src/services/router-connection-service.js | 1 + src/templates/nats/server-no-cluster.conf | 1 - test/src/init.test.js | 15 +++++++ .../src/services/iofog-greenfield-pki.test.js | 12 +++++ .../router-connection-service.test.js | 37 +++++++++++++++ 9 files changed, 109 insertions(+), 26 deletions(-) create mode 100644 test/src/init.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index e67bd39a..e5b7e02c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ Controller v3.8 is a **greenfield** release aligned with **Edgelet**. There is * - Controller register accepts optional **`schedule: 0`**; server always enforces schedule **0** on create, re-register, and **`PATCH /api/v3/microservices/system/:uuid`** for controller workloads. - Agent version command (**`GET /api/v3/agent/version`**) refreshes the provision key on each pull instead of returning a stale or deleted key. - Controller AMQP certificate provisioning uses shared **`default-router-local-ca`** instead of per-fog router local CA secret names. +- Central local CAs (`default-router-local-ca`, `default-nats-local-ca`) are ensured on first agent provision (or via operator direct import before first agent), not at Controller boot — allows custom local CAs before agent deployment. - Fog teardown drops obsolete per-fog **`nats-local-ca-*`** and **`router-local-ca-*`** secret names from cleanup lists. - OIDC discovery with **`AUTH_INSECURE_ALLOW_HTTP`** uses the supported `openid-client` insecure-request hook for local **`http://`** issuers. - Config keys **`auth.bootstrap.adminUsername`** / **`adminPassword`** renamed to **`auth.bootstrap.username`** / **`password`** (**`OIDC_BOOTSTRAP_ADMIN_*`** env vars unchanged). diff --git a/docs/pki.md b/docs/pki.md index 34983c1c..d554513d 100644 --- a/docs/pki.md +++ b/docs/pki.md @@ -9,14 +9,14 @@ Controller issues and stores TLS material for router messaging, NATS MQTT, and i ## Central certificate authorities -On startup, Controller ensures two fleet-wide local CAs (stored as TLS secrets): +Fleet-wide local CAs (stored as TLS secrets): | Secret name | Signs | |-------------|--------| | `default-router-local-ca` | Router **messaging** certs (`router-local-server-*`, `router-local-agent-*`) | | `default-nats-local-ca` | NATS **MQTT local** certs (`nats-mqtt-*`) | -Site CAs (unchanged from earlier releases): +Site CAs: | Secret name | Purpose | |-------------|---------| @@ -37,7 +37,7 @@ Plan 5 originally scoped a **one-time PKI rotation job** to re-sign certs from l | Scenario | Operator action | |----------|-----------------| -| New v3.8 fleet | Install Controller v3.8; central CAs are created at boot; provision Edgelet agents normally | +| New v3.8 fleet | Install Controller v3.8; provision Edgelet agents normally (CAs are ensured on first agent, or import custom CAs first — see below) | | Old v3.7 lab with per-agent CAs | **Wipe and reinstall** (new DB + new secrets) per greenfield policy — do not attempt in-place PKI migration | | Agent host / IP change | Controller recreates affected router/NATS certs and sets the **`volumeMounts`** change flag so Edgelet reloads mounted secrets (automatic) | @@ -47,13 +47,22 @@ Plan 5 originally scoped a **one-time PKI rotation job** to re-sign certs from l ### Boot -After database init, Controller calls `ensureCentralLocalCAs()` — creating `default-router-local-ca` and `default-nats-local-ca` if missing (60-month CA validity for central CAs). +Controller does **not** create fleet CAs at boot. + +### Operator import (optional, before first agent) + +Import custom CAs using the canonical secret names: + +1. `POST /api/v3/secrets` — `type: "tls"`, data keys **`tls.crt`** and **`tls.key`** (base64-encoded PEM; optional **`ca.crt`** for CA secrets). +2. `POST /api/v3/certificates/ca` — `type: "direct"`, `secretName` matching the secret. + +Supported names: `router-site-ca`, `nats-site-ca`, `default-router-local-ca`, `default-nats-local-ca`. ### Agent provision and host changes When an agent is provisioned or its **host** changes, Controller: -1. Ensures the central local CA exists. +1. Ensures missing fleet CAs (self-signed, 60-month validity) or uses operator-imported CAs when present. 2. Creates or recreates router local-server / local-agent and NATS MQTT certs with current DNS SANs (including bridge SANs such as `router.default.svc.bridge.local`). 3. Sets **`volumeMounts`** (and related) change tracking so Edgelet picks up new secret mounts. diff --git a/src/init.js b/src/init.js index 22b398ee..280cbd84 100644 --- a/src/init.js +++ b/src/init.js @@ -27,10 +27,6 @@ async function initialize () { logger.info('Initializing database...') await db.initDB(true) - const CertificateService = require('./services/certificate-service') - logger.info('Ensuring central router and NATS local CAs...') - await CertificateService.ensureCentralLocalCAs({ fakeTransaction: true }) - logger.info('Initialization completed successfully') return true } catch (error) { diff --git a/src/services/certificate-service.js b/src/services/certificate-service.js index 8e117fb9..ea4983f6 100644 --- a/src/services/certificate-service.js +++ b/src/services/certificate-service.js @@ -154,25 +154,36 @@ async function createCAEndpoint (caData, transaction) { } } -async function ensureCentralLocalCAs (transaction) { - for (const name of [Constants.DEFAULT_ROUTER_LOCAL_CA, Constants.DEFAULT_NATS_LOCAL_CA]) { - try { - await getCAEndpoint(name, transaction) - } catch (err) { - if (err.name === 'NotFoundError') { - await createCAEndpoint({ - name, - subject: name, - expiration: 60, - type: 'self-signed' - }, transaction) - } else if (err.name !== 'ConflictError') { - throw err - } +async function ensureLocalCA (name, transaction) { + try { + await getCAEndpoint(name, transaction) + } catch (err) { + if (err.name === 'NotFoundError') { + await createCAEndpoint({ + name, + subject: name, + expiration: 60, + type: 'self-signed' + }, transaction) + } else if (err.name !== 'ConflictError') { + throw err } } } +async function ensureRouterLocalCA (transaction) { + await ensureLocalCA(Constants.DEFAULT_ROUTER_LOCAL_CA, transaction) +} + +async function ensureNatsLocalCA (transaction) { + await ensureLocalCA(Constants.DEFAULT_NATS_LOCAL_CA, transaction) +} + +async function ensureCentralLocalCAs (transaction) { + await ensureRouterLocalCA(transaction) + await ensureNatsLocalCA(transaction) +} + async function getCAEndpoint (name, transaction) { const certRecord = await CertificateManager.findCertificateByName(name, transaction) @@ -667,5 +678,7 @@ module.exports = { deleteCertificateEndpoint: TransactionDecorator.generateTransaction(deleteCertificateEndpoint), renewCertificateEndpoint: TransactionDecorator.generateTransaction(renewCertificateEndpoint), listExpiringCertificatesEndpoint: TransactionDecorator.generateTransaction(listExpiringCertificatesEndpoint), - ensureCentralLocalCAs: TransactionDecorator.generateTransaction(ensureCentralLocalCAs) + ensureCentralLocalCAs: TransactionDecorator.generateTransaction(ensureCentralLocalCAs), + ensureRouterLocalCA: TransactionDecorator.generateTransaction(ensureRouterLocalCA), + ensureNatsLocalCA: TransactionDecorator.generateTransaction(ensureNatsLocalCA) } diff --git a/src/services/router-connection-service.js b/src/services/router-connection-service.js index 9a5e2070..29d1fe8f 100644 --- a/src/services/router-connection-service.js +++ b/src/services/router-connection-service.js @@ -287,6 +287,7 @@ class RouterConnectionService { async _createControllerCertificate () { logger.debug('[AMQP] Ensuring controller certificate secret exists', { name: CONTROLLER_CERT_NAME }) + await CertificateService.ensureRouterLocalCA(this.fakeTransaction) const existingSecret = await this._safeGetSecret(CONTROLLER_CERT_NAME) const caName = Constants.DEFAULT_ROUTER_LOCAL_CA if (existingSecret) { diff --git a/src/templates/nats/server-no-cluster.conf b/src/templates/nats/server-no-cluster.conf index 428474fd..f45b2c8a 100644 --- a/src/templates/nats/server-no-cluster.conf +++ b/src/templates/nats/server-no-cluster.conf @@ -28,7 +28,6 @@ leafnodes: { key_file: "$NATS_TLS_DIR/$NATS_CERT_NAME/tls.key" handshake_first: true verify: true - handshake_first: true timeout: "3s" } } diff --git a/test/src/init.test.js b/test/src/init.test.js new file mode 100644 index 00000000..7a2bd0ea --- /dev/null +++ b/test/src/init.test.js @@ -0,0 +1,15 @@ +const { expect } = require('chai') +const fs = require('fs') +const path = require('path') + +describe('init', () => { + it('does not ensure central local CAs at boot', () => { + const source = fs.readFileSync( + path.join(__dirname, '../../src/init.js'), + 'utf8' + ) + + expect(source).to.not.include('ensureCentralLocalCAs') + expect(source).to.not.match(/Ensuring central router and NATS local CAs/) + }) +}) diff --git a/test/src/services/iofog-greenfield-pki.test.js b/test/src/services/iofog-greenfield-pki.test.js index da81f900..f4fab860 100644 --- a/test/src/services/iofog-greenfield-pki.test.js +++ b/test/src/services/iofog-greenfield-pki.test.js @@ -87,5 +87,17 @@ describe('iofog greenfield PKI (central local CAs)', () => { ]) expect(certNames).to.not.include(`router-local-ca-${fogData.name}`) }) + + it('does not recreate default-router-local-ca when operator imported it', async () => { + CertificateService.getCAEndpoint + .withArgs(Constants.DEFAULT_ROUTER_LOCAL_CA, $transaction) + .resolves({ name: Constants.DEFAULT_ROUTER_LOCAL_CA, isCA: true }) + + await $subject + + const createCaCalls = CertificateService.createCAEndpoint.getCalls() + const caNames = createCaCalls.map((call) => call.args[0].name) + expect(caNames).to.not.include(Constants.DEFAULT_ROUTER_LOCAL_CA) + }) }) }) diff --git a/test/src/services/router-connection-service.test.js b/test/src/services/router-connection-service.test.js index 1208ceaa..24b64fc3 100644 --- a/test/src/services/router-connection-service.test.js +++ b/test/src/services/router-connection-service.test.js @@ -4,6 +4,8 @@ const sinon = require('sinon') const Constants = require('../../../src/helpers/constants') const config = require('../../../src/config') const RouterManager = require('../../../src/data/managers/router-manager') +const CertificateService = require('../../../src/services/certificate-service') +const SecretService = require('../../../src/services/secret-service') const RouterConnectionService = require('../../../src/services/router-connection-service') describe('Router Connection Service', () => { @@ -146,6 +148,41 @@ describe('Router Connection Service', () => { }) }) + describe('_createControllerCertificate()', () => { + const certSecret = { + data: { + 'tls.crt': Buffer.from('cert-pem').toString('base64'), + 'tls.key': Buffer.from('key-pem').toString('base64'), + 'ca.crt': Buffer.from('ca-pem').toString('base64') + } + } + const caSecret = { + data: { + 'tls.crt': Buffer.from('ca-pem').toString('base64') + } + } + + beforeEach(() => { + $sandbox.stub(CertificateService, 'ensureRouterLocalCA').resolves() + $sandbox.stub(SecretService, 'getSecretEndpoint') + .onFirstCall().resolves(null) + .onSecondCall().resolves(certSecret) + .onThirdCall().resolves(caSecret) + $sandbox.stub(CertificateService, 'createCertificateEndpoint').resolves() + }) + + it('ensures default-router-local-ca before creating the exec client certificate', async () => { + await RouterConnectionService._createControllerCertificate() + + expect(CertificateService.ensureRouterLocalCA).to.have.been.calledOnce + expect(CertificateService.createCertificateEndpoint).to.have.been.calledOnce + expect(CertificateService.createCertificateEndpoint.firstCall.args[0].ca).to.deep.equal({ + type: 'direct', + secretName: Constants.DEFAULT_ROUTER_LOCAL_CA + }) + }) + }) + describe('_createConnection() — Remote connect fallback', () => { const certBundle = { cert: Buffer.from('cert'),