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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
19 changes: 14 additions & 5 deletions docs/pki.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|-------------|---------|
Expand All @@ -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) |

Expand All @@ -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.

Expand Down
4 changes: 0 additions & 4 deletions src/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
45 changes: 29 additions & 16 deletions src/services/certificate-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)
}
1 change: 1 addition & 0 deletions src/services/router-connection-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 0 additions & 1 deletion src/templates/nats/server-no-cluster.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Expand Down
15 changes: 15 additions & 0 deletions test/src/init.test.js
Original file line number Diff line number Diff line change
@@ -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/)
})
})
12 changes: 12 additions & 0 deletions test/src/services/iofog-greenfield-pki.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
37 changes: 37 additions & 0 deletions test/src/services/router-connection-service.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'),
Expand Down