@@ -226,20 +372,8 @@ const TLS = () => {
{formatDate(cert.renewaltime)}
-
{cert.generation}
-
-
- handleRevokeAndRotate(cert)}
- />
- handleRevoke(cert)}
- />
-
-
+ {renderGenerationCell(cert, superseded)}
+
{renderCertActions(cert)}
);
}
@@ -274,6 +408,16 @@ const TLS = () => {
/>
)}
+ {actionNotice && (
+
setActionNotice(null)}
+ style={{ marginBottom: "1rem" }}
+ />
+ )}
+
{!loading && !error && certificates.length === 0 && (
{
- {certificates.map((cert) => (
+ {sortedRootCerts.map((cert) => (
))}
diff --git a/components/management-controller/src/backbone-links.js b/components/management-controller/src/backbone-links.js
index 4e1eca1d..d73fa9c3 100644
--- a/components/management-controller/src/backbone-links.js
+++ b/components/management-controller/src/backbone-links.js
@@ -26,26 +26,72 @@
import { LoadSecret } from "@vms/modules/kube";
import { Log } from "@vms/modules/log";
import { ClientFromPool } from "./db.js";
-import { OpenConnection, CloseConnection } from "@vms/modules/amqp";
+import { OpenConnection, CloseConnection, OnConnectionClosed } from "@vms/modules/amqp";
import { NotifyTransaction, RegisterNotification } from "./notify.js";
+import { overlayDualTrustCa } from "./tls-rotation.js";
+
+const UNEXPECTED_RECONNECT_DELAY_MS = 1000;
let controller_name;
+let controller_certificate_id;
let tls_ca;
let tls_cert;
let tls_key;
const manageConnections = {};
const registrations = [];
+function connectionNeedsRefresh(existing, row) {
+ return existing.host !== row.hostname || String(existing.port) !== String(row.port);
+}
+
+function applyTlsSecretData(data) {
+ let count = 0;
+ if (data?.["ca.crt"]) {
+ tls_ca = Buffer.from(data["ca.crt"], "base64");
+ count += 1;
+ }
+ if (data?.["tls.crt"]) {
+ tls_cert = Buffer.from(data["tls.crt"], "base64");
+ count += 1;
+ }
+ if (data?.["tls.key"]) {
+ tls_key = Buffer.from(data["tls.key"], "base64");
+ count += 1;
+ }
+ if (count != 3) {
+ throw new Error(`Unexpected set of values from TLS secret data - expected 3, got ${count}`);
+ }
+}
+
+async function loadManageClientTls(client) {
+ const tls_result = await client.query("SELECT ObjectName FROM TlsCertificates WHERE Id = $1", [
+ controller_certificate_id,
+ ]);
+ if (tls_result.rowCount != 1) {
+ throw new Error(
+ `Expected to find a TlsCertificate record for ready controller: ${controller_certificate_id}`
+ );
+ }
+ const secret = await LoadSecret(tls_result.rows[0].objectname);
+ if (!secret?.data) {
+ throw new Error(`Missing TLS secret ${tls_result.rows[0].objectname}`);
+ }
+ const data = await overlayDualTrustCa(client, controller_certificate_id, secret.data);
+ applyTlsSecretData(data);
+}
+
async function createConnection(apid, row) {
- manageConnections[apid] = {
+ const rec = {
toDelete: false,
+ closing: false,
host: row.hostname,
port: row.port,
colocated: row.colocated,
};
+ manageConnections[apid] = rec;
Log(`Connecting to Access Point: ${row.hostname}:${row.port}`);
- manageConnections[apid].conn = OpenConnection(
+ rec.conn = OpenConnection(
`Backbone-management-${apid}`,
row.hostname,
row.port,
@@ -54,17 +100,25 @@ async function createConnection(apid, row) {
tls_cert,
tls_key
);
+ OnConnectionClosed(rec.conn, () => {
+ void onManageConnectionClosed(apid, rec.conn);
+ });
for (const reg of registrations) {
- await reg.onLinkAdded(apid, manageConnections[apid].conn, {
- colocated: manageConnections[apid].colocated,
+ await reg.onLinkAdded(apid, rec.conn, {
+ colocated: rec.colocated,
});
}
}
async function deleteConnection(apid) {
- const conn = manageConnections[apid].conn;
- const colocated = manageConnections[apid].colocated;
+ const rec = manageConnections[apid];
+ if (!rec) {
+ return;
+ }
+ rec.closing = true;
+ const conn = rec.conn;
+ const colocated = rec.colocated;
CloseConnection(conn);
delete manageConnections[apid];
@@ -73,6 +127,18 @@ async function deleteConnection(apid) {
}
}
+async function onManageConnectionClosed(apid, conn) {
+ const rec = manageConnections[apid];
+ if (!rec || rec.closing || rec.conn !== conn) {
+ return;
+ }
+ Log(`Manage AMQP connection to access point ${apid} closed, reconnecting`);
+ await deleteConnection(apid);
+ setTimeout(() => {
+ void reconcileBackboneConnections();
+ }, UNEXPECTED_RECONNECT_DELAY_MS);
+}
+
async function periodicCheck() {
const normal_period = 30000;
const startup_period = 2000;
@@ -95,8 +161,17 @@ async function reconcileBackboneConnections() {
}
for (const row of result.rows) {
- if (manageConnections[row.id]) {
- manageConnections[row.id].toDelete = false;
+ const existing = manageConnections[row.id];
+ if (existing && connectionNeedsRefresh(existing, row)) {
+ Log(`Manage access point ${row.id} endpoint changed, reconnecting AMQP`);
+ await deleteConnection(row.id);
+ try {
+ await createConnection(row.id, row);
+ } catch (error) {
+ Log(`Failed to reconnect to manage access point ${row.id}: ${error.message}`);
+ }
+ } else if (existing) {
+ existing.toDelete = false;
} else {
// Fire and forget individual connection promises to prevent a single
// failure from blocking subsequent access points.
@@ -119,8 +194,8 @@ async function reconcileBackboneConnections() {
}
}
-async function resolveTLSData() {
- let reschedule_delay = 1000;
+async function resolveTLSData(renewal = false) {
+ let reschedule_delay = renewal ? -1 : 1000;
const client = await ClientFromPool("system");
try {
await client.query("BEGIN");
@@ -129,48 +204,25 @@ async function resolveTLSData() {
[controller_name]
);
if (result.rowCount == 1) {
- const tls_result = await client.query(
- "SELECT ObjectName FROM TlsCertificates WHERE Id = $1",
- [result.rows[0].certificate]
- );
- if (tls_result.rowCount == 1) {
- const secret = await LoadSecret(tls_result.rows[0].objectname);
- let count = 0;
- for (const [key, value] of Object.entries(secret.data)) {
- if (key == "ca.crt") {
- tls_ca = Buffer.from(value, "base64");
- count += 1;
- } else if (key == "tls.crt") {
- tls_cert = Buffer.from(value, "base64");
- count += 1;
- } else if (key == "tls.key") {
- tls_key = Buffer.from(value, "base64");
- count += 1;
- }
- }
-
- if (count != 3) {
- throw new Error(
- `Unexpected set of values from TLS secret data - expected 3, got ${count}`
- );
- }
-
+ controller_certificate_id = result.rows[0].certificate;
+ await loadManageClientTls(client);
+ if (renewal) {
+ await reconcileBackboneConnections();
+ } else {
reschedule_delay = -1;
setTimeout(reconcileBackboneConnections, 0);
- } else {
- throw new Error(
- `Expected to find a TlsCertificate record for ready controller: ${result.rows[0].certificate}`
- );
}
}
await client.query("COMMIT");
} catch (err) {
Log(`Rolling back resolveTLSData transaction: ${err.stack}`);
await client.query("ROLLBACK");
- reschedule_delay = 10000;
+ if (!renewal) {
+ reschedule_delay = 10000;
+ }
} finally {
client.release();
- if (reschedule_delay >= 0) {
+ if (!renewal && reschedule_delay >= 0) {
setTimeout(resolveTLSData, reschedule_delay);
}
}
@@ -216,6 +268,58 @@ async function onAccessPointChange(action, id) {
}
}
+async function onTlsCertificateChange(action, id) {
+ if (action != "ADD" || !tls_cert) {
+ return;
+ }
+ const client = await ClientFromPool("system");
+ let currentId;
+ try {
+ const result = await client.query(
+ "SELECT Certificate FROM ManagementControllers WHERE Name = $1",
+ [controller_name]
+ );
+ currentId = result.rows[0]?.certificate;
+ } finally {
+ client.release();
+ }
+ if (id != currentId) {
+ return;
+ }
+ Log(`Management controller TLS certificate renewed (${id}), reloading AMQP connections`);
+ for (const apid of Object.keys(manageConnections)) {
+ await deleteConnection(apid);
+ }
+ controller_certificate_id = id;
+ await resolveTLSData(true);
+}
+
+async function onManagementControllerChange(action, _id) {
+ if (action != "UPDATE" || !tls_cert) {
+ return;
+ }
+ const client = await ClientFromPool("system");
+ let currentId;
+ try {
+ const result = await client.query(
+ "SELECT Certificate FROM ManagementControllers WHERE Name = $1",
+ [controller_name]
+ );
+ currentId = result.rows[0]?.certificate;
+ } finally {
+ client.release();
+ }
+ if (!currentId || currentId == controller_certificate_id) {
+ return;
+ }
+ Log(`Management controller certificate changed, reloading AMQP connections`);
+ for (const apid of Object.keys(manageConnections)) {
+ await deleteConnection(apid);
+ }
+ controller_certificate_id = currentId;
+ await resolveTLSData(true);
+}
+
export async function RegisterHandler(onAdded, onDeleted) {
for (const [key, value] of Object.entries(manageConnections)) {
await onAdded(key, value.conn);
@@ -232,5 +336,7 @@ export async function Start(name) {
controller_name = name;
await resolveControllerRecord();
RegisterNotification("BackboneAccessPoints", onAccessPointChange, false);
+ RegisterNotification("TlsCertificates", onTlsCertificateChange, false);
+ RegisterNotification("ManagementControllers", onManagementControllerChange, false);
setTimeout(periodicCheck, 5000);
}
diff --git a/components/management-controller/src/backbone-links.test.js b/components/management-controller/src/backbone-links.test.js
index fc155bdb..2792683c 100644
--- a/components/management-controller/src/backbone-links.test.js
+++ b/components/management-controller/src/backbone-links.test.js
@@ -24,6 +24,9 @@ const mockClient = {
release: vi.fn(),
};
+/** @type {Record} */
+const notificationHandlers = {};
+
vi.mock("@vms/modules/kube", () => ({
LoadSecret: vi.fn(),
}));
@@ -31,6 +34,9 @@ vi.mock("@vms/modules/kube", () => ({
vi.mock("@vms/modules/amqp", () => ({
OpenConnection: vi.fn(() => ({ id: "mock-conn" })),
CloseConnection: vi.fn(),
+ OnConnectionClosed: vi.fn((conn, handler) => {
+ conn._onClosed = handler;
+ }),
}));
vi.mock("./db.js", () => ({
@@ -42,11 +48,13 @@ vi.mock("./notify.js", () => ({
add() {}
async commit() {}
},
- RegisterNotification: vi.fn(),
+ RegisterNotification: vi.fn((tableName, handler) => {
+ notificationHandlers[tableName] = handler;
+ }),
}));
import { LoadSecret } from "@vms/modules/kube";
-import { OpenConnection } from "@vms/modules/amqp";
+import { OpenConnection, CloseConnection, OnConnectionClosed } from "@vms/modules/amqp";
import { RegisterNotification } from "./notify.js";
describe("RegisterHandler", () => {
@@ -57,6 +65,9 @@ describe("RegisterHandler", () => {
vi.useFakeTimers();
vi.clearAllMocks();
mockClient.query.mockReset();
+ for (const key of Object.keys(notificationHandlers)) {
+ delete notificationHandlers[key];
+ }
vi.resetModules();
({ Start, RegisterHandler } = await import("./backbone-links.js"));
});
@@ -129,6 +140,9 @@ describe("resolveControllerRecord (via Start)", () => {
vi.useFakeTimers();
vi.clearAllMocks();
mockClient.query.mockReset();
+ for (const key of Object.keys(notificationHandlers)) {
+ delete notificationHandlers[key];
+ }
vi.resetModules();
({ Start } = await import("./backbone-links.js"));
});
@@ -163,6 +177,16 @@ describe("resolveControllerRecord (via Start)", () => {
expect.any(Function),
false
);
+ expect(RegisterNotification).toHaveBeenCalledWith(
+ "TlsCertificates",
+ expect.any(Function),
+ false
+ );
+ expect(RegisterNotification).toHaveBeenCalledWith(
+ "ManagementControllers",
+ expect.any(Function),
+ false
+ );
expect(vi.getTimerCount()).toBe(2);
});
@@ -268,6 +292,185 @@ describe("resolveControllerRecord (via Start)", () => {
expect.any(Buffer),
expect.any(Buffer)
);
+ expect(OnConnectionClosed).toHaveBeenCalled();
expect(vi.getTimerCount()).toBeGreaterThanOrEqual(1);
});
});
+
+function mockReadyManageAccessPoint(
+ accessPoint = {
+ id: "ap-1",
+ hostname: "router.example.com",
+ port: 5671,
+ colocated: false,
+ }
+) {
+ LoadSecret.mockResolvedValue({
+ data: {
+ "ca.crt": Buffer.from("ca").toString("base64"),
+ "tls.crt": Buffer.from("cert").toString("base64"),
+ "tls.key": Buffer.from("key").toString("base64"),
+ },
+ });
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
+ return {};
+ }
+ if (sql.includes("SELECT Certificate FROM ManagementControllers")) {
+ return { rows: [{ certificate: "cert-1" }] };
+ }
+ if (sql.includes("SELECT * FROM ManagementControllers WHERE Name = $1 and LifeCycle")) {
+ return {
+ rowCount: 1,
+ rows: [{ name: "test-controller", certificate: "cert-1" }],
+ };
+ }
+ if (sql.includes("SELECT * FROM ManagementControllers WHERE Name")) {
+ return {
+ rowCount: 1,
+ rows: [{ name: "test-controller", certificate: "cert-1" }],
+ };
+ }
+ if (sql.includes("SELECT ObjectName FROM TlsCertificates")) {
+ return { rowCount: 1, rows: [{ objectname: "tls-secret" }] };
+ }
+ if (sql.includes("BackboneAccessPoints AS ap")) {
+ return { rows: [accessPoint] };
+ }
+ return { rows: [] };
+ });
+}
+
+describe("manage connection refresh", () => {
+ let Start;
+
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ mockClient.query.mockReset();
+ for (const key of Object.keys(notificationHandlers)) {
+ delete notificationHandlers[key];
+ }
+ vi.resetModules();
+ ({ Start } = await import("./backbone-links.js"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ async function startConnected() {
+ mockReadyManageAccessPoint();
+ await Start("test-controller");
+ await vi.runOnlyPendingTimersAsync();
+ await vi.runOnlyPendingTimersAsync();
+ }
+
+ it("reconnects when the manage access point endpoint changes", async () => {
+ await startConnected();
+ OpenConnection.mockClear();
+ CloseConnection.mockClear();
+
+ mockReadyManageAccessPoint({
+ id: "ap-1",
+ hostname: "router-b.example.com",
+ port: 5672,
+ colocated: false,
+ });
+
+ await notificationHandlers.BackboneAccessPoints("UPDATE", "ap-1");
+
+ expect(CloseConnection).toHaveBeenCalled();
+ expect(OpenConnection).toHaveBeenCalledWith(
+ "Backbone-management-ap-1",
+ "router-b.example.com",
+ 5672,
+ "tls",
+ expect.any(Buffer),
+ expect.any(Buffer),
+ expect.any(Buffer)
+ );
+ });
+
+ it("reloads AMQP connections when the controller TLS certificate is renewed", async () => {
+ await startConnected();
+ OpenConnection.mockClear();
+ CloseConnection.mockClear();
+
+ await notificationHandlers.TlsCertificates("ADD", "cert-1");
+
+ expect(CloseConnection).toHaveBeenCalled();
+ expect(OpenConnection).toHaveBeenCalledWith(
+ "Backbone-management-ap-1",
+ "router.example.com",
+ 5671,
+ "tls",
+ expect.any(Buffer),
+ expect.any(Buffer),
+ expect.any(Buffer)
+ );
+ });
+
+ it("reloads AMQP connections when the controller certificate id changes", async () => {
+ await startConnected();
+ OpenConnection.mockClear();
+ CloseConnection.mockClear();
+
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
+ return {};
+ }
+ if (sql.includes("SELECT Certificate FROM ManagementControllers")) {
+ return { rows: [{ certificate: "cert-2" }] };
+ }
+ if (sql.includes("SELECT * FROM ManagementControllers WHERE Name = $1 and LifeCycle")) {
+ return {
+ rowCount: 1,
+ rows: [{ name: "test-controller", certificate: "cert-2" }],
+ };
+ }
+ if (sql.includes("SELECT * FROM ManagementControllers WHERE Name")) {
+ return {
+ rowCount: 1,
+ rows: [{ name: "test-controller", certificate: "cert-2" }],
+ };
+ }
+ if (sql.includes("SELECT ObjectName FROM TlsCertificates")) {
+ return { rowCount: 1, rows: [{ objectname: "tls-secret" }] };
+ }
+ if (sql.includes("BackboneAccessPoints AS ap")) {
+ return {
+ rows: [
+ {
+ id: "ap-1",
+ hostname: "router.example.com",
+ port: 5671,
+ colocated: false,
+ },
+ ],
+ };
+ }
+ return { rows: [] };
+ });
+
+ await notificationHandlers.ManagementControllers("UPDATE", "mc-1");
+
+ expect(CloseConnection).toHaveBeenCalled();
+ expect(OpenConnection).toHaveBeenCalled();
+ });
+
+ it("reconnects after an unexpected AMQP disconnect", async () => {
+ await startConnected();
+ const conn = OpenConnection.mock.results[0].value;
+ OpenConnection.mockClear();
+ CloseConnection.mockClear();
+
+ conn._onClosed();
+ await Promise.resolve();
+ await Promise.resolve();
+ await vi.advanceTimersByTimeAsync(1000);
+
+ expect(CloseConnection).toHaveBeenCalled();
+ expect(OpenConnection).toHaveBeenCalled();
+ });
+});
diff --git a/components/management-controller/src/certs.js b/components/management-controller/src/certs.js
index 17252069..afd87c5f 100644
--- a/components/management-controller/src/certs.js
+++ b/components/management-controller/src/certs.js
@@ -19,14 +19,21 @@
"use strict";
+import { randomUUID } from "node:crypto";
import {
ApplyObject,
LoadCertificate,
+ LoadSecret,
+ ReplaceCertificate,
+ ReplaceSecret,
+ TriggerCertificateRenewal,
WatchSecrets,
WatchCertificates,
GetIssuers,
+ kubeStatusCode,
} from "@vms/modules/kube";
import { Log } from "@vms/modules/log";
+import { IsValidUuid } from "@vms/modules/util";
import { ClientFromPool, IntervalMilliseconds } from "./db.js";
import {
BackboneExpiration,
@@ -37,10 +44,32 @@ import {
CertOrganization,
} from "./config.js";
import { SiteCertificateChanged, AccessCertificateChanged } from "./sync-management.js";
+import { SyncColoTlsCertificate } from "./colo-sync.js";
import { CompleteMember } from "./claim-server.js";
import { AccessPointCertReady, SiteLifecycleChanged_TX } from "./site-deployment-state.js";
import { META_ANNOTATION_VMS_CONTROLLED } from "@vms/modules/common";
import { NotifyTransaction, RegisterNotification } from "./notify.js";
+import {
+ expirationFromTlsSecret,
+ timestampsEqual,
+ lockCurrentCertificate,
+ lockCurrentCertificateByObjectName,
+ isCertificateSuperseded,
+ retargetParentCertificateFks,
+ hasLiveChildren,
+ listCurrentLeafChildren,
+ loadCertificateRow,
+} from "./tls-rotation.js";
+
+const PG_UNIQUE_VIOLATION = "23505";
+const KUBE_CONFLICT_RETRIES = 5;
+const secretWorkTail = new Map();
+
+function httpError(statusCode, message) {
+ const error = new Error(message);
+ error.statusCode = statusCode;
+ return error;
+}
//
// When new management controllers are created, add a certificate request.
@@ -186,7 +215,7 @@ async function onAccessPointsChange(action, id) {
row.starttime.getTime() +
IntervalMilliseconds(row.deletedelay);
} else {
- duration_ms = IntervalMilliseconds(DefaultCaExpiration());
+ duration_ms = IntervalMilliseconds(DefaultCertExpiration());
}
const cert = await client.query(
"INSERT INTO CertificateRequests(Id, RequestType, CreatedTime, RequestTime, DurationHours, AccessPoint, Issuer, Hostname) " +
@@ -573,6 +602,486 @@ async function processCertificateRequests(nonrecurring) {
}
}
+function ownerFromCertificateRequest(cert_request) {
+ if (cert_request.managementcontroller) {
+ return {
+ ref_table: "ManagementControllers",
+ ref_id: cert_request.managementcontroller,
+ ref_label: "Management Controller",
+ is_ca: false,
+ alertSiteCertChanged: false,
+ alertAccessCertChanged: false,
+ alertMemberCompletion: false,
+ };
+ }
+ if (cert_request.backbone) {
+ return {
+ ref_table: "Backbones",
+ ref_id: cert_request.backbone,
+ ref_label: "Backbone",
+ is_ca: true,
+ alertSiteCertChanged: false,
+ alertAccessCertChanged: false,
+ alertMemberCompletion: false,
+ };
+ }
+ if (cert_request.interiorsite) {
+ return {
+ ref_table: "InteriorSites",
+ ref_id: cert_request.interiorsite,
+ ref_label: "Backbone Site",
+ is_ca: false,
+ alertSiteCertChanged: true,
+ alertAccessCertChanged: false,
+ alertMemberCompletion: false,
+ };
+ }
+ if (cert_request.accesspoint) {
+ return {
+ ref_table: "BackboneAccessPoints",
+ ref_id: cert_request.accesspoint,
+ ref_label: "Access Point",
+ is_ca: false,
+ alertSiteCertChanged: false,
+ alertAccessCertChanged: true,
+ alertMemberCompletion: false,
+ };
+ }
+ if (cert_request.applicationnetwork) {
+ return {
+ ref_table: "ApplicationNetworks",
+ ref_id: cert_request.applicationnetwork,
+ ref_label: "VAN",
+ is_ca: true,
+ alertSiteCertChanged: false,
+ alertAccessCertChanged: false,
+ alertMemberCompletion: false,
+ };
+ }
+ if (cert_request.networkcredential) {
+ return {
+ ref_table: "NetworkCredentials",
+ ref_id: cert_request.networkcredential,
+ ref_label: "VAN Attach",
+ is_ca: false,
+ alertSiteCertChanged: false,
+ alertAccessCertChanged: false,
+ alertMemberCompletion: false,
+ };
+ }
+ if (cert_request.invitation) {
+ return {
+ ref_table: "MemberInvitations",
+ ref_id: cert_request.invitation,
+ ref_label: "Member Invitation",
+ is_ca: false,
+ alertSiteCertChanged: false,
+ alertAccessCertChanged: false,
+ alertMemberCompletion: false,
+ };
+ }
+ if (cert_request.site) {
+ return {
+ ref_table: "MemberSites",
+ ref_id: cert_request.site,
+ ref_label: "Member Site",
+ is_ca: false,
+ alertSiteCertChanged: false,
+ alertAccessCertChanged: false,
+ alertMemberCompletion: true,
+ };
+ }
+ throw new Error("Unknown Target");
+}
+
+async function expirationAndRenewalFromSecret(secret) {
+ const cert_object = await LoadCertificate(secret.metadata.name);
+ const expiration =
+ expirationFromTlsSecret(secret) ||
+ (cert_object?.status?.notAfter ? new Date(cert_object.status.notAfter) : undefined);
+ const renewal = cert_object?.status?.renewalTime
+ ? new Date(cert_object.status.renewalTime)
+ : undefined;
+ return { expiration, renewal };
+}
+
+function applyCertificateDblink(cert, newId) {
+ const alreadyUpdated =
+ cert.metadata?.annotations?.["skupper.io/vms-dblink"] === newId &&
+ cert.spec?.secretTemplate?.annotations?.["skupper.io/vms-dblink"] === newId;
+ if (alreadyUpdated) {
+ return false;
+ }
+ cert.metadata ??= {};
+ cert.metadata.annotations ??= {};
+ cert.spec ??= {};
+ cert.spec.secretTemplate ??= {};
+ cert.spec.secretTemplate.annotations ??= {};
+ cert.metadata.annotations["skupper.io/vms-dblink"] = newId;
+ cert.spec.secretTemplate.annotations["skupper.io/vms-dblink"] = newId;
+ return true;
+}
+
+function applySecretDblink(kubeSecret, newId) {
+ if (kubeSecret.metadata?.annotations?.["skupper.io/vms-dblink"] === newId) {
+ return false;
+ }
+ kubeSecret.metadata ??= {};
+ kubeSecret.metadata.annotations ??= {};
+ kubeSecret.metadata.annotations["skupper.io/vms-dblink"] = newId;
+ return true;
+}
+
+async function replaceWithConflictRetry(load, shouldWrite, write) {
+ let lastErr;
+ for (let attempt = 0; attempt < KUBE_CONFLICT_RETRIES; attempt++) {
+ const obj = await load();
+ if (!obj) {
+ return;
+ }
+ if (!shouldWrite(obj)) {
+ return;
+ }
+ try {
+ await write(obj);
+ return;
+ } catch (err) {
+ lastErr = err;
+ if (kubeStatusCode(err) != 409) {
+ throw err;
+ }
+ }
+ }
+ throw lastErr;
+}
+
+async function retargetTlsDbLink(objectName, newId) {
+ await replaceWithConflictRetry(
+ () => LoadCertificate(objectName),
+ (cert) => applyCertificateDblink(cert, newId),
+ (cert) => ReplaceCertificate(cert)
+ );
+ await replaceWithConflictRetry(
+ () => LoadSecret(objectName),
+ (kubeSecret) => applySecretDblink(kubeSecret, newId),
+ (kubeSecret) => ReplaceSecret(objectName, kubeSecret)
+ );
+}
+
+function enqueueSecretWork(objectName, work) {
+ const previous = secretWorkTail.get(objectName) || Promise.resolve();
+ const next = previous.catch(() => {}).then(work);
+ secretWorkTail.set(objectName, next);
+ next.finally(() => {
+ if (secretWorkTail.get(objectName) === next) {
+ secretWorkTail.delete(objectName);
+ }
+ });
+ return next;
+}
+
+async function maybeTrimIssuerSiblings(certId) {
+ const client = await ClientFromPool("system");
+ try {
+ const cert = await loadCertificateRow(client, certId);
+ if (!cert?.signedby) {
+ return;
+ }
+ const issuer = await loadCertificateRow(client, cert.signedby);
+ const oldCaId = issuer?.supercedes;
+ if (!oldCaId) {
+ return;
+ }
+ if (await hasLiveChildren(client, oldCaId)) {
+ return;
+ }
+ const siblings = await listCurrentLeafChildren(client, issuer.id);
+ for (const sibling of siblings) {
+ if (sibling.id === certId) {
+ continue;
+ }
+ await SiteCertificateChanged(sibling.id);
+ await AccessCertificateChanged(sibling.id);
+ await SyncColoTlsCertificate(sibling.id);
+ }
+ } finally {
+ client.release();
+ }
+}
+
+async function notifyTlsConsumers(certId) {
+ await SiteCertificateChanged(certId);
+ await AccessCertificateChanged(certId);
+ await SyncColoTlsCertificate(certId);
+ await maybeTrimIssuerSiblings(certId);
+}
+
+function durationHoursFromInterval(interval) {
+ return Math.trunc(IntervalMilliseconds(interval) / 3600000);
+}
+
+const VAN_CA_MIN_DURATION_HOURS = 1;
+
+function vanCaDurationHours(van) {
+ if (van.endtime) {
+ const durationMs =
+ new Date(van.endtime).getTime() - Date.now() + IntervalMilliseconds(van.deletedelay);
+ return Math.max(VAN_CA_MIN_DURATION_HOURS, Math.trunc(durationMs / 3600000));
+ }
+ return durationHoursFromInterval(DefaultCaExpiration());
+}
+
+async function insertRotationCertificateRequest(
+ client,
+ notify,
+ created,
+ { requestType, ownerColumn, ownerId, issuerId, supercedes, durationHours, hostname }
+) {
+ const pending = await client.query("SELECT Id FROM CertificateRequests WHERE Supercedes = $1", [
+ supercedes,
+ ]);
+ if (pending.rowCount > 0) {
+ return created;
+ }
+ const already = await client.query("SELECT Id FROM TlsCertificates WHERE Supercedes = $1", [
+ supercedes,
+ ]);
+ if (already.rowCount > 0) {
+ return created;
+ }
+ const result = await client.query(
+ `INSERT INTO CertificateRequests(Id, RequestType, CreatedTime, RequestTime, DurationHours, ${ownerColumn}, Issuer, Supercedes, Hostname) ` +
+ "VALUES(gen_random_uuid(), $1, $2, now(), $3, $4, $5, $6, $7) RETURNING Id",
+ [requestType, created, durationHours, ownerId, issuerId, supercedes, hostname || null]
+ );
+ notify.add("CertificateRequests", result.rows[0].id);
+ return new Date(created.getTime() + 1);
+}
+
+async function insertCaRotationRequest(oldCertId) {
+ const client = await ClientFromPool("system");
+ const notify = new NotifyTransaction();
+ try {
+ await client.query("BEGIN");
+ const cert = await lockCurrentCertificate(client, oldCertId);
+ if (!cert) {
+ throw httpError(404, "Certificate not found");
+ }
+ if (!cert.isca) {
+ throw httpError(400, "Certificate is not a CA");
+ }
+ if (await isCertificateSuperseded(client, oldCertId)) {
+ throw httpError(409, "Certificate has been superseded");
+ }
+ const pending = await client.query(
+ "SELECT Id FROM CertificateRequests WHERE Supercedes = $1",
+ [oldCertId]
+ );
+ if (pending.rowCount > 0) {
+ throw httpError(409, "Certificate rotation already in progress");
+ }
+ const bb = await client.query("SELECT Id FROM Backbones WHERE Certificate = $1", [
+ oldCertId,
+ ]);
+ let result;
+ if (bb.rowCount == 1) {
+ const durationHours = durationHoursFromInterval(BackboneExpiration());
+ result = await client.query(
+ "INSERT INTO CertificateRequests(Id, RequestType, CreatedTime, RequestTime, DurationHours, Backbone, Issuer, Supercedes) " +
+ "VALUES(gen_random_uuid(), 'backboneCA', now(), now(), $1, $2, $3, $4) RETURNING Id",
+ [durationHours, bb.rows[0].id, cert.signedby, oldCertId]
+ );
+ } else {
+ const van = await client.query(
+ "SELECT an.Id, an.StartTime, an.EndTime, an.DeleteDelay, b.Certificate AS bbca " +
+ "FROM ApplicationNetworks an " +
+ "JOIN Backbones b ON b.Id = an.Backbone " +
+ "WHERE an.Certificate = $1",
+ [oldCertId]
+ );
+ if (van.rowCount != 1) {
+ throw httpError(400, "Certificate rotation of this CA is not supported");
+ }
+ const row = van.rows[0];
+ result = await client.query(
+ "INSERT INTO CertificateRequests(Id, RequestType, CreatedTime, RequestTime, DurationHours, ApplicationNetwork, Issuer, Supercedes) " +
+ "VALUES(gen_random_uuid(), 'vanCA', now(), now(), $1, $2, $3, $4) RETURNING Id",
+ [vanCaDurationHours(row), row.id, row.bbca, oldCertId]
+ );
+ }
+ notify.add("CertificateRequests", result.rows[0].id);
+ await client.query("COMMIT");
+ await notify.commit();
+ return result.rows[0].id;
+ } catch (err) {
+ await client.query("ROLLBACK");
+ throw err;
+ } finally {
+ client.release();
+ }
+}
+
+async function enqueueBackboneCaChildRequests(newCaId, oldCaId) {
+ const client = await ClientFromPool("system");
+ const notify = new NotifyTransaction();
+ try {
+ await client.query("BEGIN");
+ const backbone = await client.query("SELECT Id FROM Backbones WHERE Certificate = $1", [
+ newCaId,
+ ]);
+ if (backbone.rowCount != 1) {
+ await client.query("COMMIT");
+ return;
+ }
+ const backboneId = backbone.rows[0].id;
+ const leafHours = durationHoursFromInterval(DefaultCertExpiration());
+ const sites = await client.query(
+ "SELECT s.Id, s.Certificate FROM InteriorSites s " +
+ "JOIN TlsCertificates c ON c.Id = s.Certificate " +
+ "WHERE s.Backbone = $1 AND c.SignedBy = $2",
+ [backboneId, oldCaId]
+ );
+ const aps = await client.query(
+ "SELECT ap.Id, ap.Kind, ap.Hostname, ap.Certificate FROM BackboneAccessPoints ap " +
+ "JOIN InteriorSites s ON s.Id = ap.InteriorSite " +
+ "JOIN TlsCertificates c ON c.Id = ap.Certificate " +
+ "WHERE s.Backbone = $1 AND c.SignedBy = $2",
+ [backboneId, oldCaId]
+ );
+ const vans = await client.query(
+ "SELECT an.Id, an.Certificate, an.StartTime, an.EndTime, an.DeleteDelay FROM ApplicationNetworks an " +
+ "JOIN TlsCertificates c ON c.Id = an.Certificate " +
+ "WHERE an.Backbone = $1 AND c.SignedBy = $2",
+ [backboneId, oldCaId]
+ );
+ const creds = await client.query(
+ "SELECT cred.Id, cred.Certificate FROM NetworkCredentials cred " +
+ "JOIN ApplicationNetworks an ON an.Id = cred.MemberOf " +
+ "JOIN TlsCertificates c ON c.Id = cred.Certificate " +
+ "WHERE an.Backbone = $1 AND c.SignedBy = $2",
+ [backboneId, oldCaId]
+ );
+ const nonManage = [];
+ const manage = [];
+ for (const ap of aps.rows) {
+ if (ap.kind == "manage") {
+ manage.push(ap);
+ } else {
+ nonManage.push(ap);
+ }
+ }
+
+ let created = new Date();
+ const insertChild = async (spec) => {
+ created = await insertRotationCertificateRequest(client, notify, created, {
+ ...spec,
+ issuerId: newCaId,
+ });
+ };
+
+ for (const site of sites.rows) {
+ await insertChild({
+ requestType: "interiorRouter",
+ ownerColumn: "InteriorSite",
+ ownerId: site.id,
+ supercedes: site.certificate,
+ durationHours: leafHours,
+ });
+ }
+ for (const ap of nonManage) {
+ await insertChild({
+ requestType: "accessPoint",
+ ownerColumn: "AccessPoint",
+ ownerId: ap.id,
+ supercedes: ap.certificate,
+ durationHours: leafHours,
+ hostname: ap.hostname,
+ });
+ }
+ for (const van of vans.rows) {
+ await insertChild({
+ requestType: "vanCA",
+ ownerColumn: "ApplicationNetwork",
+ ownerId: van.id,
+ supercedes: van.certificate,
+ durationHours: vanCaDurationHours(van),
+ });
+ }
+ for (const cred of creds.rows) {
+ await insertChild({
+ requestType: "vanCredential",
+ ownerColumn: "NetworkCredential",
+ ownerId: cred.id,
+ supercedes: cred.certificate,
+ durationHours: leafHours,
+ });
+ }
+ for (const ap of manage) {
+ await insertChild({
+ requestType: "accessPoint",
+ ownerColumn: "AccessPoint",
+ ownerId: ap.id,
+ supercedes: ap.certificate,
+ durationHours: leafHours,
+ hostname: ap.hostname,
+ });
+ }
+
+ await client.query("COMMIT");
+ await notify.commit();
+ } catch (err) {
+ Log(`Rolling back enqueue-backbone-ca-children transaction: ${err.stack}`);
+ await client.query("ROLLBACK");
+ } finally {
+ client.release();
+ }
+}
+
+async function enqueueVanCaChildRequests(newCaId, oldCaId) {
+ const client = await ClientFromPool("system");
+ const notify = new NotifyTransaction();
+ try {
+ await client.query("BEGIN");
+ const van = await client.query(
+ "SELECT Id FROM ApplicationNetworks WHERE Certificate = $1",
+ [newCaId]
+ );
+ if (van.rowCount != 1) {
+ await client.query("COMMIT");
+ return;
+ }
+ const vanId = van.rows[0].id;
+ const leafHours = durationHoursFromInterval(DefaultCertExpiration());
+ // Invitation claims stay on the old vanCA; claim rotation is out of scope.
+ const members = await client.query(
+ "SELECT m.Id, m.Certificate FROM MemberSites m " +
+ "JOIN TlsCertificates c ON c.Id = m.Certificate " +
+ "WHERE m.MemberOf = $1 AND c.SignedBy = $2",
+ [vanId, oldCaId]
+ );
+
+ let created = new Date();
+ for (const member of members.rows) {
+ created = await insertRotationCertificateRequest(client, notify, created, {
+ requestType: "vanSite",
+ ownerColumn: "Site",
+ ownerId: member.id,
+ issuerId: newCaId,
+ supercedes: member.certificate,
+ durationHours: leafHours,
+ });
+ }
+
+ await client.query("COMMIT");
+ await notify.commit();
+ } catch (err) {
+ Log(`Rolling back enqueue-van-ca-children transaction: ${err.stack}`);
+ await client.query("ROLLBACK");
+ } finally {
+ client.release();
+ }
+}
+
//
// A secret that is controlled by this controller and has a database link has been added. Update the database
// to register the completion of the creation of a certificate or a CA.
@@ -585,107 +1094,114 @@ async function secretAdded(dblink, secret) {
const result = await client.query("SELECT * FROM CertificateRequests WHERE Id = $1", [
dblink,
]);
- let ref_table;
- let ref_id;
- let ref_label;
- let is_ca = false;
- let alertSiteCertChanged = false;
- let alertAccessCertChanged = false;
- let alertMemberCompletion = false;
- if (result.rowCount == 1) {
- const cert_request = result.rows[0];
-
- if (cert_request.managementcontroller) {
- ref_table = "ManagementControllers";
- ref_id = cert_request.managementcontroller;
- ref_label = "Management Controller";
- } else if (cert_request.backbone) {
- ref_table = "Backbones";
- ref_id = cert_request.backbone;
- is_ca = true;
- ref_label = "Backbone";
- } else if (cert_request.interiorsite) {
- ref_table = "InteriorSites";
- ref_id = cert_request.interiorsite;
- ref_label = "Backbone Site";
- alertSiteCertChanged = true;
- } else if (cert_request.accesspoint) {
- ref_table = "BackboneAccessPoints";
- ref_id = cert_request.accesspoint;
- ref_label = "Access Point";
- alertAccessCertChanged = true;
- } else if (cert_request.applicationnetwork) {
- ref_table = "ApplicationNetworks";
- ref_id = cert_request.applicationnetwork;
- is_ca = true;
- ref_label = "VAN";
- } else if (cert_request.networkcredential) {
- ref_table = "NetworkCredentials";
- ref_id = cert_request.networkcredential;
- is_ca = false;
- ref_label = "VAN Attach";
- } else if (cert_request.invitation) {
- ref_table = "MemberInvitations";
- ref_id = cert_request.invitation;
- ref_label = "Member Invitation";
- } else if (cert_request.site) {
- ref_table = "MemberSites";
- ref_id = cert_request.site;
- ref_label = "Member Site";
- alertMemberCompletion = true;
- } else {
- throw new Error("Unknown Target");
- }
- const cert_object = await LoadCertificate(secret.metadata.name);
- const expiration = cert_object.status.notAfter
- ? new Date(cert_object.status.notAfter)
- : undefined;
- const renewal = cert_object.status.renewalTime
- ? new Date(cert_object.status.renewalTime)
- : undefined;
- const signed_by = secret.metadata.annotations["skupper.io/vms-issuerlink"];
- const get_name = await client.query(`SELECT name FROM ${ref_table} WHERE Id = $1`, [
- ref_id,
- ]);
- const label = `${ref_label}: ${get_name.rows[0].name}`;
- if (signed_by == "root") {
- await client.query(
- "INSERT INTO TlsCertificates (Id, IsCA, ObjectName, Expiration, RenewalTime, Label) VALUES ($1, $2, $3, $4, $5, $6)",
- [dblink, is_ca, secret.metadata.name, expiration, renewal, label]
- );
- notify.add("TlsCertificates", dblink);
- } else {
- await client.query(
- "INSERT INTO TlsCertificates (Id, IsCA, ObjectName, Expiration, RenewalTime, Label, SignedBy) VALUES ($1, $2, $3, $4, $5, $6, $7)",
- [dblink, is_ca, secret.metadata.name, expiration, renewal, label, signed_by]
- );
- notify.add("TlsCertificates", dblink);
+ if (result.rowCount != 1) {
+ await client.query("ROLLBACK");
+ return false;
+ }
+
+ const cert_request = result.rows[0];
+ const owner = ownerFromCertificateRequest(cert_request);
+ const ref_table = owner.ref_table;
+ const ref_id = owner.ref_id;
+ const is_ca = owner.is_ca;
+ const alertSiteCertChanged = owner.alertSiteCertChanged;
+ const alertAccessCertChanged = owner.alertAccessCertChanged;
+ const alertMemberCompletion = owner.alertMemberCompletion;
+ const rotation = !!cert_request.supercedes;
+ const oldCaId = cert_request.supercedes;
+
+ const { expiration, renewal } = await expirationAndRenewalFromSecret(secret);
+ const annotationIssuer = secret.metadata.annotations["skupper.io/vms-issuerlink"];
+ const signed_by = rotation
+ ? cert_request.issuer
+ : annotationIssuer == "root"
+ ? null
+ : annotationIssuer;
+ const get_name = await client.query(`SELECT name FROM ${ref_table} WHERE Id = $1`, [
+ ref_id,
+ ]);
+ const label = `${owner.ref_label}: ${get_name.rows[0].name}`;
+
+ let rotationOrdinal = 0;
+ if (rotation) {
+ const predecessor = await lockCurrentCertificate(client, cert_request.supercedes);
+ if (!predecessor) {
+ throw new Error(`Superseded certificate ${cert_request.supercedes} not found`);
}
+ rotationOrdinal = (predecessor.rotationordinal ?? 0) + 1;
+ }
+
+ await client.query(
+ "INSERT INTO TlsCertificates (Id, IsCA, ObjectName, Expiration, RenewalTime, Label, SignedBy, RotationOrdinal, Supercedes) " +
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
+ [
+ dblink,
+ is_ca,
+ secret.metadata.name,
+ expiration,
+ renewal,
+ label,
+ signed_by || null,
+ rotationOrdinal,
+ cert_request.supercedes,
+ ]
+ );
+ notify.add("TlsCertificates", dblink);
+
+ if (rotation) {
+ await retargetParentCertificateFks(client, notify, cert_request.supercedes, dblink);
+ } else {
await client.query(
`UPDATE ${ref_table} SET Certificate = $1, Lifecycle = 'ready' WHERE Id = $2`,
[dblink, ref_id]
);
notify.update(ref_table, ref_id);
- await client.query("DELETE FROM CertificateRequests WHERE Id = $1", [dblink]);
- notify.delete("CertificateRequests", dblink);
- if (is_ca) {
- const issuer_obj = issuerObject(
- secret.metadata.name,
- secret.metadata.annotations["skupper.io/vms-dblink"]
+ }
+
+ await client.query("DELETE FROM CertificateRequests WHERE Id = $1", [dblink]);
+ notify.delete("CertificateRequests", dblink);
+ if (is_ca) {
+ const issuer_obj = issuerObject(
+ secret.metadata.name,
+ secret.metadata.annotations["skupper.io/vms-dblink"]
+ );
+ await ApplyObject(issuer_obj);
+ }
+ Log(
+ `Certificate${is_ca ? " Authority" : ""}${rotation ? " rotated" : " created"}: ${secret.metadata.name}`
+ );
+ if (alertSiteCertChanged && !rotation) {
+ await SiteLifecycleChanged_TX(client, notify, ref_id, "ready");
+ }
+ await client.query("COMMIT");
+ await notify.commit();
+
+ // cert-manager writes the Secret before status.renewalTime exists; fill it from a follow-up GET.
+ if (!renewal) {
+ try {
+ const cert_object = await LoadCertificate(secret.metadata.name);
+ await persistCertificateTimes(cert_object, dblink);
+ } catch (err) {
+ Log(
+ `Failed to persist certificate times for ${secret.metadata.name}: ${err.stack}`
);
- await ApplyObject(issuer_obj);
}
- Log(`Certificate${is_ca ? " Authority" : ""} created: ${secret.metadata.name}`);
- if (alertSiteCertChanged) {
- await SiteLifecycleChanged_TX(client, notify, ref_id, "ready");
- }
- await client.query("COMMIT");
- await notify.commit();
+ }
- //
- // Alert the sync module that changes have been made that require reconciliation with remote sites
- //
+ if (rotation) {
+ if (is_ca && oldCaId) {
+ if (ref_table == "Backbones") {
+ await enqueueBackboneCaChildRequests(dblink, oldCaId);
+ } else if (ref_table == "ApplicationNetworks") {
+ await enqueueVanCaChildRequests(dblink, oldCaId);
+ }
+ await maybeTrimIssuerSiblings(dblink);
+ }
+ if (!is_ca) {
+ await notifyTlsConsumers(dblink);
+ }
+ } else {
if (alertSiteCertChanged) {
await SiteCertificateChanged(dblink);
} else if (alertAccessCertChanged) {
@@ -705,65 +1221,200 @@ async function secretAdded(dblink, secret) {
if (ref_table == "BackboneAccessPoints") {
await AccessPointCertReady(ref_id);
}
+ }
+ return true;
+ } catch (err) {
+ if (err.code === PG_UNIQUE_VIOLATION) {
+ Log(`Certificate ${dblink} already has a successor; ignoring duplicate secret add`);
} else {
- //
- // There's been no meaningful action taken. Roll back the transaction.
- //
+ Log(`Rolling back secret-added transaction: ${err.stack}`);
+ }
+ //
+ // There's been no meaningful action taken. Roll back the transaction.
+ //
+ await client.query("ROLLBACK");
+ return false;
+ } finally {
+ client.release();
+ }
+}
+
+async function secretRenewed(secret) {
+ const objectName = secret.metadata.name;
+ const { expiration, renewal } = await expirationAndRenewalFromSecret(secret);
+ let currentId;
+ let caRotationId;
+ const client = await ClientFromPool("system");
+ const notify = new NotifyTransaction();
+ try {
+ await client.query("BEGIN");
+ const latest = await lockCurrentCertificateByObjectName(client, objectName);
+ if (!latest) {
await client.query("ROLLBACK");
+ return;
+ }
+ if (await isCertificateSuperseded(client, latest.id)) {
+ await client.query("ROLLBACK");
+ return;
+ }
+ if (timestampsEqual(latest.expiration, expiration)) {
+ if (!timestampsEqual(latest.renewaltime, renewal) && renewal) {
+ await client.query("UPDATE TlsCertificates SET RenewalTime = $1 WHERE Id = $2", [
+ renewal,
+ latest.id,
+ ]);
+ notify.update("TlsCertificates", latest.id);
+ await client.query("COMMIT");
+ await notify.commit();
+ } else {
+ await client.query("ROLLBACK");
+ }
+ return;
+ }
+ if (latest.isca) {
+ caRotationId = latest.id;
+ await client.query("COMMIT");
+ } else {
+ currentId = randomUUID();
+ const signedBy =
+ secret.metadata.annotations?.["skupper.io/vms-issuerlink"] == "root"
+ ? null
+ : secret.metadata.annotations?.["skupper.io/vms-issuerlink"] || latest.signedby;
+ await client.query(
+ "INSERT INTO TlsCertificates (Id, IsCA, ObjectName, SignedBy, Expiration, RenewalTime, RotationOrdinal, Supercedes, Label) " +
+ "VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)",
+ [
+ currentId,
+ latest.isca,
+ objectName,
+ signedBy,
+ expiration,
+ renewal,
+ (latest.rotationordinal ?? 0) + 1,
+ latest.id,
+ latest.label,
+ ]
+ );
+ notify.add("TlsCertificates", currentId);
+ await retargetParentCertificateFks(client, notify, latest.id, currentId);
+ await client.query("COMMIT");
+ await notify.commit();
}
} catch (err) {
- Log(`Rolling back secret-added transaction: ${err.stack}`);
+ if (err.code === PG_UNIQUE_VIOLATION) {
+ Log(`Leaf rotation skipped for ${objectName}: successor already exists`);
+ } else {
+ Log(`Rolling back secret-renewed transaction: ${err.stack}`);
+ }
await client.query("ROLLBACK");
+ currentId = undefined;
+ caRotationId = undefined;
} finally {
client.release();
}
+
+ if (caRotationId) {
+ try {
+ await insertCaRotationRequest(caRotationId);
+ } catch (err) {
+ if (err.statusCode === 409) {
+ Log(`CA rotation skipped for ${caRotationId}: ${err.message}`);
+ } else {
+ Log(`CA rotation failed for ${caRotationId}: ${err.stack || err.message}`);
+ }
+ }
+ return;
+ }
+
+ if (!currentId) {
+ return;
+ }
+
+ try {
+ await retargetTlsDbLink(objectName, currentId);
+ } catch (err) {
+ Log(`WARN: Failed to retarget vms-dblink to ${currentId}: ${err.message}`);
+ }
+ await notifyTlsConsumers(currentId);
}
//
// Handle watch events on Secrets
//
const onSecretWatch = function (action, secret) {
- switch (action) {
- case "ADDED": {
- const anno = secret.metadata.annotations;
- if (anno?.[META_ANNOTATION_VMS_CONTROLLED] == "true") {
- const dblink = anno["skupper.io/vms-dblink"];
- if (dblink) {
- secretAdded(dblink, secret);
- }
+ const anno = secret.metadata.annotations;
+ if (anno?.[META_ANNOTATION_VMS_CONTROLLED] != "true") {
+ return;
+ }
+ const dblink = anno["skupper.io/vms-dblink"];
+ if (!dblink) {
+ return;
+ }
+ const objectName = secret.metadata.name;
+ if (action == "ADDED") {
+ return enqueueSecretWork(objectName, () => secretAdded(dblink, secret));
+ }
+ if (action == "MODIFIED" && secret.data) {
+ return enqueueSecretWork(objectName, async () => {
+ const created = await secretAdded(dblink, secret);
+ if (!created) {
+ await secretRenewed(secret);
}
- }
+ });
}
};
+async function persistCertificateTimes(cert, currentIdHint) {
+ const renewalTime = cert?.status?.renewalTime;
+ if (!renewalTime) {
+ return;
+ }
+ const renewal = new Date(renewalTime);
+ const expiration = cert.status?.notAfter ? new Date(cert.status.notAfter) : null;
+ const notify = new NotifyTransaction();
+ const client = await ClientFromPool("system");
+ try {
+ await client.query("BEGIN");
+ let currentId = currentIdHint || cert.metadata?.annotations?.["skupper.io/vms-dblink"];
+ if (currentId && (await isCertificateSuperseded(client, currentId))) {
+ const tip = await lockCurrentCertificateByObjectName(client, cert.metadata?.name);
+ currentId = tip?.id;
+ }
+ if (!currentId) {
+ const tip = await lockCurrentCertificateByObjectName(client, cert.metadata?.name);
+ currentId = tip?.id;
+ }
+ if (!currentId) {
+ await client.query("ROLLBACK");
+ return;
+ }
+ const dbcert = await client.query(
+ "UPDATE TlsCertificates SET RenewalTime = $1::timestamptz, Expiration = COALESCE(Expiration, $2::timestamptz) " +
+ "WHERE Id = $3 AND (RenewalTime IS DISTINCT FROM $1::timestamptz OR (Expiration IS NULL AND $2::timestamptz IS NOT NULL)) RETURNING Id",
+ [renewal, expiration, currentId]
+ );
+ for (const dbrow of dbcert.rows) {
+ notify.update("TlsCertificates", dbrow.id);
+ }
+ await client.query("COMMIT");
+ await notify.commit();
+ } catch (error) {
+ await client.query("ROLLBACK");
+ Log(`Exception in persistCertificateTimes: ${error.stack}`);
+ } finally {
+ client.release();
+ }
+}
+
//
// Handle watch events on Certificates
//
const onCertificateWatch = async function (action, cert) {
if (
- action == "MODIFIED" &&
- cert.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true" &&
- cert.status?.notAfter &&
- cert.status.renewalTime
+ (action == "ADDED" || action == "MODIFIED") &&
+ cert.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true"
) {
- const notify = new NotifyTransaction();
- const client = await ClientFromPool("system");
- const expiration = new Date(cert.status.notAfter);
- const renewal = new Date(cert.status.renewalTime);
- try {
- const dbcert = await client.query(
- "UPDATE TlsCertificates SET expiration = $1, renewalTime = $2 WHERE ObjectName = $3 RETURNING Id",
- [expiration, renewal, cert.metadata.name]
- );
- for (const dbrow of dbcert.rows) {
- notify.update("TlsCertificates", dbrow.id);
- }
- await notify.commit();
- } catch (error) {
- Log(`Exception in onCertificateWatch: ${error.stack}`);
- } finally {
- client.release();
- }
+ await persistCertificateTimes(cert);
}
};
@@ -882,6 +1533,47 @@ const WatchCertManager = async function () {
}
};
+export async function RotateCertificate(cid) {
+ if (!IsValidUuid(cid)) {
+ throw httpError(400, `Malformed certificate ID: ${cid}`);
+ }
+
+ const client = await ClientFromPool("system");
+ try {
+ const result = await client.query(
+ "SELECT Id, ObjectName, IsCA FROM TlsCertificates WHERE Id = $1",
+ [cid]
+ );
+ if (result.rowCount == 0) {
+ throw httpError(404, "Certificate not found");
+ }
+ const cert = result.rows[0];
+ if (await isCertificateSuperseded(client, cid)) {
+ throw httpError(409, "Certificate has been superseded");
+ }
+ if (!cert.isca) {
+ if (!cert.objectname) {
+ throw httpError(400, "Certificate has no Kubernetes object");
+ }
+ Log(`Triggering cert-manager renewal for ${cert.objectname} (${cid})`);
+ try {
+ await TriggerCertificateRenewal(cert.objectname);
+ } catch (err) {
+ if (kubeStatusCode(err) == 404) {
+ throw httpError(404, `Certificate object ${cert.objectname} not found`);
+ }
+ throw err;
+ }
+ return { id: cid };
+ }
+ } finally {
+ client.release();
+ }
+
+ await insertCaRotationRequest(cid);
+ return { id: cid };
+}
+
export async function Start() {
Log("[Certificate module starting]");
RegisterNotification("ManagementControllers", onManagementControllersChange, true);
diff --git a/components/management-controller/src/certs.test.js b/components/management-controller/src/certs.test.js
index 145b96ed..3c2f115a 100644
--- a/components/management-controller/src/certs.test.js
+++ b/components/management-controller/src/certs.test.js
@@ -17,7 +17,7 @@
under the License.
*/
-import { describe, it, expect, vi, beforeEach } from "vitest";
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockClient = {
query: vi.fn(),
@@ -30,13 +30,21 @@ const notificationHandlers = {};
/** @type {Array<{ method: string, table: string, id: string }>} */
const notifyEvents = [];
-vi.mock("@vms/modules/kube", () => ({
- ApplyObject: vi.fn(),
- LoadCertificate: vi.fn(),
- WatchSecrets: vi.fn(),
- WatchCertificates: vi.fn(),
- GetIssuers: vi.fn(async () => []),
-}));
+vi.mock("@vms/modules/kube", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ ApplyObject: vi.fn(),
+ LoadCertificate: vi.fn(),
+ LoadSecret: vi.fn(),
+ ReplaceCertificate: vi.fn(),
+ ReplaceSecret: vi.fn(),
+ TriggerCertificateRenewal: vi.fn(),
+ WatchSecrets: vi.fn(),
+ WatchCertificates: vi.fn(),
+ GetIssuers: vi.fn(async () => []),
+ };
+});
vi.mock("./config.js", () => ({
BackboneExpiration: vi.fn(() => ({ years: 1 })),
@@ -56,6 +64,10 @@ vi.mock("./claim-server.js", () => ({
CompleteMember: vi.fn(),
}));
+vi.mock("./colo-sync.js", () => ({
+ SyncColoTlsCertificate: vi.fn(),
+}));
+
vi.mock("./site-deployment-state.js", () => ({
AccessPointCertReady: vi.fn(),
SiteLifecycleChanged_TX: vi.fn(),
@@ -88,14 +100,91 @@ vi.mock("./notify.js", () => ({
},
}));
-import { Start } from "./certs.js";
+import { Start, RotateCertificate } from "./certs.js";
import { RegisterNotification } from "./notify.js";
-import { ApplyObject } from "@vms/modules/kube";
+import {
+ ApplyObject,
+ LoadCertificate,
+ TriggerCertificateRenewal,
+ WatchSecrets,
+ WatchCertificates,
+} from "@vms/modules/kube";
+import { IntervalMilliseconds } from "./db.js";
+import { DefaultCaExpiration, DefaultCertExpiration } from "./config.js";
+import { META_ANNOTATION_VMS_CONTROLLED } from "@vms/modules/common";
+import { AccessCertificateChanged, SiteCertificateChanged } from "./sync-management.js";
+import { SyncColoTlsCertificate } from "./colo-sync.js";
+import { TEST_UUIDS } from "./test-helpers/mock-db.js";
function transactionSql(sql) {
return sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK";
}
+function certificateRequestInserts() {
+ return mockClient.query.mock.calls.filter(([sql]) =>
+ typeof sql === "string" ? sql.includes("INSERT INTO CertificateRequests") : false
+ );
+}
+
+function mockCaRotationQueries({
+ backboneId,
+ van,
+ pending = false,
+ insertId = "cr-rotate-van",
+ signedby = "bb-ca",
+} = {}) {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("SELECT Id, ObjectName, IsCA FROM TlsCertificates")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: TEST_UUIDS.cert, objectname: "ca-cert", isca: true }],
+ };
+ }
+ if (sql.includes("FOR UPDATE")) {
+ return {
+ rows: [{ id: TEST_UUIDS.cert, isca: true, signedby }],
+ };
+ }
+ if (sql.includes("SELECT 1 FROM TlsCertificates WHERE Supercedes")) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("FROM CertificateRequests WHERE Supercedes")) {
+ return pending
+ ? { rowCount: 1, rows: [{ id: "cr-pending" }] }
+ : { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("FROM Backbones WHERE Certificate")) {
+ return backboneId
+ ? { rowCount: 1, rows: [{ id: backboneId }] }
+ : { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("AS bbca")) {
+ return van ? { rowCount: 1, rows: [van] } : { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("INSERT INTO CertificateRequests")) {
+ return { rows: [{ id: insertId }] };
+ }
+ return { rowCount: 0, rows: [] };
+ });
+}
+
+function controlledTlsSecret(name, dblink, issuerlink = "ca-1") {
+ return {
+ metadata: {
+ name,
+ annotations: {
+ [META_ANNOTATION_VMS_CONTROLLED]: "true",
+ "skupper.io/vms-dblink": dblink,
+ "skupper.io/vms-issuerlink": issuerlink,
+ },
+ },
+ data: { "tls.crt": "cert" },
+ };
+}
+
describe("certs Start", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -414,6 +503,8 @@ describe("onBackboneAccessPointsChange", () => {
table: "CertificateRequests",
id: "cert-req-ap-1",
});
+ expect(DefaultCertExpiration).toHaveBeenCalled();
+ expect(IntervalMilliseconds).toHaveBeenCalledWith({ days: 7 });
});
});
@@ -557,3 +648,656 @@ describe("onInteriorSitesChange", () => {
});
});
});
+
+describe("RotateCertificate", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockClient.query.mockReset();
+ notifyEvents.length = 0;
+ IntervalMilliseconds.mockImplementation(() => 3600000);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("rejects a malformed certificate id", async () => {
+ await expect(RotateCertificate("not-a-uuid")).rejects.toMatchObject({
+ statusCode: 400,
+ message: expect.stringContaining("Malformed certificate ID"),
+ });
+ expect(mockClient.query).not.toHaveBeenCalled();
+ });
+
+ it("returns 404 when the certificate does not exist", async () => {
+ mockClient.query.mockResolvedValue({ rowCount: 0, rows: [] });
+ await expect(RotateCertificate(TEST_UUIDS.cert)).rejects.toMatchObject({
+ statusCode: 404,
+ message: "Certificate not found",
+ });
+ expect(mockClient.release).toHaveBeenCalled();
+ });
+
+ it("returns 409 when the certificate has already been superseded", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql.includes("SELECT Id, ObjectName, IsCA FROM TlsCertificates")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: TEST_UUIDS.cert, objectname: "site-cert", isca: false }],
+ };
+ }
+ if (sql.includes("WHERE Supercedes = $1")) {
+ return { rowCount: 1, rows: [{ "?column?": 1 }] };
+ }
+ return { rowCount: 0, rows: [] };
+ });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).rejects.toMatchObject({
+ statusCode: 409,
+ message: "Certificate has been superseded",
+ });
+ expect(TriggerCertificateRenewal).not.toHaveBeenCalled();
+ });
+
+ it("triggers cert-manager renewal for a current leaf certificate", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql.includes("SELECT Id, ObjectName, IsCA FROM TlsCertificates")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: TEST_UUIDS.cert, objectname: "site-cert", isca: false }],
+ };
+ }
+ return { rowCount: 0, rows: [] };
+ });
+ TriggerCertificateRenewal.mockResolvedValue({});
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).resolves.toEqual({ id: TEST_UUIDS.cert });
+ expect(TriggerCertificateRenewal).toHaveBeenCalledWith("site-cert");
+ });
+
+ it("maps a missing certificate object to 404", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql.includes("SELECT Id, ObjectName, IsCA FROM TlsCertificates")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: TEST_UUIDS.cert, objectname: "site-cert", isca: false }],
+ };
+ }
+ return { rowCount: 0, rows: [] };
+ });
+ TriggerCertificateRenewal.mockRejectedValue({ statusCode: 404 });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).rejects.toMatchObject({
+ statusCode: 404,
+ message: "Certificate object site-cert not found",
+ });
+ });
+
+ it("enqueues a backbone CA rotation request", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("SELECT Id, ObjectName, IsCA FROM TlsCertificates")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: TEST_UUIDS.cert, objectname: "bb-ca", isca: true }],
+ };
+ }
+ if (sql.includes("FOR UPDATE")) {
+ return {
+ rows: [{ id: TEST_UUIDS.cert, isca: true, signedby: "root-ca" }],
+ };
+ }
+ if (sql.includes("WHERE Supercedes = $1")) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("FROM CertificateRequests WHERE Supercedes")) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("FROM Backbones WHERE Certificate")) {
+ return { rowCount: 1, rows: [{ id: "bb-1" }] };
+ }
+ if (sql.includes("INSERT INTO CertificateRequests")) {
+ return { rows: [{ id: "cr-rotate-1" }] };
+ }
+ return { rowCount: 0, rows: [] };
+ });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).resolves.toEqual({ id: TEST_UUIDS.cert });
+ expect(mockClient.query).toHaveBeenCalledWith(
+ expect.stringContaining("'backboneCA'"),
+ expect.arrayContaining(["bb-1", "root-ca", TEST_UUIDS.cert])
+ );
+ expect(TriggerCertificateRenewal).not.toHaveBeenCalled();
+ });
+
+ it("enqueues a van CA rotation request using default CA expiration", async () => {
+ IntervalMilliseconds.mockImplementation((interval) =>
+ interval?.days ? interval.days * 24 * 3600000 : 3600000
+ );
+ mockCaRotationQueries({
+ van: {
+ id: TEST_UUIDS.van,
+ starttime: new Date("2026-01-01T00:00:00Z"),
+ endtime: null,
+ deletedelay: null,
+ bbca: "bb-ca",
+ },
+ });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).resolves.toEqual({ id: TEST_UUIDS.cert });
+ expect(DefaultCaExpiration).toHaveBeenCalled();
+ expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("'vanCA'"), [
+ 720,
+ TEST_UUIDS.van,
+ "bb-ca",
+ TEST_UUIDS.cert,
+ ]);
+ expect(TriggerCertificateRenewal).not.toHaveBeenCalled();
+ });
+
+ it("enqueues a van CA rotation request for the VAN remaining lifetime", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
+ mockCaRotationQueries({
+ van: {
+ id: TEST_UUIDS.van,
+ starttime: new Date("2026-01-01T00:00:00Z"),
+ endtime: new Date("2026-01-02T00:00:00Z"),
+ deletedelay: { hours: 1 },
+ bbca: "bb-ca",
+ },
+ });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).resolves.toEqual({ id: TEST_UUIDS.cert });
+ expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("'vanCA'"), [
+ 25,
+ TEST_UUIDS.van,
+ "bb-ca",
+ TEST_UUIDS.cert,
+ ]);
+ });
+
+ it("uses remaining VAN lifetime on rotate instead of the original span", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
+ mockCaRotationQueries({
+ van: {
+ id: TEST_UUIDS.van,
+ starttime: new Date("2025-01-01T00:00:00Z"),
+ endtime: new Date("2030-01-01T00:00:00Z"),
+ deletedelay: { hours: 1 },
+ bbca: "bb-ca",
+ },
+ });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).resolves.toEqual({ id: TEST_UUIDS.cert });
+ const remainingHours = Math.trunc(
+ (new Date("2030-01-01T00:00:00Z").getTime() -
+ new Date("2026-01-01T00:00:00Z").getTime() +
+ 3600000) /
+ 3600000
+ );
+ const originalSpanHours = Math.trunc(
+ (new Date("2030-01-01T00:00:00Z").getTime() -
+ new Date("2025-01-01T00:00:00Z").getTime() +
+ 3600000) /
+ 3600000
+ );
+ expect(remainingHours).toBeLessThan(originalSpanHours);
+ expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("'vanCA'"), [
+ remainingHours,
+ TEST_UUIDS.van,
+ "bb-ca",
+ TEST_UUIDS.cert,
+ ]);
+ });
+
+ it("floors van CA rotation duration when the VAN end time has passed", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-09-09T00:00:00Z"));
+ mockCaRotationQueries({
+ van: {
+ id: TEST_UUIDS.van,
+ starttime: new Date("2026-01-01T00:00:00Z"),
+ endtime: new Date("2026-01-02T00:00:00Z"),
+ deletedelay: { hours: 1 },
+ bbca: "bb-ca",
+ },
+ });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).resolves.toEqual({ id: TEST_UUIDS.cert });
+ expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("'vanCA'"), [
+ 1,
+ TEST_UUIDS.van,
+ "bb-ca",
+ TEST_UUIDS.cert,
+ ]);
+ });
+
+ it("returns 409 when CA rotation is already in progress", async () => {
+ mockCaRotationQueries({ pending: true, van: { id: TEST_UUIDS.van, bbca: "bb-ca" } });
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).rejects.toMatchObject({
+ statusCode: 409,
+ message: "Certificate rotation already in progress",
+ });
+ expect(certificateRequestInserts()).toHaveLength(0);
+ });
+
+ it("rejects rotation of a CA that is not a backbone or VAN certificate", async () => {
+ mockCaRotationQueries();
+
+ await expect(RotateCertificate(TEST_UUIDS.cert)).rejects.toMatchObject({
+ statusCode: 400,
+ message: "Certificate rotation of this CA is not supported",
+ });
+ });
+});
+
+describe("secret and certificate watches", () => {
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ mockClient.query.mockReset();
+ notifyEvents.length = 0;
+ IntervalMilliseconds.mockImplementation(() => 3600000);
+ for (const key of Object.keys(notificationHandlers)) {
+ delete notificationHandlers[key];
+ }
+ await Start();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("records a new interior-site certificate when the TLS secret is added", async () => {
+ LoadCertificate.mockResolvedValue({
+ status: {
+ notAfter: "2099-01-01T00:00:00Z",
+ renewalTime: "2098-01-01T00:00:00Z",
+ },
+ });
+ mockClient.query.mockImplementation(async (sql) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("FROM CertificateRequests WHERE Id = $1")) {
+ return {
+ rowCount: 1,
+ rows: [
+ {
+ id: "req-1",
+ interiorsite: "site-1",
+ supercedes: null,
+ issuer: "ca-1",
+ },
+ ],
+ };
+ }
+ if (sql.includes("SELECT name FROM InteriorSites")) {
+ return { rows: [{ name: "backbone-site-a" }] };
+ }
+ if (sql.includes("INSERT INTO TlsCertificates")) {
+ return {};
+ }
+ if (sql.includes("UPDATE InteriorSites SET Certificate")) {
+ return {};
+ }
+ if (sql.includes("DELETE FROM CertificateRequests")) {
+ return {};
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ const onSecretWatch = WatchSecrets.mock.calls[0][0];
+ await onSecretWatch("ADDED", {
+ metadata: {
+ name: "vms-site-cert",
+ annotations: {
+ [META_ANNOTATION_VMS_CONTROLLED]: "true",
+ "skupper.io/vms-dblink": "req-1",
+ "skupper.io/vms-issuerlink": "ca-1",
+ },
+ },
+ data: { "tls.crt": "cert" },
+ });
+
+ expect(mockClient.query).toHaveBeenCalledWith(
+ expect.stringContaining("INSERT INTO TlsCertificates"),
+ expect.arrayContaining(["req-1", false, "vms-site-cert"])
+ );
+ expect(mockClient.query).toHaveBeenCalledWith(
+ expect.stringContaining(
+ "UPDATE InteriorSites SET Certificate = $1, Lifecycle = 'ready'"
+ ),
+ ["req-1", "site-1"]
+ );
+ expect(SiteCertificateChanged).toHaveBeenCalledWith("req-1");
+ });
+
+ it("ignores secret add events that are not VMS-controlled", async () => {
+ const onSecretWatch = WatchSecrets.mock.calls[0][0];
+ await onSecretWatch("ADDED", {
+ metadata: { name: "other", annotations: {} },
+ });
+ expect(mockClient.query).not.toHaveBeenCalled();
+ });
+
+ it("persists certificate times from a certificate watch", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("WHERE Supercedes = $1")) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("UPDATE TlsCertificates SET RenewalTime")) {
+ return { rows: [{ id: "cert-1" }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ const onCertificateWatch = WatchCertificates.mock.calls[0][0];
+ await onCertificateWatch("MODIFIED", {
+ metadata: {
+ name: "vms-site-cert",
+ annotations: {
+ [META_ANNOTATION_VMS_CONTROLLED]: "true",
+ "skupper.io/vms-dblink": "cert-1",
+ },
+ },
+ status: {
+ notAfter: "2099-01-01T00:00:00Z",
+ renewalTime: "2098-01-01T00:00:00Z",
+ },
+ });
+
+ expect(mockClient.query).toHaveBeenCalledWith(
+ expect.stringContaining("UPDATE TlsCertificates SET RenewalTime"),
+ [expect.any(Date), expect.any(Date), "cert-1"]
+ );
+ expect(notifyEvents).toContainEqual({
+ method: "update",
+ table: "TlsCertificates",
+ id: "cert-1",
+ });
+ });
+
+ it("enqueues VAN CA and credential children after a backbone CA rotation", async () => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
+ LoadCertificate.mockResolvedValue({
+ status: {
+ notAfter: "2099-01-01T00:00:00Z",
+ renewalTime: "2098-01-01T00:00:00Z",
+ },
+ });
+ const newCaId = "req-bb-ca";
+ mockClient.query.mockImplementation(async (sql, params) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("FROM CertificateRequests WHERE Id = $1")) {
+ return {
+ rowCount: 1,
+ rows: [
+ {
+ id: newCaId,
+ backbone: "bb-1",
+ supercedes: "old-bb-ca",
+ issuer: "root-ca",
+ },
+ ],
+ };
+ }
+ if (sql.includes("SELECT name FROM Backbones")) {
+ return { rows: [{ name: "backbone-a" }] };
+ }
+ if (sql.includes("FOR UPDATE")) {
+ return { rows: [{ id: "old-bb-ca", rotationordinal: 0 }] };
+ }
+ if (sql.includes("INSERT INTO TlsCertificates")) {
+ return {};
+ }
+ if (sql.includes("SET Certificate = $1 WHERE Certificate = $2")) {
+ return { rows: [], rowCount: 0 };
+ }
+ if (sql.includes("DELETE FROM CertificateRequests")) {
+ return {};
+ }
+ if (sql.includes("SELECT Id FROM Backbones WHERE Certificate")) {
+ return { rowCount: 1, rows: [{ id: "bb-1" }] };
+ }
+ if (sql.includes("FROM InteriorSites s")) {
+ return { rows: [{ id: "site-1", certificate: "site-cert-old" }] };
+ }
+ if (sql.includes("FROM BackboneAccessPoints ap")) {
+ return {
+ rows: [
+ {
+ id: "ap-peer",
+ kind: "peer",
+ hostname: "peer.example.com",
+ certificate: "ap-peer-old",
+ },
+ {
+ id: "ap-manage",
+ kind: "manage",
+ hostname: "manage.example.com",
+ certificate: "ap-manage-old",
+ },
+ ],
+ };
+ }
+ if (sql.includes("FROM ApplicationNetworks an") && sql.includes("an.Certificate")) {
+ return {
+ rows: [
+ {
+ id: "van-1",
+ certificate: "van-ca-old",
+ starttime: new Date("2026-01-01T00:00:00Z"),
+ endtime: new Date("2026-01-02T00:00:00Z"),
+ deletedelay: { hours: 1 },
+ },
+ ],
+ };
+ }
+ if (sql.includes("FROM NetworkCredentials cred")) {
+ return { rows: [{ id: "cred-1", certificate: "cred-old" }] };
+ }
+ if (
+ sql.includes("FROM CertificateRequests WHERE Supercedes") ||
+ sql.includes("FROM TlsCertificates WHERE Supercedes")
+ ) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("INSERT INTO CertificateRequests")) {
+ return { rows: [{ id: `cr-${params[0]}-${params[3]}` }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ const onSecretWatch = WatchSecrets.mock.calls[0][0];
+ await onSecretWatch("ADDED", controlledTlsSecret("vms-bb-ca", newCaId, "root"));
+
+ const inserts = certificateRequestInserts();
+ expect(inserts.map(([, insertParams]) => insertParams[0])).toEqual([
+ "interiorRouter",
+ "accessPoint",
+ "vanCA",
+ "vanCredential",
+ "accessPoint",
+ ]);
+ expect(inserts[2][1]).toEqual([
+ "vanCA",
+ expect.any(Date),
+ 25,
+ "van-1",
+ newCaId,
+ "van-ca-old",
+ null,
+ ]);
+ expect(inserts[3][1][0]).toBe("vanCredential");
+ expect(inserts[3][1][3]).toBe("cred-1");
+ expect(ApplyObject).toHaveBeenCalledWith(expect.objectContaining({ kind: "Issuer" }));
+ });
+
+ it("enqueues member site rotations after a van CA secret is added and skips pending children", async () => {
+ LoadCertificate.mockResolvedValue({
+ status: {
+ notAfter: "2099-01-01T00:00:00Z",
+ renewalTime: "2098-01-01T00:00:00Z",
+ },
+ });
+ const newCaId = "req-van-ca";
+ mockClient.query.mockImplementation(async (sql, params) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("FROM CertificateRequests WHERE Id = $1")) {
+ return {
+ rowCount: 1,
+ rows: [
+ {
+ id: newCaId,
+ applicationnetwork: "van-1",
+ supercedes: "old-van-ca",
+ issuer: "bb-ca",
+ },
+ ],
+ };
+ }
+ if (sql.includes("SELECT name FROM ApplicationNetworks")) {
+ return { rows: [{ name: "van-a" }] };
+ }
+ if (sql.includes("FOR UPDATE")) {
+ return { rows: [{ id: "old-van-ca", rotationordinal: 1 }] };
+ }
+ if (sql.includes("INSERT INTO TlsCertificates")) {
+ return {};
+ }
+ if (sql.includes("SET Certificate = $1 WHERE Certificate = $2")) {
+ return { rows: [], rowCount: 0 };
+ }
+ if (sql.includes("DELETE FROM CertificateRequests")) {
+ return {};
+ }
+ if (sql.includes("SELECT Id FROM ApplicationNetworks WHERE Certificate")) {
+ return { rowCount: 1, rows: [{ id: "van-1" }] };
+ }
+ if (sql.includes("FROM MemberSites m")) {
+ return {
+ rows: [
+ { id: "member-pending", certificate: "member-cert-pending" },
+ { id: "member-ready", certificate: "member-cert-ready" },
+ ],
+ };
+ }
+ if (sql.includes("FROM CertificateRequests WHERE Supercedes")) {
+ if (params[0] === "member-cert-pending") {
+ return { rowCount: 1, rows: [{ id: "cr-pending" }] };
+ }
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("FROM TlsCertificates WHERE Supercedes")) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("INSERT INTO CertificateRequests")) {
+ return { rows: [{ id: "cr-van-site" }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ const onSecretWatch = WatchSecrets.mock.calls[0][0];
+ await onSecretWatch("ADDED", controlledTlsSecret("vms-van-ca", newCaId));
+
+ const inserts = certificateRequestInserts();
+ expect(inserts).toHaveLength(1);
+ expect(inserts[0][0]).toContain("Site");
+ expect(inserts[0][1]).toEqual([
+ "vanSite",
+ expect.any(Date),
+ 1,
+ "member-ready",
+ newCaId,
+ "member-cert-ready",
+ null,
+ ]);
+ expect(ApplyObject).toHaveBeenCalledWith(expect.objectContaining({ kind: "Issuer" }));
+ });
+
+ it("notifies sibling leaves when a rotated cert is the last child of the old issuer", async () => {
+ LoadCertificate.mockResolvedValue({
+ status: {
+ notAfter: "2099-01-01T00:00:00Z",
+ renewalTime: "2098-01-01T00:00:00Z",
+ },
+ });
+ const newCertId = "req-leaf";
+ mockClient.query.mockImplementation(async (sql, params) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("FROM CertificateRequests WHERE Id = $1")) {
+ return {
+ rowCount: 1,
+ rows: [
+ {
+ id: newCertId,
+ interiorsite: "site-1",
+ supercedes: "old-leaf",
+ issuer: "new-ca",
+ },
+ ],
+ };
+ }
+ if (sql.includes("SELECT name FROM InteriorSites")) {
+ return { rows: [{ name: "backbone-site-a" }] };
+ }
+ if (sql.includes("FOR UPDATE")) {
+ return { rows: [{ id: "old-leaf", rotationordinal: 0 }] };
+ }
+ if (sql.includes("INSERT INTO TlsCertificates")) {
+ return {};
+ }
+ if (sql.includes("SET Certificate = $1 WHERE Certificate = $2")) {
+ return { rows: [], rowCount: 0 };
+ }
+ if (sql.includes("DELETE FROM CertificateRequests")) {
+ return {};
+ }
+ if (sql.includes("WHERE SignedBy = $1") && sql.includes("LIMIT 1")) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("WHERE SignedBy = $1")) {
+ return {
+ rows: [
+ { id: "sibling-1", isca: false },
+ { id: newCertId, isca: false },
+ ],
+ };
+ }
+ if (sql.includes("FROM TlsCertificates WHERE Id = $1")) {
+ if (params[0] === newCertId) {
+ return { rows: [{ id: newCertId, signedby: "new-ca" }] };
+ }
+ if (params[0] === "new-ca") {
+ return { rows: [{ id: "new-ca", supercedes: "old-ca" }] };
+ }
+ return { rows: [] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ const onSecretWatch = WatchSecrets.mock.calls[0][0];
+ await onSecretWatch("ADDED", controlledTlsSecret("vms-site-cert", newCertId, "new-ca"));
+
+ expect(SiteCertificateChanged).toHaveBeenCalledWith(newCertId);
+ expect(SiteCertificateChanged).toHaveBeenCalledWith("sibling-1");
+ expect(AccessCertificateChanged).toHaveBeenCalledWith("sibling-1");
+ expect(SyncColoTlsCertificate).toHaveBeenCalledWith("sibling-1");
+ expect(ApplyObject).not.toHaveBeenCalled();
+ });
+});
diff --git a/components/management-controller/src/claim-server.js b/components/management-controller/src/claim-server.js
index c9f8384d..020a3e60 100644
--- a/components/management-controller/src/claim-server.js
+++ b/components/management-controller/src/claim-server.js
@@ -28,7 +28,6 @@ import {
META_ANNOTATION_STATE_KEY,
META_ANNOTATION_STATE_HASH,
META_ANNOTATION_STATE_DIR,
- META_ANNOTATION_TLS_INJECT,
INJECT_TYPE_SITE,
META_ANNOTATION_STATE_TYPE,
STATE_TYPE_LINK,
@@ -40,8 +39,9 @@ import { ClientFromPool } from "./db.js";
import { LoadSecret } from "@vms/modules/kube";
import { DispatchMessage, AssertClaimResponseSuccess, ReponseFailure } from "@vms/modules/protocol";
import { RegisterHandler } from "./backbone-links.js";
-import { HashOfData } from "./resource-templates.js";
+import { HashOfData, Secret } from "./resource-templates.js";
import { NotifyTransaction } from "./notify.js";
+import { getTlsRotationMeta, overlayDualTrustCa } from "./tls-rotation.js";
const backbones = {}; // backboneId => {conn: AMQP-Connection, sender: anon-sender, receiver: claim-receiver}
const memberCompletions = {}; // memberId => {handler: completion-function, result: undefined || {}, error: undefined || ERROR }
@@ -75,20 +75,15 @@ const memberCompletion = async function (memberId) {
// Get the member site's siteClient certificate
//
const secret = await LoadSecret(memberSite.objectname);
- siteClient = {
- apiVersion: "v1",
- kind: "Secret",
- data: secret.data,
- metadata: {
- name: `vms-site-${memberId}`,
- annotations: {
- [META_ANNOTATION_STATE_KEY]: `tls-site-${memberId}`,
- [META_ANNOTATION_STATE_HASH]: HashOfData(secret.data),
- [META_ANNOTATION_STATE_DIR]: "remote",
- [META_ANNOTATION_TLS_INJECT]: INJECT_TYPE_SITE,
- },
- },
- };
+ const data = await overlayDualTrustCa(client, memberSite.certificate, secret.data);
+ const tlsMeta = await getTlsRotationMeta(client, memberSite.certificate);
+ siteClient = Secret(
+ { ...secret, data },
+ `vms-site-${memberId}`,
+ INJECT_TYPE_SITE,
+ `tls-site-${memberId}`,
+ tlsMeta
+ );
//
// Gather the edge-link information for the outgoingLinks
@@ -341,4 +336,5 @@ export function _registerMemberCompletionForTest(memberId, { callback } = {}) {
error: undefined,
callback,
};
+ return memberCompletions[memberId];
}
diff --git a/components/management-controller/src/claim-server.test.js b/components/management-controller/src/claim-server.test.js
index 80ff675e..1d35c691 100644
--- a/components/management-controller/src/claim-server.test.js
+++ b/components/management-controller/src/claim-server.test.js
@@ -48,8 +48,20 @@ vi.mock("./notify.js", () => ({
},
}));
+vi.mock("./tls-rotation.js", () => ({
+ overlayDualTrustCa: vi.fn(async (_client, _certId, data) => data),
+ getTlsRotationMeta: vi.fn(async () => ({ ordinal: 2, lastValid: 1 })),
+}));
+
import { LoadSecret } from "@vms/modules/kube";
import { CompleteMember, _registerMemberCompletionForTest } from "./claim-server.js";
+import {
+ INJECT_TYPE_SITE,
+ META_ANNOTATION_TLS_INJECT,
+ META_ANNOTATION_TLS_ORDINAL,
+ META_ANNOTATION_TLS_LAST_VALID,
+ META_ANNOTATION_STATE_KEY,
+} from "@vms/modules/common";
describe("CompleteMember", () => {
it("handles unknown member id without throwing", async () => {
@@ -58,7 +70,7 @@ describe("CompleteMember", () => {
it("stores completion result and invokes callback for pending member", async () => {
const callback = vi.fn();
- _registerMemberCompletionForTest("member-1", { callback });
+ const completion = _registerMemberCompletionForTest("member-1", { callback });
mockClient.query.mockImplementation(async (sql) => {
if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
@@ -94,5 +106,13 @@ describe("CompleteMember", () => {
expect(callback).toHaveBeenCalled();
expect(LoadSecret).toHaveBeenCalledWith("tls-secret");
expect(mockClient.release).toHaveBeenCalled();
+ const siteClient = completion.result[1];
+ expect(siteClient.metadata.name).toBe("vms-site-member-1");
+ expect(siteClient.metadata.annotations[META_ANNOTATION_TLS_INJECT]).toBe(INJECT_TYPE_SITE);
+ expect(siteClient.metadata.annotations[META_ANNOTATION_STATE_KEY]).toBe(
+ "tls-site-member-1"
+ );
+ expect(siteClient.metadata.annotations[META_ANNOTATION_TLS_ORDINAL]).toBe("2");
+ expect(siteClient.metadata.annotations[META_ANNOTATION_TLS_LAST_VALID]).toBe("1");
});
});
diff --git a/components/management-controller/src/colo-sync.js b/components/management-controller/src/colo-sync.js
index 77ba5d58..488373c8 100644
--- a/components/management-controller/src/colo-sync.js
+++ b/components/management-controller/src/colo-sync.js
@@ -29,6 +29,7 @@ import { ClientFromPool } from "./db.js";
import * as resourceTemplates from "./resource-templates.js";
import * as common from "@vms/modules/common";
import { NotifyTransaction, RegisterNotification } from "./notify.js";
+import { getTlsRotationMeta, overlayDualTrustCa } from "./tls-rotation.js";
const coloNamespaces = {}; // {namespace-name: {backbone, site, accesspoint}}
const backbonesWithNoNamespace = [];
@@ -363,25 +364,15 @@ async function doVisitNamespace(ns) {
//
// Ensure that if the site record is in READY or ACTIVE state, the site certificate is installed in namespace (else apply it)
//
- // TODO: Check the contents of the secret to see if it needs to be updated (for certificate rotation)
- //
if (["ready", "active"].includes(coloNamespaces[ns].site.lifecycle)) {
- const siteSecretName = `vms-site-${coloNamespaces[ns].site.id}`;
- const siteSecret = await kube.LoadSecret(siteSecretName, ns);
- if (!siteSecret) {
- const cert = await client
- .query("SELECT objectname FROM TlsCertificates WHERE Id = $1", [
- coloNamespaces[ns].site.certificate,
- ])
- .then((res) => res.rows[0]);
- const secret = await kube.LoadSecret(cert.objectname);
- const resource = resourceTemplates.Secret(
- secret,
- siteSecretName,
- common.INJECT_TYPE_SITE
- );
- await kube.ApplyObject(resource, ns);
- }
+ await syncColoTlsSecret(
+ client,
+ ns,
+ `vms-site-${coloNamespaces[ns].site.id}`,
+ coloNamespaces[ns].site.certificate,
+ common.INJECT_TYPE_SITE,
+ false
+ );
}
//
@@ -417,17 +408,14 @@ async function doVisitNamespace(ns) {
// Ensure that if accesspoint is in READY state, the server certificate is installed in namespace (else apply it)
//
if (coloNamespaces[ns].accesspoint.lifecycle === "ready") {
- const apSecret = await kube.LoadSecret(apSecretName, ns);
- if (!apSecret) {
- const cert = await client
- .query("SELECT objectname FROM TlsCertificates WHERE Id = $1", [
- coloNamespaces[ns].accesspoint.certificate,
- ])
- .then((res) => res.rows[0]);
- const secret = await kube.LoadSecret(cert.objectname);
- const resource = resourceTemplates.Secret(secret, apSecretName);
- await kube.ApplyObject(resource, ns);
- }
+ await syncColoTlsSecret(
+ client,
+ ns,
+ apSecretName,
+ coloNamespaces[ns].accesspoint.certificate,
+ undefined,
+ false
+ );
}
await client.query("COMMIT");
@@ -445,3 +433,98 @@ async function doVisitNamespace(ns) {
client.release();
}
}
+
+function tlsDataHash(data) {
+ if (!data) {
+ return "";
+ }
+ return resourceTemplates.HashOfData({
+ "ca.crt": data["ca.crt"] || "",
+ "tls.crt": data["tls.crt"] || "",
+ "tls.key": data["tls.key"] || "",
+ });
+}
+
+async function syncColoTlsSecret(client, ns, secretName, certId, inject, replaceIfChanged) {
+ if (!certId) {
+ return;
+ }
+ const cert = await client
+ .query("SELECT objectname FROM TlsCertificates WHERE Id = $1", [certId])
+ .then((res) => res.rows[0]);
+ if (!cert?.objectname) {
+ return;
+ }
+ const mcSecret = await kube.LoadSecret(cert.objectname);
+ if (!mcSecret?.data) {
+ return;
+ }
+ const data = await overlayDualTrustCa(client, certId, mcSecret.data);
+ const tlsMeta = await getTlsRotationMeta(client, certId);
+ const resource = resourceTemplates.Secret(
+ { ...mcSecret, data },
+ secretName,
+ inject,
+ undefined,
+ tlsMeta
+ );
+ const coloSecret = await kube.LoadSecret(secretName, ns);
+ if (!coloSecret) {
+ await kube.ApplyObject(resource, ns);
+ return;
+ }
+ if (!replaceIfChanged) {
+ return;
+ }
+ if (tlsDataHash(coloSecret.data) === tlsDataHash(resource.data)) {
+ return;
+ }
+ resource.metadata.resourceVersion = coloSecret.metadata.resourceVersion;
+ await kube.ReplaceSecret(secretName, resource, ns);
+}
+
+export async function SyncColoTlsCertificate(certId) {
+ if (!certId) {
+ return;
+ }
+ const client = await ClientFromPool("system");
+ try {
+ const siteResult = await client.query(
+ "SELECT Id FROM InteriorSites WHERE CoLocated = true AND Certificate = $1",
+ [certId]
+ );
+ if (siteResult.rowCount == 1) {
+ const siteId = siteResult.rows[0].id;
+ const ns = siteIndex[siteId];
+ if (ns && coloNamespaces[ns]?.site) {
+ await syncColoTlsSecret(
+ client,
+ ns,
+ `vms-site-${siteId}`,
+ certId,
+ common.INJECT_TYPE_SITE,
+ true
+ );
+ }
+ return;
+ }
+
+ const apResult = await client.query(
+ "SELECT ap.Id FROM BackboneAccessPoints ap " +
+ "JOIN InteriorSites s ON s.Id = ap.InteriorSite " +
+ "WHERE s.CoLocated = true AND ap.Kind = 'manage' AND ap.Certificate = $1",
+ [certId]
+ );
+ if (apResult.rowCount == 1) {
+ const apId = apResult.rows[0].id;
+ const ns = apIndex[apId];
+ if (ns && coloNamespaces[ns]?.accesspoint) {
+ await syncColoTlsSecret(client, ns, "vms-colo-manage", certId, undefined, true);
+ }
+ }
+ } catch (error) {
+ Log(`Exception in SyncColoTlsCertificate: ${error.stack}`);
+ } finally {
+ client.release();
+ }
+}
diff --git a/components/management-controller/src/colo-sync.test.js b/components/management-controller/src/colo-sync.test.js
index eb0cd1b9..b08c6988 100644
--- a/components/management-controller/src/colo-sync.test.js
+++ b/components/management-controller/src/colo-sync.test.js
@@ -19,30 +19,66 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+const mockClient = {
+ query: vi.fn(),
+ release: vi.fn(),
+};
+
+/** @type {Record} */
+const notificationHandlers = {};
+
vi.mock("@vms/modules/kube", () => ({
GetNamespaces: vi.fn(async () => []),
createNamespace: vi.fn(),
deleteNamespace: vi.fn(),
LoadSecret: vi.fn(),
ApplyObject: vi.fn(),
+ ReplaceSecret: vi.fn(),
+ GetSites: vi.fn(async () => [{ metadata: { name: "colo-site" } }]),
+ LoadRouterAccess: vi.fn(async () => ({ metadata: { name: "vms-colo-manage" } })),
}));
-vi.mock("./notify.js", async (importOriginal) => {
- const actual = await importOriginal();
- return {
- ...actual,
- RegisterNotification: vi.fn(actual.RegisterNotification),
- };
-});
+vi.mock("./db.js", () => ({
+ ClientFromPool: vi.fn(async () => mockClient),
+}));
-import { Start } from "./colo-sync.js";
+vi.mock("./notify.js", () => ({
+ RegisterNotification: vi.fn((tableName, handler) => {
+ notificationHandlers[tableName] = handler;
+ }),
+ NotifyTransaction: class {
+ add() {}
+ update() {}
+ delete() {}
+ async commit() {}
+ },
+}));
+
+vi.mock("./tls-rotation.js", () => ({
+ overlayDualTrustCa: vi.fn(async (_client, _certId, data) => data),
+ getTlsRotationMeta: vi.fn(async () => ({ ordinal: 1, lastValid: 0 })),
+}));
+
+import { Start, SyncColoTlsCertificate } from "./colo-sync.js";
import { RegisterNotification } from "./notify.js";
-import { GetNamespaces } from "@vms/modules/kube";
+import { GetNamespaces, LoadSecret, ApplyObject, ReplaceSecret } from "@vms/modules/kube";
+import { META_ANNOTATION_TLS_ORDINAL } from "@vms/modules/common";
+
+function tlsSecretData(ca = "ca") {
+ return {
+ "ca.crt": Buffer.from(ca).toString("base64"),
+ "tls.crt": Buffer.from("cert").toString("base64"),
+ "tls.key": Buffer.from("key").toString("base64"),
+ };
+}
describe("colo-sync Start", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
+ for (const key of Object.keys(notificationHandlers)) {
+ delete notificationHandlers[key];
+ }
});
afterEach(() => {
@@ -73,6 +109,181 @@ describe("colo-sync Start", () => {
expect.any(Function),
false
);
- expect(vi.getTimerCount()).toBe(2);
+ expect(vi.getTimerCount()).toBe(1);
+ });
+});
+
+describe("SyncColoTlsCertificate", () => {
+ beforeEach(async () => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ for (const key of Object.keys(notificationHandlers)) {
+ delete notificationHandlers[key];
+ }
+ mockClient.query.mockReset();
+ GetNamespaces.mockResolvedValue([
+ {
+ metadata: {
+ name: "colo-ns-1",
+ annotations: { "skupper.io/vms-controlled": "true" },
+ },
+ },
+ ]);
+ await Start();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("does nothing when certId is missing", async () => {
+ await SyncColoTlsCertificate();
+ expect(mockClient.query).not.toHaveBeenCalled();
+ });
+
+ it("applies a missing site TLS secret and replaces it when the payload changes", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql.includes("FROM InteriorSites WHERE CoLocated = true AND Backbone")) {
+ return {
+ rowCount: 1,
+ rows: [
+ {
+ id: "site-1",
+ certificate: "cert-1",
+ lifecycle: "ready",
+ deploymentstate: "deployed",
+ },
+ ],
+ };
+ }
+ if (sql.includes("FROM BackboneAccessPoints WHERE InteriorSite")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: "ap-1", kind: "manage", lifecycle: "partial" }],
+ };
+ }
+ if (sql.includes("FROM InteriorSites WHERE CoLocated = true AND Certificate")) {
+ return { rowCount: 1, rows: [{ id: "site-1" }] };
+ }
+ if (sql.includes("SELECT objectname FROM TlsCertificates")) {
+ return { rows: [{ objectname: "mc-tls-secret" }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ await notificationHandlers.Backbones("EXISTS", "bb-1", "Backbones", {
+ id: "bb-1",
+ colocatednamespace: "colo-ns-1",
+ });
+ LoadSecret.mockImplementation(async (name, ns) => {
+ if (name === "mc-tls-secret") {
+ return { data: tlsSecretData("ca-v1") };
+ }
+ if (name === "vms-site-site-1" && ns === "colo-ns-1") {
+ return undefined;
+ }
+ return undefined;
+ });
+ await notificationHandlers.Backbones("EXISTS_COMPLETE", null, "Backbones");
+
+ expect(ApplyObject).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: "Secret",
+ metadata: expect.objectContaining({
+ name: "vms-site-site-1",
+ annotations: expect.objectContaining({
+ [META_ANNOTATION_TLS_ORDINAL]: "1",
+ }),
+ }),
+ }),
+ "colo-ns-1"
+ );
+
+ ApplyObject.mockClear();
+ LoadSecret.mockImplementation(async (name, ns) => {
+ if (name === "mc-tls-secret") {
+ return { data: tlsSecretData("ca-v2") };
+ }
+ if (name === "vms-site-site-1" && ns === "colo-ns-1") {
+ return {
+ metadata: { resourceVersion: "11" },
+ data: tlsSecretData("ca-v1"),
+ };
+ }
+ return undefined;
+ });
+
+ await SyncColoTlsCertificate("cert-1");
+
+ expect(ReplaceSecret).toHaveBeenCalledWith(
+ "vms-site-site-1",
+ expect.objectContaining({
+ metadata: expect.objectContaining({
+ resourceVersion: "11",
+ annotations: expect.objectContaining({
+ [META_ANNOTATION_TLS_ORDINAL]: "1",
+ }),
+ }),
+ }),
+ "colo-ns-1"
+ );
+ expect(ApplyObject).not.toHaveBeenCalled();
+ });
+
+ it("skips replace when colo TLS data is already current", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql.includes("FROM InteriorSites WHERE CoLocated = true AND Backbone")) {
+ return {
+ rowCount: 1,
+ rows: [
+ {
+ id: "site-1",
+ certificate: "cert-1",
+ lifecycle: "ready",
+ deploymentstate: "deployed",
+ },
+ ],
+ };
+ }
+ if (sql.includes("FROM BackboneAccessPoints WHERE InteriorSite")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: "ap-1", kind: "manage", lifecycle: "partial" }],
+ };
+ }
+ if (sql.includes("FROM InteriorSites WHERE CoLocated = true AND Certificate")) {
+ return { rowCount: 1, rows: [{ id: "site-1" }] };
+ }
+ if (sql.includes("SELECT objectname FROM TlsCertificates")) {
+ return { rows: [{ objectname: "mc-tls-secret" }] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+
+ const data = tlsSecretData("ca-same");
+ LoadSecret.mockResolvedValue({
+ metadata: { resourceVersion: "7" },
+ data,
+ });
+
+ await notificationHandlers.Backbones("EXISTS", "bb-1", "Backbones", {
+ id: "bb-1",
+ colocatednamespace: "colo-ns-1",
+ });
+ await notificationHandlers.Backbones("EXISTS_COMPLETE", null, "Backbones");
+ ApplyObject.mockClear();
+ ReplaceSecret.mockClear();
+
+ LoadSecret.mockImplementation(async (name) => {
+ if (name === "mc-tls-secret") {
+ return { data };
+ }
+ return { metadata: { resourceVersion: "7" }, data };
+ });
+
+ await SyncColoTlsCertificate("cert-1");
+
+ expect(ReplaceSecret).not.toHaveBeenCalled();
+ expect(ApplyObject).not.toHaveBeenCalled();
});
});
diff --git a/components/management-controller/src/mc-apiserver.js b/components/management-controller/src/mc-apiserver.js
index 071765bc..d776e511 100644
--- a/components/management-controller/src/mc-apiserver.js
+++ b/components/management-controller/src/mc-apiserver.js
@@ -43,6 +43,8 @@ import { StartWatchServer } from "./watch-server.js";
import ViteExpress from "vite-express";
import { createManagementOidcAuth } from "./auth/management-oidc.js";
import { NotifyTransaction, RegisterNotification } from "./notify.js";
+import { RotateCertificate } from "./certs.js";
+import { getTlsRotationMeta, overlayDualTrustCa } from "./tls-rotation.js";
const __dirname = import.meta.dirname;
/** Deployed image: sources live in `/app/src`, console bundle in `/app/console/dist`. Monorepo dev: `components/console` (two levels up from `components/management-controller/src`). */
@@ -71,6 +73,12 @@ const vanProxy = {}; // { Id: { vanId, backboneName } }
app.use(sessionParser);
+async function secretResourceForSync(client, certId, secret, profileName, inject, stateKey) {
+ const data = await overlayDualTrustCa(client, certId, secret.data);
+ const tlsMeta = await getTlsRotationMeta(client, certId);
+ return resourceTemplates.Secret({ ...secret, data }, profileName, inject, stateKey, tlsMeta);
+}
+
const link_config_map_yaml = function (name, data) {
const configMap = {
apiVersion: "v1",
@@ -151,7 +159,9 @@ const fetchBackboneSiteSkupper2 = async function (req, res) {
output.push(resourceTemplates.RoleBinding());
output.push(resourceTemplates.Deployment(siteId, true, "sk2"));
output.push(
- resourceTemplates.Secret(
+ await secretResourceForSync(
+ client,
+ site.certificate,
secret,
`vms-site-${siteId}`,
common.INJECT_TYPE_SITE,
@@ -211,7 +221,7 @@ const fetchBackboneAccessPointsKube = async function (req, res) {
const output = [];
const ap_result = await client.query(
- "SELECT TlsCertificates.ObjectName, BackboneAccessPoints.Id as apid, Lifecycle, Kind FROM BackboneAccessPoints " +
+ "SELECT TlsCertificates.Id as certid, TlsCertificates.ObjectName, BackboneAccessPoints.Id as apid, Lifecycle, Kind FROM BackboneAccessPoints " +
"JOIN TlsCertificates ON TlsCertificates.Id = Certificate " +
"WHERE BackboneAccessPoints.InteriorSite = $1",
[bsid]
@@ -224,7 +234,9 @@ const fetchBackboneAccessPointsKube = async function (req, res) {
}
const secret = await LoadSecret(ap.objectname);
output.push(
- resourceTemplates.Secret(
+ await secretResourceForSync(
+ client,
+ ap.certid,
secret,
`vms-access-${ap.apid}`,
common.INJECT_TYPE_ACCESS_POINT,
@@ -274,7 +286,7 @@ const getVanConfigConnecting = async function (req, res) {
try {
const { result, apResult } = await queryWithContext(req, client, async (client) => {
const result = await client.query(
- "SELECT VanId, ObjectName FROM ApplicationNetworks " +
+ "SELECT VanId, ObjectName, TlsCertificates.Id AS certificate FROM ApplicationNetworks " +
"JOIN NetworkCredentials ON NetworkCredentials.MemberOf = ApplicationNetworks.Id " +
"JOIN TlsCertificates ON TlsCertificates.Id = NetworkCredentials.Certificate " +
"WHERE ApplicationNetworks.Id = $1",
@@ -293,10 +305,18 @@ const getVanConfigConnecting = async function (req, res) {
const van = result.rows[0];
const ap = apResult.rows[0];
const secret = await LoadSecret(van.objectname);
+ const data = await overlayDualTrustCa(client, van.certificate, secret.data);
+ const tlsMeta = await getTlsRotationMeta(client, van.certificate);
const output = [
resourceTemplates.NetworkCR(van.vanid),
resourceTemplates.NetworkLinkCR(ap.hostname, ap.port, van.objectname),
- resourceTemplates.Secret(secret, van.objectname),
+ resourceTemplates.Secret(
+ { ...secret, data },
+ van.objectname,
+ undefined,
+ undefined,
+ tlsMeta
+ ),
];
if (exposeNetworkObserverConsole) {
const routingKey = `skupper-console-${van.vanid}`;
@@ -373,12 +393,18 @@ const getCertsSignedBy = async function (req, res) {
if (ca_result.rowCount == 0 || !ca_result.rows[0].isca) {
throw new Error(`signedby certificate is not an issuer`);
}
- return await client.query("SELECT * FROM tlsCertificates WHERE signedBy = $1", [
- ca,
- ]);
+ return await client.query(
+ "SELECT t.*, EXISTS(SELECT 1 FROM TlsCertificates s WHERE s.Supercedes = t.Id) AS superseded " +
+ "FROM tlsCertificates t WHERE signedBy = $1",
+ [ca]
+ );
}
- return await client.query("SELECT * FROM tlsCertificates WHERE signedBy IS NULL");
+ return await client.query(
+ "SELECT t.*, EXISTS(SELECT 1 FROM TlsCertificates s WHERE s.Supercedes = t.Id) AS superseded " +
+ "FROM tlsCertificates t WHERE signedBy IS NULL"
+ );
});
+ res._watch = [{ table: "TlsCertificates" }];
res.status(returnStatus).json(result.rows);
} catch (err) {
returnStatus = 400;
@@ -432,6 +458,21 @@ const getCertDetail = async function (req, res) {
}
};
+const rotateCert = async function (req, res) {
+ try {
+ if (!util.IsValidUuid(req.params.cid)) {
+ throw Object.assign(new Error(`Malformed certificate ID: ${req.params.cid}`), {
+ statusCode: 400,
+ });
+ }
+ const cert = await RotateCertificate(req.params.cid);
+ res.status(202).json(cert);
+ } catch (err) {
+ const returnStatus = err.statusCode || 400;
+ res.status(returnStatus).send(err.message);
+ }
+};
+
export async function AddHostToAccessPoint(req, siteId, apid, hostname, port) {
let retval = 1;
const client = await ClientFromPool();
@@ -649,6 +690,14 @@ export async function Initialize(router, auth) {
}
);
+ router.post(
+ API_PREFIX + "certs/:cid/rotate",
+ auth.protect("realm:certificate-manager"),
+ async (req, res) => {
+ await rotateCert(req, res);
+ }
+ );
+
router.get(API_PREFIX + "user/profile", auth.protect(), async (req, res) => {
await getUserProfile(req, res);
});
diff --git a/components/management-controller/src/mc-apiserver.test.js b/components/management-controller/src/mc-apiserver.test.js
index c0c0bb9c..76843600 100644
--- a/components/management-controller/src/mc-apiserver.test.js
+++ b/components/management-controller/src/mc-apiserver.test.js
@@ -21,6 +21,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import request from "supertest";
import { createMockClient, TEST_UUIDS } from "./test-helpers/mock-db.js";
import { buildApiApp } from "./test-helpers/build-api-app.js";
+import { RotateCertificate } from "./certs.js";
+import { LoadSecret } from "@vms/modules/kube";
+import { getTlsRotationMeta, overlayDualTrustCa } from "./tls-rotation.js";
+import { META_ANNOTATION_TLS_LAST_VALID, META_ANNOTATION_TLS_ORDINAL } from "@vms/modules/common";
const mockClient = createMockClient();
let mockFormFields = {};
@@ -43,6 +47,14 @@ vi.mock("./watch-server.js", () => ({
WatchNotify: vi.fn(),
}));
+vi.mock("./certs.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ RotateCertificate: vi.fn(),
+ };
+});
+
vi.mock("./sync-management.js", async (importOriginal) => {
const actual = await importOriginal();
return {
@@ -58,9 +70,28 @@ vi.mock("./db.js", async (importOriginal) => {
};
});
+vi.mock("@vms/modules/kube", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ LoadSecret: vi.fn(),
+ };
+});
+
+vi.mock("./tls-rotation.js", async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ overlayDualTrustCa: vi.fn(actual.overlayDualTrustCa),
+ getTlsRotationMeta: vi.fn(actual.getTlsRotationMeta),
+ };
+});
+
describe("mc-apiserver routes", () => {
beforeEach(() => {
vi.clearAllMocks();
+ overlayDualTrustCa.mockImplementation(async (_client, _id, data) => data);
+ getTlsRotationMeta.mockResolvedValue({ ordinal: 0, lastValid: 0 });
mockClient.query.mockImplementation(async (sql) => {
if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
return {};
@@ -113,6 +144,29 @@ describe("mc-apiserver routes", () => {
],
};
}
+ if (sql.includes("AS superseded")) {
+ return {
+ rows: [{ id: TEST_UUIDS.cert, superseded: false }],
+ };
+ }
+ if (sql.includes("TlsCertificates.Id AS certificate FROM ApplicationNetworks")) {
+ return {
+ rowCount: 1,
+ rows: [
+ {
+ vanid: "van-network-id",
+ objectname: "van-cred-secret",
+ certificate: TEST_UUIDS.cert,
+ },
+ ],
+ };
+ }
+ if (sql.includes("SELECT hostname, port FROM BackboneAccessPoints")) {
+ return {
+ rowCount: 1,
+ rows: [{ hostname: "edge.example.com", port: 443 }],
+ };
+ }
return { rows: [], rowCount: 0 };
});
});
@@ -230,4 +284,114 @@ describe("mc-apiserver routes", () => {
expect(res.body).toEqual({ processed: 1 });
});
+
+ it("GET /certs returns certificates with a superseded flag", async () => {
+ const { app } = await buildApiApp({
+ includeAdmin: false,
+ includeUser: false,
+ includeMcRoutes: true,
+ });
+
+ const res = await request(app)
+ .get("/api/v1alpha1/certs")
+ .set("x-test-auth", "1")
+ .expect(200);
+
+ expect(res.body).toEqual([{ id: TEST_UUIDS.cert, superseded: false }]);
+ expect(mockClient.query).toHaveBeenCalledWith(expect.stringContaining("AS superseded"));
+ });
+
+ it("POST /certs/:cid/rotate returns 202", async () => {
+ RotateCertificate.mockResolvedValue({ id: TEST_UUIDS.cert });
+ const { app } = await buildApiApp({
+ includeAdmin: false,
+ includeUser: false,
+ includeMcRoutes: true,
+ });
+
+ const res = await request(app)
+ .post(`/api/v1alpha1/certs/${TEST_UUIDS.cert}/rotate`)
+ .set("x-test-auth", "1")
+ .expect(202);
+
+ expect(res.body).toEqual({ id: TEST_UUIDS.cert });
+ expect(RotateCertificate).toHaveBeenCalledWith(TEST_UUIDS.cert);
+ });
+
+ it("POST /certs/:cid/rotate rejects a malformed id", async () => {
+ const { app } = await buildApiApp({
+ includeAdmin: false,
+ includeUser: false,
+ includeMcRoutes: true,
+ });
+
+ const res = await request(app)
+ .post("/api/v1alpha1/certs/not-a-uuid/rotate")
+ .set("x-test-auth", "1")
+ .expect(400);
+
+ expect(res.text).toContain("Malformed certificate ID");
+ expect(RotateCertificate).not.toHaveBeenCalled();
+ });
+
+ it("POST /certs/:cid/rotate forwards certificate-manager errors", async () => {
+ RotateCertificate.mockRejectedValue(
+ Object.assign(new Error("Certificate has been superseded"), { statusCode: 409 })
+ );
+ const { app } = await buildApiApp({
+ includeAdmin: false,
+ includeUser: false,
+ includeMcRoutes: true,
+ });
+
+ const res = await request(app)
+ .post(`/api/v1alpha1/certs/${TEST_UUIDS.cert}/rotate`)
+ .set("x-test-auth", "1")
+ .expect(409);
+
+ expect(res.text).toBe("Certificate has been superseded");
+ });
+
+ it("GET /vans/:vid/config/connecting/:apid overlays dual-trust CA and TLS rotation metadata", async () => {
+ LoadSecret.mockResolvedValue({
+ data: {
+ "ca.crt": "b2xkLWNh",
+ "tls.crt": "Y2VydA==",
+ "tls.key": "a2V5",
+ },
+ metadata: { name: "van-cred-secret" },
+ });
+ overlayDualTrustCa.mockResolvedValue({
+ "ca.crt": "ZHVhbC10cnVzdA==",
+ "tls.crt": "Y2VydA==",
+ "tls.key": "a2V5",
+ });
+ getTlsRotationMeta.mockResolvedValue({ ordinal: 3, lastValid: 1 });
+
+ const { app } = await buildApiApp({
+ includeAdmin: false,
+ includeUser: false,
+ includeMcRoutes: true,
+ });
+
+ const res = await request(app)
+ .get(`/api/v1alpha1/vans/${TEST_UUIDS.van}/config/connecting/${TEST_UUIDS.accessPoint}`)
+ .set("x-test-auth", "1")
+ .expect(200);
+
+ expect(LoadSecret).toHaveBeenCalledWith("van-cred-secret");
+ expect(overlayDualTrustCa).toHaveBeenCalledWith(
+ mockClient,
+ TEST_UUIDS.cert,
+ expect.objectContaining({ "ca.crt": "b2xkLWNh" })
+ );
+ expect(getTlsRotationMeta).toHaveBeenCalledWith(mockClient, TEST_UUIDS.cert);
+ expect(res.text).toContain("van-network-id");
+ expect(res.text).toContain("edge.example.com");
+ expect(res.text).toContain("ZHVhbC10cnVzdA==");
+ expect(res.text).toContain(META_ANNOTATION_TLS_ORDINAL);
+ expect(res.text).toContain(META_ANNOTATION_TLS_LAST_VALID);
+ expect(res.text).toContain("3");
+ expect(res.text).toContain("1");
+ });
});
diff --git a/components/management-controller/src/prune.js b/components/management-controller/src/prune.js
index 94d119a5..266c113d 100644
--- a/components/management-controller/src/prune.js
+++ b/components/management-controller/src/prune.js
@@ -31,48 +31,65 @@ import { Log } from "@vms/modules/log";
import { META_ANNOTATION_VMS_CONTROLLED } from "@vms/modules/common";
import { ClientFromPool } from "./db.js";
import { NotifyTransaction } from "./notify.js";
+import {
+ deleteExpiredSupersededCertificates,
+ TLS_CERTIFICATE_PARENT_TABLES,
+} from "./tls-rotation.js";
+import { AccessCertificateChanged, SiteCertificateChanged } from "./sync-management.js";
+import { SyncColoTlsCertificate } from "./colo-sync.js";
const reconcileCertificates = async function () {
const client = await ClientFromPool("system");
try {
const result = await client.query("SELECT ObjectName FROM TlsCertificates");
- const db_cert_names = [];
- result.rows.forEach((row) => {
- db_cert_names.push(row.objectname);
- });
+ const db_cert_names = new Set(result.rows.map((row) => row.objectname).filter(Boolean));
const issuer_list = await GetIssuers();
- issuer_list.forEach((issuer) => {
+ for (const issuer of issuer_list || []) {
if (
- !db_cert_names.includes(issuer.metadata.name) &&
- issuer.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true"
+ issuer.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true" &&
+ !db_cert_names.has(issuer.metadata.name)
) {
- DeleteIssuer(issuer.metadata.name);
- Log(` Deleted issuer: ${issuer.metadata.name}`);
+ try {
+ await DeleteIssuer(issuer.metadata.name);
+ Log(` Deleted issuer: ${issuer.metadata.name}`);
+ } catch (error) {
+ Log(`WARN: Failed to delete issuer ${issuer.metadata.name}: ${error.message}`);
+ }
}
- });
+ }
const cert_list = await GetCertificates();
- cert_list.forEach((cert) => {
+ for (const cert of cert_list || []) {
if (
- !db_cert_names.includes(cert.metadata.name) &&
- cert.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true"
+ cert.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true" &&
+ !db_cert_names.has(cert.metadata.name)
) {
- DeleteCertificate(cert.metadata.name);
- Log(` Deleted certificate: ${cert.metadata.name}`);
+ try {
+ await DeleteCertificate(cert.metadata.name);
+ Log(` Deleted certificate: ${cert.metadata.name}`);
+ } catch (error) {
+ Log(
+ `WARN: Failed to delete certificate ${cert.metadata.name}: ${error.message}`
+ );
+ }
}
- });
+ }
const secret_list = await GetSecrets();
- secret_list.forEach((secret) => {
+ for (const secret of secret_list || []) {
if (
- !db_cert_names.includes(secret.metadata.name) &&
- secret.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true"
+ secret.metadata.annotations?.[META_ANNOTATION_VMS_CONTROLLED] == "true" &&
+ !db_cert_names.has(secret.metadata.name)
) {
- DeleteSecret(secret.metadata.name);
- Log(` Deleted secret: ${secret.metadata.name}`);
+ try {
+ await DeleteSecret(secret.metadata.name);
+ Log(` Deleted secret: ${secret.metadata.name}`);
+ } catch (error) {
+ Log(`WARN: Failed to delete secret ${secret.metadata.name}: ${error.message}`);
+ }
}
- });
+ }
} catch (error) {
Log(`Exception in reconcileCertificates: ${error.stack}`);
} finally {
@@ -85,8 +102,14 @@ export async function DeleteOrphanCertificates() {
const notify = new NotifyTransaction();
try {
await client.query("BEGIN");
+ const expiredObjectNames = await deleteExpiredSupersededCertificates(client, notify);
const deleteMap = {};
- const tlsResult = await client.query("SELECT Id, SignedBy FROM TlsCertificates");
+ const tlsResult = await client.query(
+ "SELECT Id, SignedBy, Supercedes FROM TlsCertificates"
+ );
+ const referencedAsPredecessor = new Set(
+ tlsResult.rows.map((row) => row.supercedes).filter(Boolean)
+ );
for (const tlsRow of tlsResult.rows) {
if (tlsRow.signedby) {
if (!deleteMap[tlsRow.signedby]) {
@@ -107,16 +130,7 @@ export async function DeleteOrphanCertificates() {
}
}
- for (const table of [
- "ManagementControllers",
- "Backbones",
- "BackboneAccessPoints",
- "InteriorSites",
- "ApplicationNetworks",
- "NetworkCredentials",
- "MemberInvitations",
- "MemberSites",
- ]) {
+ for (const table of TLS_CERTIFICATE_PARENT_TABLES) {
const result = await client.query(`SELECT Id, Certificate FROM ${table}`);
for (const row of result.rows) {
if (row.certificate) {
@@ -129,6 +143,12 @@ export async function DeleteOrphanCertificates() {
}
}
+ for (const certId of referencedAsPredecessor) {
+ if (deleteMap[certId]) {
+ deleteMap[certId].pleaseDelete = false;
+ }
+ }
+
const depthFirstDelete = async function (client, notify, certId) {
const record = deleteMap[certId];
for (const childId of record.children) {
@@ -148,6 +168,7 @@ export async function DeleteOrphanCertificates() {
await client.query("COMMIT");
await notify.commit();
+ return expiredObjectNames;
} catch (error) {
await client.query("ROLLBACK");
Log(`Exception in DeleteOrphanCertificates: ${error.message}`);
@@ -157,8 +178,36 @@ export async function DeleteOrphanCertificates() {
}
}
+async function advertiseTlsLastValid(objectNames) {
+ const names = [...new Set((objectNames || []).filter(Boolean))];
+ if (names.length == 0) {
+ return;
+ }
+ const client = await ClientFromPool("system");
+ try {
+ for (const objectName of names) {
+ const result = await client.query(
+ "SELECT c.Id FROM TlsCertificates c " +
+ "WHERE c.ObjectName = $1 " +
+ "AND NOT EXISTS (SELECT 1 FROM TlsCertificates s WHERE s.Supercedes = c.Id) " +
+ "ORDER BY c.RotationOrdinal DESC LIMIT 1",
+ [objectName]
+ );
+ const certId = result.rows[0]?.id;
+ if (certId) {
+ await SiteCertificateChanged(certId);
+ await AccessCertificateChanged(certId);
+ await SyncColoTlsCertificate(certId);
+ }
+ }
+ } finally {
+ client.release();
+ }
+}
+
export async function Start() {
Log("[Prune - Reconciling Kubernetes objects to the database]");
- await DeleteOrphanCertificates();
+ const expiredObjectNames = await DeleteOrphanCertificates();
await reconcileCertificates();
+ await advertiseTlsLastValid(expiredObjectNames);
}
diff --git a/components/management-controller/src/prune.test.js b/components/management-controller/src/prune.test.js
index 8e1a80be..1e0b17b1 100644
--- a/components/management-controller/src/prune.test.js
+++ b/components/management-controller/src/prune.test.js
@@ -18,6 +18,7 @@
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
+import { META_ANNOTATION_VMS_CONTROLLED } from "@vms/modules/common";
const mockClient = {
query: vi.fn(),
@@ -35,17 +36,52 @@ vi.mock("./notify.js", () => ({
},
}));
-import { DeleteOrphanCertificates } from "./prune.js";
+vi.mock("@vms/modules/kube", () => ({
+ GetIssuers: vi.fn(async () => []),
+ GetCertificates: vi.fn(async () => []),
+ GetSecrets: vi.fn(async () => []),
+ DeleteIssuer: vi.fn(),
+ DeleteCertificate: vi.fn(),
+ DeleteSecret: vi.fn(),
+}));
+
+vi.mock("./sync-management.js", () => ({
+ SiteCertificateChanged: vi.fn(),
+ AccessCertificateChanged: vi.fn(),
+}));
+
+vi.mock("./colo-sync.js", () => ({
+ SyncColoTlsCertificate: vi.fn(),
+}));
+
+import { DeleteOrphanCertificates, Start } from "./prune.js";
+import {
+ GetIssuers,
+ GetCertificates,
+ GetSecrets,
+ DeleteIssuer,
+ DeleteCertificate,
+ DeleteSecret,
+} from "@vms/modules/kube";
+import { SiteCertificateChanged, AccessCertificateChanged } from "./sync-management.js";
+import { SyncColoTlsCertificate } from "./colo-sync.js";
+
+function transactionSql(sql) {
+ return sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK";
+}
describe("DeleteOrphanCertificates", () => {
beforeEach(() => {
vi.clearAllMocks();
mockClient.query.mockImplementation(async (sql) => {
- if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
+ if (transactionSql(sql)) {
return {};
}
- if (sql.includes("SELECT Id, SignedBy FROM TlsCertificates")) {
- return { rows: [{ id: "orphan-cert", signedby: null }] };
+ if (sql.includes("Expiration < CURRENT_TIMESTAMP")) {
+ return { rows: [] };
+ }
+ if (sql.includes("SELECT Id, SignedBy, Supercedes FROM TlsCertificates")) {
+ return { rows: [{ id: "orphan-cert", signedby: null, supercedes: null }] };
}
if (sql.includes("SELECT Id, Certificate FROM")) {
return { rows: [] };
@@ -65,4 +101,146 @@ describe("DeleteOrphanCertificates", () => {
]);
expect(mockClient.release).toHaveBeenCalled();
});
+
+ it("keeps certificates that are still referenced as rotation predecessors", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("Expiration < CURRENT_TIMESTAMP")) {
+ return { rows: [] };
+ }
+ if (sql.includes("SELECT Id, SignedBy, Supercedes FROM TlsCertificates")) {
+ return {
+ rows: [
+ { id: "old-cert", signedby: null, supercedes: null },
+ { id: "new-cert", signedby: null, supercedes: "old-cert" },
+ ],
+ };
+ }
+ if (sql.includes("SELECT Id, Certificate FROM InteriorSites")) {
+ return { rows: [{ id: "site-1", certificate: "new-cert" }] };
+ }
+ if (sql.includes("SELECT Id, Certificate FROM")) {
+ return { rows: [] };
+ }
+ return { rows: [] };
+ });
+
+ await DeleteOrphanCertificates();
+
+ expect(mockClient.query).not.toHaveBeenCalledWith(
+ "DELETE FROM TlsCertificates WHERE Id = $1",
+ ["old-cert"]
+ );
+ expect(mockClient.query).not.toHaveBeenCalledWith(
+ "DELETE FROM TlsCertificates WHERE Id = $1",
+ ["new-cert"]
+ );
+ });
+
+ it("deletes expired superseded certificates before orphan detection", async () => {
+ mockClient.query.mockImplementation(async (sql) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("Expiration < CURRENT_TIMESTAMP")) {
+ return { rows: [{ id: "expired-old", objectname: "tls-site" }] };
+ }
+ if (sql.includes("SELECT Id, SignedBy, Supercedes FROM TlsCertificates")) {
+ return { rows: [{ id: "current-cert", signedby: null, supercedes: null }] };
+ }
+ if (sql.includes("SELECT Id, Certificate FROM InteriorSites")) {
+ return { rows: [{ id: "site-1", certificate: "current-cert" }] };
+ }
+ if (sql.includes("SELECT Id, Certificate FROM")) {
+ return { rows: [] };
+ }
+ return { rows: [], rowCount: 1 };
+ });
+
+ const expiredNames = await DeleteOrphanCertificates();
+
+ expect(expiredNames).toEqual(["tls-site"]);
+ expect(mockClient.query).toHaveBeenCalledWith(
+ "UPDATE TlsCertificates SET Supercedes = NULL WHERE Supercedes = $1",
+ ["expired-old"]
+ );
+ expect(mockClient.query).toHaveBeenCalledWith("DELETE FROM TlsCertificates WHERE Id = $1", [
+ "expired-old",
+ ]);
+ });
+});
+
+describe("Start", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockClient.query.mockImplementation(async (sql) => {
+ if (transactionSql(sql)) {
+ return {};
+ }
+ if (sql.includes("Expiration < CURRENT_TIMESTAMP")) {
+ return { rows: [{ id: "expired-old", objectname: "tls-site" }] };
+ }
+ if (sql.includes("SELECT Id, SignedBy, Supercedes FROM TlsCertificates")) {
+ return { rows: [{ id: "current-cert", signedby: null, supercedes: null }] };
+ }
+ if (sql.includes("SELECT Id, Certificate FROM InteriorSites")) {
+ return { rows: [{ id: "site-1", certificate: "current-cert" }] };
+ }
+ if (sql.includes("SELECT ObjectName FROM TlsCertificates")) {
+ return { rows: [{ objectname: "keep-me" }] };
+ }
+ if (sql.includes("SELECT c.Id FROM TlsCertificates c")) {
+ return { rows: [{ id: "current-cert" }] };
+ }
+ if (sql.includes("SELECT Id, Certificate FROM")) {
+ return { rows: [] };
+ }
+ return { rows: [], rowCount: 1 };
+ });
+ GetIssuers.mockResolvedValue([
+ {
+ metadata: {
+ name: "orphan-issuer",
+ annotations: { [META_ANNOTATION_VMS_CONTROLLED]: "true" },
+ },
+ },
+ {
+ metadata: {
+ name: "keep-me",
+ annotations: { [META_ANNOTATION_VMS_CONTROLLED]: "true" },
+ },
+ },
+ ]);
+ GetCertificates.mockResolvedValue([
+ {
+ metadata: {
+ name: "orphan-cert",
+ annotations: { [META_ANNOTATION_VMS_CONTROLLED]: "true" },
+ },
+ },
+ ]);
+ GetSecrets.mockResolvedValue([
+ {
+ metadata: {
+ name: "orphan-secret",
+ annotations: { [META_ANNOTATION_VMS_CONTROLLED]: "true" },
+ },
+ },
+ ]);
+ DeleteIssuer.mockRejectedValueOnce(new Error("issuer busy"));
+ });
+
+ it("reconciles kube objects and advertises lastValid after expired predecessor deletion", async () => {
+ await Start();
+
+ expect(DeleteIssuer).toHaveBeenCalledWith("orphan-issuer");
+ expect(DeleteIssuer).not.toHaveBeenCalledWith("keep-me");
+ expect(DeleteCertificate).toHaveBeenCalledWith("orphan-cert");
+ expect(DeleteSecret).toHaveBeenCalledWith("orphan-secret");
+ expect(SiteCertificateChanged).toHaveBeenCalledWith("current-cert");
+ expect(AccessCertificateChanged).toHaveBeenCalledWith("current-cert");
+ expect(SyncColoTlsCertificate).toHaveBeenCalledWith("current-cert");
+ });
});
diff --git a/components/management-controller/src/resource-templates.js b/components/management-controller/src/resource-templates.js
index 5eb1144d..8b6c7f22 100644
--- a/components/management-controller/src/resource-templates.js
+++ b/components/management-controller/src/resource-templates.js
@@ -22,6 +22,8 @@
import {
META_ANNOTATION_VMS_CONTROLLED,
META_ANNOTATION_TLS_INJECT,
+ META_ANNOTATION_TLS_ORDINAL,
+ META_ANNOTATION_TLS_LAST_VALID,
META_ANNOTATION_STATE_TYPE,
META_ANNOTATION_STATE_ID,
META_ANNOTATION_STATE_DIR,
@@ -51,6 +53,21 @@ export function HashOfData(data) {
return createHash("sha1").update(text).digest("hex");
}
+export function tlsSyncData(secretData, tlsMeta) {
+ if (tlsMeta?.ordinal === undefined || tlsMeta.ordinal === null) {
+ return secretData;
+ }
+ return {
+ ...secretData,
+ ordinal: String(tlsMeta.ordinal),
+ lastValid: String(tlsMeta.lastValid),
+ };
+}
+
+export function HashOfTlsPayload(secretData, tlsMeta) {
+ return HashOfData(tlsSyncData(secretData, tlsMeta));
+}
+
export function HashOfSecret(secret) {
return HashOfData(secret.data);
}
@@ -296,7 +313,7 @@ export function InterNetworkIngressCR(name, routingKey, networkLink = "", networ
return ingress;
}
-export function Secret(certificate, profile_name, inject, stateKey) {
+export function Secret(certificate, profile_name, inject, stateKey, tlsMeta) {
const secret = {
apiVersion: "v1",
kind: "Secret",
@@ -313,10 +330,17 @@ export function Secret(certificate, profile_name, inject, stateKey) {
if (inject) {
secret.metadata.annotations[META_ANNOTATION_TLS_INJECT] = inject;
}
+ if (tlsMeta?.ordinal !== undefined && tlsMeta.ordinal !== null) {
+ secret.metadata.annotations[META_ANNOTATION_TLS_ORDINAL] = String(tlsMeta.ordinal);
+ secret.metadata.annotations[META_ANNOTATION_TLS_LAST_VALID] = String(tlsMeta.lastValid);
+ }
if (stateKey) {
secret.metadata.annotations[META_ANNOTATION_STATE_DIR] = "remote";
secret.metadata.annotations[META_ANNOTATION_STATE_KEY] = stateKey;
- secret.metadata.annotations[META_ANNOTATION_STATE_HASH] = HashOfSecret(secret);
+ secret.metadata.annotations[META_ANNOTATION_STATE_HASH] = HashOfTlsPayload(
+ secret.data,
+ tlsMeta
+ );
}
return secret;
diff --git a/components/management-controller/src/resource-templates.test.js b/components/management-controller/src/resource-templates.test.js
index cc96bca3..6575f908 100644
--- a/components/management-controller/src/resource-templates.test.js
+++ b/components/management-controller/src/resource-templates.test.js
@@ -27,12 +27,23 @@ import {
HashOfData,
HashOfConfigMap,
HashOfObjectNoChildren,
+ HashOfTlsPayload,
+ tlsSyncData,
+ Secret,
BackboneSite,
NetworkCR,
NetworkLinkCR,
AccessPointCR,
Deployment,
} from "./resource-templates.js";
+import {
+ META_ANNOTATION_TLS_INJECT,
+ META_ANNOTATION_TLS_ORDINAL,
+ META_ANNOTATION_TLS_LAST_VALID,
+ META_ANNOTATION_STATE_HASH,
+ META_ANNOTATION_STATE_KEY,
+ INJECT_TYPE_SITE,
+} from "@vms/modules/common";
describe("resource-templates", () => {
it("HashOfData is stable regardless of key order", () => {
@@ -52,6 +63,46 @@ describe("resource-templates", () => {
expect(hash).toBe(HashOfData({ name: "site" }));
});
+ it("tlsSyncData embeds ordinal metadata in the hashed payload", () => {
+ const data = { "tls.crt": "cert" };
+ expect(tlsSyncData(data)).toBe(data);
+ expect(tlsSyncData(data, { lastValid: 0 })).toBe(data);
+ expect(tlsSyncData(data, { ordinal: 2, lastValid: 1 })).toEqual({
+ "tls.crt": "cert",
+ ordinal: "2",
+ lastValid: "1",
+ });
+ });
+
+ it("HashOfTlsPayload matches HashOfData of the sync payload", () => {
+ const data = { "tls.crt": "cert" };
+ expect(HashOfTlsPayload(data)).toBe(HashOfData(data));
+ expect(HashOfTlsPayload(data, { ordinal: 3, lastValid: 1 })).toBe(
+ HashOfData({ "tls.crt": "cert", ordinal: "3", lastValid: "1" })
+ );
+ expect(HashOfTlsPayload(data, { ordinal: 3, lastValid: 1 })).not.toBe(HashOfData(data));
+ });
+
+ it("Secret annotates rotation metadata and hashes the TLS payload", () => {
+ const tlsMeta = { ordinal: 2, lastValid: 0 };
+ const secret = Secret(
+ { data: { "tls.crt": "cert", "tls.key": "key" } },
+ "vms-site-1",
+ INJECT_TYPE_SITE,
+ "tls-site-1",
+ tlsMeta
+ );
+
+ expect(secret.kind).toBe("Secret");
+ expect(secret.metadata.annotations[META_ANNOTATION_TLS_INJECT]).toBe(INJECT_TYPE_SITE);
+ expect(secret.metadata.annotations[META_ANNOTATION_TLS_ORDINAL]).toBe("2");
+ expect(secret.metadata.annotations[META_ANNOTATION_TLS_LAST_VALID]).toBe("0");
+ expect(secret.metadata.annotations[META_ANNOTATION_STATE_KEY]).toBe("tls-site-1");
+ expect(secret.metadata.annotations[META_ANNOTATION_STATE_HASH]).toBe(
+ HashOfTlsPayload(secret.data, tlsMeta)
+ );
+ });
+
it("BackboneSite produces expected CR shape", () => {
const site = BackboneSite("backbone-a", "site-uuid-123");
expect(site.kind).toBe("Site");
diff --git a/components/management-controller/src/sync-management.js b/components/management-controller/src/sync-management.js
index fc6a7517..6c74d83b 100644
--- a/components/management-controller/src/sync-management.js
+++ b/components/management-controller/src/sync-management.js
@@ -41,9 +41,10 @@ import {
DeletePeer,
} from "@vms/modules/state-sync";
import { RegisterHandler } from "./backbone-links.js";
-import { HashOfSecret, HashOfData } from "./resource-templates.js";
+import { HashOfData, HashOfTlsPayload, tlsSyncData } from "./resource-templates.js";
import { SiteLifecycleChanged_TX } from "./site-deployment-state.js";
import { NotifyTransaction, RegisterNotification } from "./notify.js";
+import { getTlsRotationMeta, overlayDualTrustCa } from "./tls-rotation.js";
const peers = {}; // {peerId: {pClass: <>, stuff}}
@@ -90,6 +91,12 @@ export async function GetBackboneAccessPoints_TX(client, siteId, initialOnly = f
return data;
}
+async function hashedTlsState(client, certId, secret) {
+ const data = await overlayDualTrustCa(client, certId, secret.data);
+ const tlsMeta = await getTlsRotationMeta(client, certId);
+ return [HashOfTlsPayload(data, tlsMeta), tlsSyncData(data, tlsMeta)];
+}
+
//=========================================================================================================================
// Backbone Site Handlers
//=========================================================================================================================
@@ -132,7 +139,8 @@ async function onNewBackboneSite(peerId) {
if (!site.colocated) {
// Don't sync the site secret to colocated sites.
const secret = await LoadSecret(site.objectname);
- localState[`tls-site-${peerId}`] = HashOfSecret(secret);
+ const [siteHash] = await hashedTlsState(client, site.certificate, secret);
+ localState[`tls-site-${peerId}`] = siteHash;
} else {
// Do sync the list of managed VANs on the site's backbone
const vanResult = await client.query(
@@ -170,7 +178,7 @@ async function onNewBackboneSite(peerId) {
}
if (accessPoint.lifecycle == "ready") {
const tlsResult = await client.query(
- "SELECT ObjectName FROM TlsCertificates WHERE Id = $1",
+ "SELECT Id, ObjectName FROM TlsCertificates WHERE Id = $1",
[accessPoint.certificate]
);
if (tlsResult.rowCount != 1) {
@@ -179,7 +187,8 @@ async function onNewBackboneSite(peerId) {
);
}
const secret = await LoadSecret(tlsResult.rows[0].objectname);
- localState[`tls-server-${accessPoint.id}`] = HashOfSecret(secret);
+ const [apHash] = await hashedTlsState(client, tlsResult.rows[0].id, secret);
+ localState[`tls-server-${accessPoint.id}`] = apHash;
remoteState[`accessstatus-${accessPoint.id}`] = HashOfData({
host: accessPoint.hostname,
port: accessPoint.port,
@@ -287,15 +296,14 @@ async function getStateTlsBackboneSite(siteId) {
try {
await client.query("BEGIN");
const result = await client.query(
- "SELECT TlsCertificates.ObjectName FROM InteriorSites " +
+ "SELECT TlsCertificates.Id, TlsCertificates.ObjectName FROM InteriorSites " +
"JOIN TlsCertificates ON TlsCertificates.Id = Certificate " +
"WHERE InteriorSites.Id = $1",
[siteId]
);
if (result.rowCount == 1) {
const secret = await LoadSecret(result.rows[0].objectname);
- hash = HashOfSecret(secret);
- data = secret.data;
+ [hash, data] = await hashedTlsState(client, result.rows[0].id, secret);
}
await client.query("COMMIT");
} catch (error) {
@@ -315,15 +323,14 @@ async function getStateTlsMemberSite(siteId) {
try {
await client.query("BEGIN");
const result = await client.query(
- "SELECT TlsCertificates.ObjectName FROM MemberSites " +
+ "SELECT TlsCertificates.Id, TlsCertificates.ObjectName FROM MemberSites " +
"JOIN TlsCertificates ON TlsCertificates.Id = Certificate " +
"WHERE MemberSites.Id = $1",
[siteId]
);
if (result.rowCount == 1) {
const secret = await LoadSecret(result.rows[0].objectname);
- hash = HashOfSecret(secret);
- data = secret.data;
+ [hash, data] = await hashedTlsState(client, result.rows[0].id, secret);
}
await client.query("COMMIT");
} catch (error) {
@@ -343,15 +350,14 @@ async function getStateTlsServer(apid) {
try {
await client.query("BEGIN");
const result = await client.query(
- "SELECT TlsCertificates.ObjectName FROM BackboneAccessPoints " +
+ "SELECT TlsCertificates.Id, TlsCertificates.ObjectName FROM BackboneAccessPoints " +
"JOIN TlsCertificates ON TlsCertificates.Id = Certificate " +
"WHERE BackboneAccessPoints.Id = $1",
[apid]
);
if (result.rowCount == 1) {
const secret = await LoadSecret(result.rows[0].objectname);
- hash = HashOfSecret(secret);
- data = secret.data;
+ [hash, data] = await hashedTlsState(client, result.rows[0].id, secret);
}
await client.query("COMMIT");
} catch (error) {
@@ -538,7 +544,8 @@ async function onNewMember(peerId) {
}
const site = siteResult.rows[0];
const secret = await LoadSecret(site.objectname);
- localState[`tls-site-${peerId}`] = HashOfSecret(secret);
+ const [memberHash] = await hashedTlsState(client, site.certificate, secret);
+ localState[`tls-site-${peerId}`] = memberHash;
//
// Find the links from this member site.
@@ -716,20 +723,31 @@ export async function SiteCertificateChanged(certId) {
const client = await ClientFromPool("system");
try {
await client.query("BEGIN");
- const result = await client.query(
+ const interior = await client.query(
"SELECT InteriorSites.Id, TlsCertificates.ObjectName FROM InteriorSites " +
"JOIN TlsCertificates ON TlsCertificates.Id = InteriorSites.Certificate " +
"WHERE Certificate = $1",
[certId]
);
- if (result.rowCount == 1) {
- const site = result.rows[0];
- if (peers[site.id]) {
- const secret = await LoadSecret(site.objectname);
- const hash = HashOfSecret(secret);
- await UpdateLocalState(site.id, `tls-site-${site.id}`, hash);
+ let site;
+ if (interior.rowCount == 1) {
+ site = interior.rows[0];
+ } else {
+ const member = await client.query(
+ "SELECT MemberSites.Id, TlsCertificates.ObjectName FROM MemberSites " +
+ "JOIN TlsCertificates ON TlsCertificates.Id = MemberSites.Certificate " +
+ "WHERE MemberSites.Certificate = $1",
+ [certId]
+ );
+ if (member.rowCount == 1) {
+ site = member.rows[0];
}
}
+ if (site && peers[site.id]) {
+ const secret = await LoadSecret(site.objectname);
+ const [hash] = await hashedTlsState(client, certId, secret);
+ await UpdateLocalState(site.id, `tls-site-${site.id}`, hash);
+ }
await client.query("COMMIT");
} catch (error) {
Log(`Exception in SiteCertificateChanged: ${error.message}`);
@@ -757,7 +775,7 @@ export async function AccessCertificateChanged(certId) {
const row = result.rows[0];
if (peers[row.id]) {
const secret = await LoadSecret(row.objectname);
- const hash = HashOfSecret(secret);
+ const [hash] = await hashedTlsState(client, certId, secret);
await UpdateLocalState(row.id, `tls-server-${row.apid}`, hash);
}
}
diff --git a/components/management-controller/src/sync-management.test.js b/components/management-controller/src/sync-management.test.js
index 1472f99b..a81f2a59 100644
--- a/components/management-controller/src/sync-management.test.js
+++ b/components/management-controller/src/sync-management.test.js
@@ -54,6 +54,7 @@ import {
GetBackboneAccessPoints_TX,
SiteDeleted,
SiteCertificateChanged,
+ AccessCertificateChanged,
SiteIngressChanged,
_registerPeerForTest,
} from "./sync-management.js";
@@ -231,6 +232,39 @@ describe("SiteCertificateChanged", () => {
expect(LoadSecret).not.toHaveBeenCalled();
expect(UpdateLocalState).not.toHaveBeenCalled();
});
+
+ it("updates tls-site state hash for connected member sites", async () => {
+ _registerPeerForTest("member-1", "member");
+
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
+ return {};
+ }
+ if (sql.includes("FROM InteriorSites") && sql.includes("Certificate = $1")) {
+ return { rowCount: 0, rows: [] };
+ }
+ if (sql.includes("FROM MemberSites") && sql.includes("Certificate = $1")) {
+ return {
+ rowCount: 1,
+ rows: [{ id: "member-1", objectname: "member-tls-secret" }],
+ };
+ }
+ return { rows: [] };
+ });
+
+ LoadSecret.mockResolvedValue({
+ data: { "tls.crt": Buffer.from("cert").toString("base64") },
+ });
+
+ await SiteCertificateChanged("cert-member");
+
+ expect(LoadSecret).toHaveBeenCalledWith("member-tls-secret");
+ expect(UpdateLocalState).toHaveBeenCalledWith(
+ "member-1",
+ "tls-site-member-1",
+ expect.stringMatching(/^[a-f0-9]{40}$/)
+ );
+ });
});
describe("SiteIngressChanged", () => {
@@ -274,3 +308,41 @@ describe("SiteIngressChanged", () => {
expect(mockClient.release).toHaveBeenCalled();
});
});
+
+describe("AccessCertificateChanged", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockClient.query.mockReset();
+ });
+
+ it("updates tls-server state hash for connected backbone sites", async () => {
+ _registerPeerForTest("site-3", "backbone");
+
+ mockClient.query.mockImplementation(async (sql) => {
+ if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
+ return {};
+ }
+ if (sql.includes("FROM BackboneAccessPoints") && sql.includes("Certificate = $1")) {
+ return {
+ rowCount: 1,
+ rows: [{ apid: "ap-9", id: "site-3", objectname: "ap-tls-secret" }],
+ };
+ }
+ return { rows: [] };
+ });
+
+ LoadSecret.mockResolvedValue({
+ data: { "tls.crt": Buffer.from("cert").toString("base64") },
+ });
+
+ await AccessCertificateChanged("cert-ap");
+
+ expect(LoadSecret).toHaveBeenCalledWith("ap-tls-secret");
+ expect(UpdateLocalState).toHaveBeenCalledWith(
+ "site-3",
+ "tls-server-ap-9",
+ expect.stringMatching(/^[a-f0-9]{40}$/)
+ );
+ expect(mockClient.release).toHaveBeenCalled();
+ });
+});
diff --git a/components/management-controller/src/tls-rotation.js b/components/management-controller/src/tls-rotation.js
new file mode 100644
index 00000000..7e397f0f
--- /dev/null
+++ b/components/management-controller/src/tls-rotation.js
@@ -0,0 +1,271 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/
+
+"use strict";
+
+import { X509Certificate } from "node:crypto";
+import { LoadSecret } from "@vms/modules/kube";
+
+export const TLS_CERTIFICATE_PARENT_TABLES = [
+ "ManagementControllers",
+ "Backbones",
+ "BackboneAccessPoints",
+ "InteriorSites",
+ "ApplicationNetworks",
+ "NetworkCredentials",
+ "MemberInvitations",
+ "MemberSites",
+];
+
+const CERT_SELECT =
+ "Id, IsCA, ObjectName, SignedBy, Expiration, RenewalTime, RotationOrdinal, Supercedes, Label";
+
+const LIVE_CHILDREN_SQL =
+ "SELECT Id, ObjectName, IsCA, SignedBy, Expiration, RenewalTime, RotationOrdinal, Label " +
+ "FROM TlsCertificates " +
+ "WHERE SignedBy = $1 " +
+ "AND NOT EXISTS (SELECT 1 FROM TlsCertificates newer WHERE newer.Supercedes = TlsCertificates.Id)";
+
+export function timestampsEqual(left, right) {
+ if (!left && !right) {
+ return true;
+ }
+ if (!left || !right) {
+ return false;
+ }
+ return new Date(left).getTime() === new Date(right).getTime();
+}
+
+export function expirationFromTlsSecret(secret) {
+ const encoded = secret?.data?.["tls.crt"];
+ if (!encoded) {
+ return undefined;
+ }
+ try {
+ const pem = Buffer.from(encoded, "base64").toString("utf-8");
+ if (!pem.includes("BEGIN CERTIFICATE")) {
+ return undefined;
+ }
+ const x509 = new X509Certificate(pem);
+ return new Date(x509.validToDate ?? x509.validTo);
+ } catch {
+ return undefined;
+ }
+}
+
+export function joinPemBundle(pems) {
+ const parts = (pems || []).map((pem) => (pem || "").trim()).filter(Boolean);
+ if (parts.length == 0) {
+ return "";
+ }
+ return `${parts.join("\n")}\n`;
+}
+
+export async function loadCertificateRow(client, certId) {
+ if (!certId) {
+ return undefined;
+ }
+ const result = await client.query(`SELECT ${CERT_SELECT} FROM TlsCertificates WHERE Id = $1`, [
+ certId,
+ ]);
+ return result.rows[0];
+}
+
+export async function lockCurrentCertificate(client, certId) {
+ if (!certId) {
+ return undefined;
+ }
+ const result = await client.query(
+ `SELECT ${CERT_SELECT} FROM TlsCertificates WHERE Id = $1 FOR UPDATE`,
+ [certId]
+ );
+ return result.rows[0];
+}
+
+export async function lockCurrentCertificateByObjectName(client, objectName) {
+ if (!objectName) {
+ return undefined;
+ }
+ const result = await client.query(
+ `SELECT ${CERT_SELECT} FROM TlsCertificates c ` +
+ "WHERE c.ObjectName = $1 " +
+ "AND NOT EXISTS (SELECT 1 FROM TlsCertificates s WHERE s.Supercedes = c.Id) " +
+ "ORDER BY c.RotationOrdinal DESC LIMIT 1 FOR UPDATE",
+ [objectName]
+ );
+ return result.rows[0];
+}
+
+export async function isCertificateSuperseded(client, certId) {
+ if (!certId) {
+ return false;
+ }
+ const result = await client.query("SELECT 1 FROM TlsCertificates WHERE Supercedes = $1", [
+ certId,
+ ]);
+ return result.rowCount > 0;
+}
+
+export async function loadSupercedesChain(client, certId) {
+ const chain = [];
+ const seen = new Set();
+ let id = certId;
+ while (id && !seen.has(id)) {
+ seen.add(id);
+ const row = await loadCertificateRow(client, id);
+ if (!row) {
+ break;
+ }
+ chain.push(row);
+ id = row.supercedes;
+ }
+ return chain;
+}
+
+export async function getTlsRotationMeta(client, certId) {
+ const chain = await loadSupercedesChain(client, certId);
+ if (chain.length == 0) {
+ return { ordinal: 0, lastValid: 0 };
+ }
+ const ordinal = chain[0].rotationordinal ?? 0;
+ let lastValid = null;
+ const now = Date.now();
+ for (const row of chain) {
+ const rotationOrdinal = row.rotationordinal ?? 0;
+ const expirationMs = row.expiration ? new Date(row.expiration).getTime() : null;
+ if (expirationMs == null || expirationMs > now) {
+ if (lastValid == null || rotationOrdinal < lastValid) {
+ lastValid = rotationOrdinal;
+ }
+ }
+ }
+ if (lastValid == null) {
+ lastValid = ordinal;
+ }
+ return { ordinal, lastValid };
+}
+
+export async function retargetParentCertificateFks(client, notify, oldId, newId) {
+ for (const table of TLS_CERTIFICATE_PARENT_TABLES) {
+ const updated = await client.query(
+ `UPDATE ${table} SET Certificate = $1 WHERE Certificate = $2 RETURNING Id`,
+ [newId, oldId]
+ );
+ for (const row of updated.rows) {
+ notify.update(table, row.id);
+ }
+ }
+}
+
+export async function listLiveChildren(client, caId) {
+ const result = await client.query(LIVE_CHILDREN_SQL, [caId]);
+ return result.rows;
+}
+
+export async function hasLiveChildren(client, caId) {
+ const result = await client.query(`${LIVE_CHILDREN_SQL} LIMIT 1`, [caId]);
+ return result.rowCount > 0 || result.rows.length > 0;
+}
+
+export async function listCurrentLeafChildren(client, caId) {
+ const children = await listLiveChildren(client, caId);
+ return children.filter((child) => !child.isca);
+}
+
+async function pemFromCaSecret(objectName) {
+ const secret = await LoadSecret(objectName);
+ const encoded = secret?.data?.["tls.crt"];
+ if (!encoded) {
+ return null;
+ }
+ return Buffer.from(encoded, "base64").toString("utf-8");
+}
+
+export async function overlayDualTrustCa(client, certId, secretData) {
+ if (!client || !certId || !secretData) {
+ return secretData;
+ }
+ const cert = await loadCertificateRow(client, certId);
+ if (!cert) {
+ return secretData;
+ }
+ const issuerId = cert.isca ? cert.id : cert.signedby;
+ if (!issuerId) {
+ return secretData;
+ }
+ const issuer = await loadCertificateRow(client, issuerId);
+ if (!issuer) {
+ return secretData;
+ }
+
+ let oldName;
+ let newName;
+ if (issuer.supercedes) {
+ const predecessor = await loadCertificateRow(client, issuer.supercedes);
+ if (predecessor && predecessor.objectname !== issuer.objectname) {
+ if (await hasLiveChildren(client, predecessor.id)) {
+ oldName = predecessor.objectname;
+ newName = issuer.objectname;
+ }
+ }
+ }
+ if (!oldName || !newName || oldName === newName) {
+ return secretData;
+ }
+
+ const pems = [];
+ for (const name of [oldName, newName]) {
+ const pem = await pemFromCaSecret(name);
+ if (pem) {
+ pems.push(pem);
+ }
+ }
+ if (pems.length < 2) {
+ return secretData;
+ }
+ return {
+ ...secretData,
+ "ca.crt": Buffer.from(joinPemBundle(pems), "utf-8").toString("base64"),
+ };
+}
+
+export async function deleteExpiredSupersededCertificates(client, notify) {
+ const expired = await client.query(
+ "SELECT c.Id, c.ObjectName FROM TlsCertificates c " +
+ "WHERE c.Expiration IS NOT NULL AND c.Expiration < CURRENT_TIMESTAMP " +
+ "AND EXISTS (SELECT 1 FROM TlsCertificates newer WHERE newer.Supercedes = c.Id) " +
+ "AND NOT EXISTS (SELECT 1 FROM TlsCertificates child WHERE child.SignedBy = c.Id) " +
+ "AND NOT EXISTS (SELECT 1 FROM TlsClientRevocations r WHERE r.CertificateId = c.Id) " +
+ "ORDER BY c.RotationOrdinal ASC"
+ );
+ const objectNames = [];
+ const seen = new Set();
+ for (const row of expired.rows) {
+ await client.query("UPDATE TlsCertificates SET Supercedes = NULL WHERE Supercedes = $1", [
+ row.id,
+ ]);
+ await client.query("DELETE FROM TlsCertificates WHERE Id = $1", [row.id]);
+ notify.delete("TlsCertificates", row.id);
+ if (row.objectname && !seen.has(row.objectname)) {
+ seen.add(row.objectname);
+ objectNames.push(row.objectname);
+ }
+ }
+ return objectNames;
+}
diff --git a/components/management-controller/src/tls-rotation.test.js b/components/management-controller/src/tls-rotation.test.js
new file mode 100644
index 00000000..880cfb96
--- /dev/null
+++ b/components/management-controller/src/tls-rotation.test.js
@@ -0,0 +1,469 @@
+/*
+ Licensed to the Apache Software Foundation (ASF) under one
+ or more contributor license agreements. See the NOTICE file
+ distributed with this work for additional information
+ regarding copyright ownership. The ASF licenses this file
+ to you under the Apache License, Version 2.0 (the
+ "License"); you may not use this file except in compliance
+ with the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing,
+ software distributed under the License is distributed on an
+ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ KIND, either express or implied. See the License for the
+ specific language governing permissions and limitations
+ under the License.
+*/
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+vi.mock("@vms/modules/kube", () => ({
+ LoadSecret: vi.fn(),
+}));
+
+import { LoadSecret } from "@vms/modules/kube";
+import {
+ TLS_CERTIFICATE_PARENT_TABLES,
+ timestampsEqual,
+ expirationFromTlsSecret,
+ joinPemBundle,
+ loadCertificateRow,
+ lockCurrentCertificate,
+ lockCurrentCertificateByObjectName,
+ isCertificateSuperseded,
+ loadSupercedesChain,
+ getTlsRotationMeta,
+ retargetParentCertificateFks,
+ listLiveChildren,
+ hasLiveChildren,
+ listCurrentLeafChildren,
+ overlayDualTrustCa,
+ deleteExpiredSupersededCertificates,
+} from "./tls-rotation.js";
+
+const TEST_CERT_PEM = `-----BEGIN CERTIFICATE-----
+MIIDBzCCAe+gAwIBAgIUQN+/jWSwj02BdJAZsQlOzFf7zv0wDQYJKoZIhvcNAQEL
+BQAwEzERMA8GA1UEAwwIdm1zLXRlc3QwHhcNMjYwOTA4MTkxOTA4WhcNMjYwOTA5
+MTkxOTA4WjATMREwDwYDVQQDDAh2bXMtdGVzdDCCASIwDQYJKoZIhvcNAQEBBQAD
+ggEPADCCAQoCggEBAJ11ugUkynmZLiuuILQ18OFN6b2o3/l7xFo3Yu+kPQuTWcF5
+/CZnUHq/Ztvpemjx7oRdozKyRkxq79AIY0ZyVeNuaJkY12VjBdLs/JTVe6G1Uqi9
+XMojzDCKLItI4khjkmuaeAOBHhfxr6YUy+2OuGY+MxwHuNtkPPmhDF33Um+dGr/d
+NcrHlXqGnQq82R5LcB8erhKQ4cJJKAc1vn9UvuO03PXPtvVTRHwbW8Xk+INbQ2t2
+w1IWKljDIiALzrPFapmZT5RZMp5XSDtsHxuSTP+BunWQEOk7LsKWy9qe6X+nIInO
+qQgtx58EZk12Mw+KZzaF9h4oDkzWjLBAGUXBtdUCAwEAAaNTMFEwHQYDVR0OBBYE
+FAt+XxV2e7PKYODezuZVfHWNhNMxMB8GA1UdIwQYMBaAFAt+XxV2e7PKYODezuZV
+fHWNhNMxMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAIyc4w1J
+AAjpbdd2NjzBg3ZxaC1LycLicaTuB11CgQWa1vsvHDtfZk71TTn6fD3MFKw7f7IQ
+kn1YZXMyOtG0tMAR64OE/eYigk4bIP985e8HDb2v/AarNJdRF8Go572l0te6dur1
+ZLE6HJlzCgdombHSHxjBa2UTQwmTiNpDIFkZQUPfR0Rw5/C/ek22LH8rJg0cjFa8
+hSRGq6AVAGOWVncdSnPmdHOf+m4GXoHUw5cd5YwVb9AzFSP3JXTXwK7aUyDFepAa
+GaaHeyOP2mW2NluntZRJ9ui7GVMugHZ/oVzNWnpYj/MIiZgUjwK/cRnmCrW7rzjU
+91T2XxO7Av/VcMQ=
+-----END CERTIFICATE-----
+`;
+
+function certRow(overrides = {}) {
+ return {
+ id: "cert-1",
+ isca: false,
+ objectname: "tls-cert-1",
+ signedby: "ca-1",
+ expiration: new Date("2099-01-01T00:00:00Z"),
+ renewaltime: new Date("2098-01-01T00:00:00Z"),
+ rotationordinal: 0,
+ supercedes: null,
+ label: "test",
+ ...overrides,
+ };
+}
+
+function createClient(handler) {
+ return {
+ query: vi.fn(async (sql, params) => handler(sql, params ?? [])),
+ };
+}
+
+describe("timestampsEqual", () => {
+ it("treats missing values as equal only when both are absent", () => {
+ expect(timestampsEqual(null, undefined)).toBe(true);
+ expect(timestampsEqual(new Date("2026-01-01"), null)).toBe(false);
+ expect(timestampsEqual(null, new Date("2026-01-01"))).toBe(false);
+ });
+
+ it("compares instants regardless of Date vs string form", () => {
+ const instant = "2026-09-08T12:00:00.000Z";
+ expect(timestampsEqual(new Date(instant), instant)).toBe(true);
+ expect(timestampsEqual(new Date(instant), "2026-09-08T12:00:01.000Z")).toBe(false);
+ });
+});
+
+describe("expirationFromTlsSecret", () => {
+ it("returns undefined when tls.crt is missing or not a certificate PEM", () => {
+ expect(expirationFromTlsSecret(undefined)).toBeUndefined();
+ expect(expirationFromTlsSecret({ data: {} })).toBeUndefined();
+ expect(
+ expirationFromTlsSecret({
+ data: { "tls.crt": Buffer.from("not-a-cert").toString("base64") },
+ })
+ ).toBeUndefined();
+ expect(
+ expirationFromTlsSecret({
+ data: {
+ "tls.crt": Buffer.from("-----BEGIN CERTIFICATE-----\nbad").toString("base64"),
+ },
+ })
+ ).toBeUndefined();
+ });
+
+ it("parses notAfter from a TLS secret certificate", () => {
+ const expiration = expirationFromTlsSecret({
+ data: { "tls.crt": Buffer.from(TEST_CERT_PEM).toString("base64") },
+ });
+ expect(expiration).toBeInstanceOf(Date);
+ expect(expiration.toISOString()).toBe("2026-09-09T19:19:08.000Z");
+ });
+});
+
+describe("joinPemBundle", () => {
+ it("returns an empty string when there are no PEM parts", () => {
+ expect(joinPemBundle()).toBe("");
+ expect(joinPemBundle(["", " "])).toBe("");
+ });
+
+ it("joins trimmed PEMs with a trailing newline", () => {
+ expect(joinPemBundle(["aaa\n", " bbb "])).toBe("aaa\nbbb\n");
+ });
+});
+
+describe("certificate row helpers", () => {
+ it("loadCertificateRow and lockCurrentCertificate return undefined without an id", async () => {
+ const client = createClient(() => ({ rows: [] }));
+ expect(await loadCertificateRow(client, null)).toBeUndefined();
+ expect(await lockCurrentCertificate(client, "")).toBeUndefined();
+ expect(client.query).not.toHaveBeenCalled();
+ });
+
+ it("loadCertificateRow selects the matching row", async () => {
+ const row = certRow();
+ const client = createClient(() => ({ rows: [row] }));
+ expect(await loadCertificateRow(client, "cert-1")).toEqual(row);
+ expect(client.query).toHaveBeenCalledWith(expect.stringContaining("WHERE Id = $1"), [
+ "cert-1",
+ ]);
+ expect(client.query.mock.calls[0][0]).not.toContain("FOR UPDATE");
+ });
+
+ it("lockCurrentCertificate locks the row for update", async () => {
+ const row = certRow({ id: "cert-2" });
+ const client = createClient(() => ({ rows: [row] }));
+ expect(await lockCurrentCertificate(client, "cert-2")).toEqual(row);
+ expect(client.query).toHaveBeenCalledWith(expect.stringContaining("FOR UPDATE"), [
+ "cert-2",
+ ]);
+ });
+
+ it("lockCurrentCertificateByObjectName returns undefined without a name", async () => {
+ const client = createClient(() => ({ rows: [] }));
+ expect(await lockCurrentCertificateByObjectName(client, "")).toBeUndefined();
+ expect(client.query).not.toHaveBeenCalled();
+ });
+
+ it("lockCurrentCertificateByObjectName selects the current tip by object name", async () => {
+ const row = certRow({ rotationordinal: 3 });
+ const client = createClient(() => ({ rows: [row] }));
+ expect(await lockCurrentCertificateByObjectName(client, "tls-cert-1")).toEqual(row);
+ expect(client.query).toHaveBeenCalledWith(expect.stringContaining("c.ObjectName = $1"), [
+ "tls-cert-1",
+ ]);
+ expect(client.query.mock.calls[0][0]).toContain("FOR UPDATE");
+ });
+});
+
+describe("isCertificateSuperseded", () => {
+ it("returns false when there is no id or no successor", async () => {
+ const client = createClient(() => ({ rowCount: 0, rows: [] }));
+ expect(await isCertificateSuperseded(client, null)).toBe(false);
+ expect(await isCertificateSuperseded(client, "cert-1")).toBe(false);
+ });
+
+ it("returns true when a successor row exists", async () => {
+ const client = createClient(() => ({ rowCount: 1, rows: [{ "?column?": 1 }] }));
+ expect(await isCertificateSuperseded(client, "cert-1")).toBe(true);
+ });
+});
+
+describe("loadSupercedesChain", () => {
+ it("walks predecessor ids and stops on cycles", async () => {
+ const rows = {
+ "cert-new": certRow({ id: "cert-new", supercedes: "cert-old", rotationordinal: 1 }),
+ "cert-old": certRow({ id: "cert-old", supercedes: "cert-new", rotationordinal: 0 }),
+ };
+ const client = createClient((sql, params) => ({
+ rows: rows[params[0]] ? [rows[params[0]]] : [],
+ }));
+
+ const chain = await loadSupercedesChain(client, "cert-new");
+ expect(chain.map((row) => row.id)).toEqual(["cert-new", "cert-old"]);
+ });
+
+ it("stops when a predecessor row is missing", async () => {
+ const client = createClient((sql, params) => {
+ if (params[0] === "cert-new") {
+ return {
+ rows: [certRow({ id: "cert-new", supercedes: "missing" })],
+ };
+ }
+ return { rows: [] };
+ });
+ const chain = await loadSupercedesChain(client, "cert-new");
+ expect(chain).toHaveLength(1);
+ expect(chain[0].id).toBe("cert-new");
+ });
+});
+
+describe("getTlsRotationMeta", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-09-08T12:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("returns zeros when the certificate is unknown", async () => {
+ const client = createClient(() => ({ rows: [] }));
+ expect(await getTlsRotationMeta(client, "missing")).toEqual({ ordinal: 0, lastValid: 0 });
+ });
+
+ it("uses the lowest unexpired rotation ordinal as lastValid", async () => {
+ const rows = {
+ "cert-new": certRow({
+ id: "cert-new",
+ supercedes: "cert-old",
+ rotationordinal: 2,
+ expiration: new Date("2099-01-01T00:00:00Z"),
+ }),
+ "cert-old": certRow({
+ id: "cert-old",
+ supercedes: null,
+ rotationordinal: 1,
+ expiration: new Date("2099-01-01T00:00:00Z"),
+ }),
+ };
+ const client = createClient((sql, params) => ({
+ rows: rows[params[0]] ? [rows[params[0]]] : [],
+ }));
+ expect(await getTlsRotationMeta(client, "cert-new")).toEqual({
+ ordinal: 2,
+ lastValid: 1,
+ });
+ });
+
+ it("falls back to the current ordinal when every predecessor has expired", async () => {
+ const rows = {
+ "cert-new": certRow({
+ id: "cert-new",
+ supercedes: "cert-old",
+ rotationordinal: 2,
+ expiration: new Date("2099-01-01T00:00:00Z"),
+ }),
+ "cert-old": certRow({
+ id: "cert-old",
+ supercedes: null,
+ rotationordinal: 1,
+ expiration: new Date("2020-01-01T00:00:00Z"),
+ }),
+ };
+ const client = createClient((sql, params) => ({
+ rows: rows[params[0]] ? [rows[params[0]]] : [],
+ }));
+ expect(await getTlsRotationMeta(client, "cert-new")).toEqual({
+ ordinal: 2,
+ lastValid: 2,
+ });
+ });
+
+ it("treats a missing expiration as still valid", async () => {
+ const client = createClient(() => ({
+ rows: [certRow({ rotationordinal: 4, expiration: null, supercedes: null })],
+ }));
+ expect(await getTlsRotationMeta(client, "cert-1")).toEqual({
+ ordinal: 4,
+ lastValid: 4,
+ });
+ });
+});
+
+describe("retargetParentCertificateFks", () => {
+ it("updates every parent table and notifies changed rows", async () => {
+ const notify = { update: vi.fn() };
+ const client = createClient((sql) => {
+ if (sql.includes("InteriorSites")) {
+ return { rows: [{ id: "site-1" }] };
+ }
+ return { rows: [] };
+ });
+
+ await retargetParentCertificateFks(client, notify, "old-id", "new-id");
+
+ expect(client.query).toHaveBeenCalledTimes(TLS_CERTIFICATE_PARENT_TABLES.length);
+ for (const table of TLS_CERTIFICATE_PARENT_TABLES) {
+ expect(client.query).toHaveBeenCalledWith(
+ `UPDATE ${table} SET Certificate = $1 WHERE Certificate = $2 RETURNING Id`,
+ ["new-id", "old-id"]
+ );
+ }
+ expect(notify.update).toHaveBeenCalledWith("InteriorSites", "site-1");
+ expect(notify.update).toHaveBeenCalledTimes(1);
+ });
+});
+
+describe("live children", () => {
+ it("listLiveChildren returns current children of a CA", async () => {
+ const children = [certRow({ id: "leaf-1" }), certRow({ id: "ca-child", isca: true })];
+ const client = createClient(() => ({ rows: children }));
+ expect(await listLiveChildren(client, "ca-1")).toEqual(children);
+ expect(client.query).toHaveBeenCalledWith(expect.stringContaining("SignedBy = $1"), [
+ "ca-1",
+ ]);
+ });
+
+ it("hasLiveChildren is true when rowCount or rows are present", async () => {
+ const byCount = createClient(() => ({ rowCount: 1, rows: [] }));
+ const byRows = createClient(() => ({ rowCount: 0, rows: [certRow()] }));
+ const empty = createClient(() => ({ rowCount: 0, rows: [] }));
+ expect(await hasLiveChildren(byCount, "ca-1")).toBe(true);
+ expect(await hasLiveChildren(byRows, "ca-1")).toBe(true);
+ expect(await hasLiveChildren(empty, "ca-1")).toBe(false);
+ });
+
+ it("listCurrentLeafChildren omits CA children", async () => {
+ const client = createClient(() => ({
+ rows: [certRow({ id: "leaf-1", isca: false }), certRow({ id: "ca-child", isca: true })],
+ }));
+ const leaves = await listCurrentLeafChildren(client, "ca-1");
+ expect(leaves.map((row) => row.id)).toEqual(["leaf-1"]);
+ });
+});
+
+describe("overlayDualTrustCa", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns the original secret data when overlay is not applicable", async () => {
+ const data = { "ca.crt": "old" };
+ expect(await overlayDualTrustCa(null, "cert-1", data)).toBe(data);
+ expect(await overlayDualTrustCa({ query: vi.fn() }, null, data)).toBe(data);
+ expect(await overlayDualTrustCa({ query: vi.fn() }, "cert-1", null)).toBe(null);
+
+ const missing = createClient(() => ({ rows: [] }));
+ expect(await overlayDualTrustCa(missing, "cert-1", data)).toBe(data);
+ });
+
+ it("bundles old and new CA PEMs while the predecessor still has live children", async () => {
+ const oldPem = "-----BEGIN CERTIFICATE-----\nold-ca\n-----END CERTIFICATE-----";
+ const newPem = "-----BEGIN CERTIFICATE-----\nnew-ca\n-----END CERTIFICATE-----";
+ const leaf = certRow({ id: "leaf-1", isca: false, signedby: "ca-new" });
+ const newCa = certRow({
+ id: "ca-new",
+ isca: true,
+ objectname: "new-ca",
+ supercedes: "ca-old",
+ signedby: null,
+ });
+ const oldCa = certRow({
+ id: "ca-old",
+ isca: true,
+ objectname: "old-ca",
+ supercedes: null,
+ });
+ const client = createClient((sql, params) => {
+ if (sql.includes("WHERE Id = $1")) {
+ const rows = { "leaf-1": leaf, "ca-new": newCa, "ca-old": oldCa };
+ return { rows: rows[params[0]] ? [rows[params[0]]] : [] };
+ }
+ if (sql.includes("SignedBy = $1")) {
+ return { rowCount: 1, rows: [certRow({ id: "still-on-old" })] };
+ }
+ return { rows: [], rowCount: 0 };
+ });
+ LoadSecret.mockImplementation(async (name) => {
+ const pems = { "old-ca": oldPem, "new-ca": newPem };
+ return { data: { "tls.crt": Buffer.from(pems[name]).toString("base64") } };
+ });
+
+ const overlaid = await overlayDualTrustCa(client, "leaf-1", {
+ "ca.crt": "leaf-ca",
+ "tls.crt": "leaf-crt",
+ });
+
+ expect(overlaid["tls.crt"]).toBe("leaf-crt");
+ expect(Buffer.from(overlaid["ca.crt"], "base64").toString("utf-8")).toBe(
+ joinPemBundle([oldPem, newPem])
+ );
+ expect(LoadSecret).toHaveBeenCalledWith("old-ca");
+ expect(LoadSecret).toHaveBeenCalledWith("new-ca");
+ });
+
+ it("does not overlay when the predecessor has no live children", async () => {
+ const leaf = certRow({ id: "leaf-1", signedby: "ca-new" });
+ const newCa = certRow({
+ id: "ca-new",
+ isca: true,
+ objectname: "new-ca",
+ supercedes: "ca-old",
+ });
+ const oldCa = certRow({ id: "ca-old", objectname: "old-ca" });
+ const client = createClient((sql, params) => {
+ if (sql.includes("WHERE Id = $1")) {
+ const rows = { "leaf-1": leaf, "ca-new": newCa, "ca-old": oldCa };
+ return { rows: rows[params[0]] ? [rows[params[0]]] : [] };
+ }
+ return { rowCount: 0, rows: [] };
+ });
+ const data = { "ca.crt": "leaf-ca" };
+ expect(await overlayDualTrustCa(client, "leaf-1", data)).toBe(data);
+ expect(LoadSecret).not.toHaveBeenCalled();
+ });
+});
+
+describe("deleteExpiredSupersededCertificates", () => {
+ it("deletes expired predecessors and returns unique object names", async () => {
+ const notify = { delete: vi.fn() };
+ const client = createClient((sql) => {
+ if (sql.includes("Expiration < CURRENT_TIMESTAMP")) {
+ return {
+ rows: [
+ { id: "old-1", objectname: "shared-secret" },
+ { id: "old-2", objectname: "shared-secret" },
+ { id: "old-3", objectname: null },
+ ],
+ };
+ }
+ return { rows: [], rowCount: 1 };
+ });
+
+ const names = await deleteExpiredSupersededCertificates(client, notify);
+
+ expect(names).toEqual(["shared-secret"]);
+ expect(client.query).toHaveBeenCalledWith(
+ "UPDATE TlsCertificates SET Supercedes = NULL WHERE Supercedes = $1",
+ ["old-1"]
+ );
+ expect(client.query).toHaveBeenCalledWith("DELETE FROM TlsCertificates WHERE Id = $1", [
+ "old-1",
+ ]);
+ expect(client.query).toHaveBeenCalledWith("DELETE FROM TlsCertificates WHERE Id = $1", [
+ "old-2",
+ ]);
+ expect(client.query).toHaveBeenCalledWith("DELETE FROM TlsCertificates WHERE Id = $1", [
+ "old-3",
+ ]);
+ expect(notify.delete).toHaveBeenCalledWith("TlsCertificates", "old-1");
+ expect(notify.delete).toHaveBeenCalledTimes(3);
+ });
+});
diff --git a/components/management-controller/src/watch-server.js b/components/management-controller/src/watch-server.js
index 1dd40e3f..ec516926 100644
--- a/components/management-controller/src/watch-server.js
+++ b/components/management-controller/src/watch-server.js
@@ -134,6 +134,14 @@ const mutex = new Mutex();
let watchDispatch = sendUpdate;
+function parseWatchQuery(url) {
+ const qIndex = url.indexOf("?");
+ if (qIndex < 0) {
+ return {};
+ }
+ return Object.fromEntries(new URLSearchParams(url.slice(qIndex + 1)));
+}
+
async function sendUpdate(watch, isInitial) {
const release = await mutex.acquire();
const url = watch.source.address;
@@ -144,7 +152,8 @@ async function sendUpdate(watch, isInitial) {
req.url = url;
req.method = "GET";
- req.query = {};
+ // router.handle() does not run Express query middleware.
+ req.query = parseWatchQuery(url);
req._skip_log = !isInitial;
router.handle(req, res, (err) => {
@@ -241,6 +250,11 @@ export async function WatchNotify(tableName, id, _holdoff) {
}
}
+/** @internal Exported for unit tests */
+export function _parseWatchQueryForTest(url) {
+ return parseWatchQuery(url);
+}
+
/** @internal Exported for unit tests */
export function _registerWatchForTest(tableName, id, watch) {
if (!watchIndex[tableName]) {
diff --git a/components/management-controller/src/watch-server.test.js b/components/management-controller/src/watch-server.test.js
index 07b01788..3c37b6a9 100644
--- a/components/management-controller/src/watch-server.test.js
+++ b/components/management-controller/src/watch-server.test.js
@@ -18,7 +18,12 @@
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
-import { WatchNotify, _registerWatchForTest, _setWatchDispatchForTest } from "./watch-server.js";
+import {
+ WatchNotify,
+ _registerWatchForTest,
+ _setWatchDispatchForTest,
+ _parseWatchQueryForTest,
+} from "./watch-server.js";
describe("WatchNotify", () => {
beforeEach(() => {
@@ -54,3 +59,16 @@ describe("WatchNotify", () => {
expect(dispatch).toHaveBeenCalledWith(allWatch, false);
});
});
+
+describe("_parseWatchQueryForTest", () => {
+ it("returns an empty object when the URL has no query string", () => {
+ expect(_parseWatchQueryForTest("/api/v1alpha1/certs")).toEqual({});
+ });
+
+ it("parses query parameters from the URL", () => {
+ expect(_parseWatchQueryForTest("/api/v1alpha1/certs?signedby=ca-1&watch=1")).toEqual({
+ signedby: "ca-1",
+ watch: "1",
+ });
+ });
+});
diff --git a/components/site-controller/src/sync-site-kube.js b/components/site-controller/src/sync-site-kube.js
index 79260f54..9cc0e482 100644
--- a/components/site-controller/src/sync-site-kube.js
+++ b/components/site-controller/src/sync-site-kube.js
@@ -45,6 +45,8 @@ import {
META_ANNOTATION_STATE_TYPE,
META_ANNOTATION_STATE_ID,
META_ANNOTATION_TLS_INJECT,
+ META_ANNOTATION_TLS_ORDINAL,
+ META_ANNOTATION_TLS_LAST_VALID,
API_CONTROLLER_ADDRESS,
STATE_TYPE_LISTENER,
} from "@vms/modules/common";
@@ -59,6 +61,7 @@ import {
DeleteConfigmap,
DeleteDeployment,
LoadSecret,
+ ReplaceSecret,
LoadConfigmap,
UpdateLink,
UpdateNetworkAccess,
@@ -242,6 +245,9 @@ const onPeerLost = async function (_peerId) {
const retrieveLatest = async function (apiVersion, objKind, objName) {
Log(`Retrieving latest object - kind: ${apiVersion}.${objKind}, name: ${objName}`);
+ if (objKind == "Secret") {
+ return await LoadSecret(objName);
+ }
if (apiVersion == "skupper.io/v2alpha1") {
try {
switch (objKind) {
@@ -270,6 +276,9 @@ const updateObject = async function (obj) {
const objKind = obj.kind;
const objName = obj.metadata.name;
Log(`Updating object - kind: ${apiVersion}.${objKind}, name: ${objName}`);
+ if (objKind == "Secret") {
+ return await ReplaceSecret(objName, obj);
+ }
if (apiVersion == "skupper.io/v2alpha1") {
switch (objKind) {
case "Link":
@@ -360,6 +369,22 @@ async function getBackboneClientSecret() {
}
}
+function takeTlsRotationFromSyncData(data, annotations) {
+ if (!data || typeof data !== "object") {
+ return data;
+ }
+ const certData = { ...data };
+ if (Object.hasOwn(certData, "ordinal")) {
+ annotations[META_ANNOTATION_TLS_ORDINAL] = String(certData.ordinal);
+ delete certData.ordinal;
+ }
+ if (Object.hasOwn(certData, "lastValid")) {
+ annotations[META_ANNOTATION_TLS_LAST_VALID] = String(certData.lastValid);
+ delete certData.lastValid;
+ }
+ return certData;
+}
+
const onStateChange = async function (peerId, stateKey, hash, data) {
const [objName, apiVersion, objKind, objType, objDir, stateType, stateId, inject] =
kubeObjectForState(stateKey, data);
@@ -396,6 +421,9 @@ const onStateChange = async function (peerId, stateKey, hash, data) {
return;
}
create = false;
+ if (!obj.metadata.annotations) {
+ obj.metadata.annotations = {};
+ }
obj.metadata.annotations[META_ANNOTATION_STATE_KEY] = stateKey;
obj.metadata.annotations[META_ANNOTATION_STATE_DIR] = objDir;
obj.metadata.annotations[META_ANNOTATION_STATE_HASH] = hash;
@@ -417,7 +445,7 @@ const onStateChange = async function (peerId, stateKey, hash, data) {
}
if (!isSkupperResource) {
- obj.data = data;
+ obj.data = takeTlsRotationFromSyncData(data, obj.metadata.annotations);
} else {
await doStateChangeSpec(obj, data);
}
diff --git a/components/site-controller/src/sync-site-kube.test.js b/components/site-controller/src/sync-site-kube.test.js
index ab5f6431..27884d02 100644
--- a/components/site-controller/src/sync-site-kube.test.js
+++ b/components/site-controller/src/sync-site-kube.test.js
@@ -22,6 +22,8 @@ import {
INJECT_TYPE_SITE,
META_ANNOTATION_VMS_CONTROLLED,
META_ANNOTATION_TLS_INJECT,
+ META_ANNOTATION_TLS_ORDINAL,
+ META_ANNOTATION_TLS_LAST_VALID,
} from "@vms/modules/common";
/** @type {Record} */
@@ -65,6 +67,7 @@ vi.mock("@vms/modules/kube", () => ({
DeleteConfigmap: vi.fn(),
DeleteDeployment: vi.fn(),
LoadSecret: vi.fn(),
+ ReplaceSecret: vi.fn(),
LoadConfigmap: vi.fn(),
UpdateLink: vi.fn(),
UpdateNetworkAccess: vi.fn(),
@@ -92,7 +95,15 @@ vi.mock("./ingress-v2.js", () => ({
}));
import { UpdateLocalState as StateSyncUpdateLocalState } from "@vms/modules/state-sync";
-import { ApplyObject, Controlled, DeleteLink, GetSecrets, UpdateLink } from "@vms/modules/kube";
+import {
+ ApplyObject,
+ Controlled,
+ DeleteLink,
+ GetSecrets,
+ UpdateLink,
+ LoadSecret,
+ ReplaceSecret,
+} from "@vms/modules/kube";
import { Start, UpdateLocalState } from "./sync-site-kube.js";
describe("UpdateLocalState", () => {
@@ -248,6 +259,70 @@ describe("onStateChange", () => {
);
expect(ApplyObject).not.toHaveBeenCalled();
});
+
+ it("creates a TLS secret without ordinal fields in the secret data", async () => {
+ LoadSecret.mockResolvedValue(undefined);
+
+ await stateSyncCallbacks.onStateChange("mgmt-peer", "tls-site-site-1", "hash-tls-1", {
+ "tls.crt": "cert",
+ "tls.key": "key",
+ ordinal: "2",
+ lastValid: "1",
+ });
+
+ expect(ApplyObject).toHaveBeenCalledWith(
+ expect.objectContaining({
+ kind: "Secret",
+ type: "kubernetes.io/tls",
+ metadata: expect.objectContaining({
+ name: "vms-site-site-1",
+ annotations: expect.objectContaining({
+ "vms/state-key": "tls-site-site-1",
+ [META_ANNOTATION_TLS_INJECT]: INJECT_TYPE_SITE,
+ [META_ANNOTATION_TLS_ORDINAL]: "2",
+ [META_ANNOTATION_TLS_LAST_VALID]: "1",
+ }),
+ }),
+ data: {
+ "tls.crt": "cert",
+ "tls.key": "key",
+ },
+ })
+ );
+ });
+
+ it("replaces an existing TLS secret when the hash changes", async () => {
+ LoadSecret.mockResolvedValue({
+ apiVersion: "v1",
+ kind: "Secret",
+ metadata: {
+ name: "vms-site-site-1",
+ },
+ data: { "tls.crt": "old" },
+ });
+
+ await stateSyncCallbacks.onStateChange("mgmt-peer", "tls-site-site-1", "hash-tls-2", {
+ "tls.crt": "new-cert",
+ ordinal: "3",
+ lastValid: "2",
+ });
+
+ expect(ReplaceSecret).toHaveBeenCalledWith(
+ "vms-site-site-1",
+ expect.objectContaining({
+ kind: "Secret",
+ metadata: expect.objectContaining({
+ annotations: expect.objectContaining({
+ "vms/state-hash": "hash-tls-2",
+ [META_ANNOTATION_TLS_ORDINAL]: "3",
+ [META_ANNOTATION_TLS_LAST_VALID]: "2",
+ }),
+ }),
+ data: { "tls.crt": "new-cert" },
+ })
+ );
+ expect(ApplyObject).not.toHaveBeenCalled();
+ });
});
describe("onStateRequest", () => {
diff --git a/modules/src/amqp.js b/modules/src/amqp.js
index 3720ef15..4dff5e80 100644
--- a/modules/src/amqp.js
+++ b/modules/src/amqp.js
@@ -143,6 +143,23 @@ export function OpenConnection(
return conn;
}
+export function OnConnectionClosed(conn, handler) {
+ const amqpConn = conn.amqpConnection;
+ if (amqpConn.options) {
+ amqpConn.options.reconnect = false;
+ }
+ let notified = false;
+ const notify = () => {
+ if (notified) {
+ return;
+ }
+ notified = true;
+ handler(conn);
+ };
+ amqpConn.on("disconnected", notify);
+ amqpConn.on("connection_close", notify);
+}
+
export function CloseConnection(conn) {
conn.amqpConnection.close();
}
diff --git a/modules/src/amqp.test.js b/modules/src/amqp.test.js
index df12bdcd..fc780e42 100644
--- a/modules/src/amqp.test.js
+++ b/modules/src/amqp.test.js
@@ -38,6 +38,8 @@ describe("amqp", () => {
send: vi.fn((message) => sentMessages.push(message)),
})),
close: vi.fn(),
+ options: { reconnect: true },
+ on: vi.fn(),
};
const mockContainer = {
@@ -99,4 +101,32 @@ describe("amqp", () => {
expect(mockAmqpConnection.close).toHaveBeenCalled();
});
+
+ it("OnConnectionClosed disables reconnect and notifies once", async () => {
+ const { OpenConnection, OnConnectionClosed } = await import("./amqp.js");
+ const conn = OpenConnection("test-conn", "localhost", 5672);
+ const handler = vi.fn();
+
+ OnConnectionClosed(conn, handler);
+
+ expect(mockAmqpConnection.options.reconnect).toBe(false);
+ expect(mockAmqpConnection.on).toHaveBeenCalledWith("disconnected", expect.any(Function));
+ expect(mockAmqpConnection.on).toHaveBeenCalledWith(
+ "connection_close",
+ expect.any(Function)
+ );
+
+ const disconnected = mockAmqpConnection.on.mock.calls.find(
+ (call) => call[0] === "disconnected"
+ )[1];
+ const connectionClose = mockAmqpConnection.on.mock.calls.find(
+ (call) => call[0] === "connection_close"
+ )[1];
+
+ disconnected();
+ connectionClose();
+
+ expect(handler).toHaveBeenCalledTimes(1);
+ expect(handler).toHaveBeenCalledWith(conn);
+ });
});
diff --git a/modules/src/common.js b/modules/src/common.js
index 16dd6fa5..3ee1ae6b 100644
--- a/modules/src/common.js
+++ b/modules/src/common.js
@@ -39,6 +39,8 @@ export const META_ANNOTATION_STATE_DIR = "vms/state-dir";
export const META_ANNOTATION_STATE_TYPE = "vms/state-type";
export const META_ANNOTATION_STATE_ID = "vms/state-id";
export const META_ANNOTATION_TLS_INJECT = "vms/tls-inject";
+export const META_ANNOTATION_TLS_ORDINAL = "vms/tls-ordinal";
+export const META_ANNOTATION_TLS_LAST_VALID = "vms/tls-last-valid";
//
// State types
diff --git a/modules/src/common.test.js b/modules/src/common.test.js
index fd306124..a796009f 100644
--- a/modules/src/common.test.js
+++ b/modules/src/common.test.js
@@ -23,6 +23,8 @@ import {
CLAIM_ASSERT_ADDRESS,
META_ANNOTATION_VMS_CONTROLLED,
META_ANNOTATION_STATE_ID,
+ META_ANNOTATION_TLS_ORDINAL,
+ META_ANNOTATION_TLS_LAST_VALID,
STATE_TYPE_LINK,
MEMBER_CONFIG_MAP_NAME,
} from "./common.js";
@@ -36,6 +38,8 @@ describe("common constants", () => {
it("exports kubernetes annotation keys", () => {
expect(META_ANNOTATION_VMS_CONTROLLED).toBe("skupper.io/vms-controlled");
expect(META_ANNOTATION_STATE_ID).toBe("vms/state-id");
+ expect(META_ANNOTATION_TLS_ORDINAL).toBe("vms/tls-ordinal");
+ expect(META_ANNOTATION_TLS_LAST_VALID).toBe("vms/tls-last-valid");
});
it("exports state and object names", () => {
diff --git a/modules/src/kube.js b/modules/src/kube.js
index 11366018..1ba67195 100644
--- a/modules/src/kube.js
+++ b/modules/src/kube.js
@@ -149,6 +149,62 @@ export async function LoadCertificate(name) {
});
}
+export function kubeStatusCode(err) {
+ const direct = err?.statusCode || err?.code || err?.response?.statusCode;
+ if (typeof direct === "number") {
+ return direct;
+ }
+ const match = /HTTP-Code:\s*(\d+)/.exec(err?.message || "");
+ return match ? Number(match[1]) : direct;
+}
+
+export function markCertificateForRenewal(cert, now = new Date()) {
+ const issuing = {
+ type: "Issuing",
+ status: "True",
+ reason: "ManuallyTriggered",
+ message: "Certificate re-issuance manually triggered",
+ lastTransitionTime: now.toISOString(),
+ };
+ if (cert.metadata?.generation !== undefined) {
+ issuing.observedGeneration = cert.metadata.generation;
+ }
+ const existing = Array.isArray(cert.status?.conditions) ? cert.status.conditions : [];
+ const conditions = existing.filter((condition) => condition.type !== "Issuing");
+ conditions.push(issuing);
+ return {
+ ...cert,
+ status: {
+ ...cert.status,
+ conditions,
+ },
+ };
+}
+
+export async function TriggerCertificateRenewal(name) {
+ const cert = await LoadCertificate(name);
+ const body = markCertificateForRenewal(cert);
+ return await customApi.replaceNamespacedCustomObjectStatus({
+ group: "cert-manager.io",
+ version: "v1",
+ namespace: namespace,
+ plural: "certificates",
+ name: name,
+ body,
+ });
+}
+
+export async function ReplaceCertificate(obj) {
+ return await customApi.replaceNamespacedCustomObject({
+ group: "cert-manager.io",
+ version: "v1",
+ namespace: obj.metadata?.namespace || namespace,
+ plural: "certificates",
+ name: obj.metadata.name,
+ body: obj,
+ });
+}
+
export async function DeleteCertificate(name) {
await customApi.deleteNamespacedCustomObject({
group: "cert-manager.io",
@@ -175,10 +231,10 @@ export async function LoadSecret(name, ns) {
}
}
-export async function ReplaceSecret(name, obj) {
+export async function ReplaceSecret(name, obj, ns) {
await v1Api.replaceNamespacedSecret({
name: name,
- namespace: namespace,
+ namespace: ns || namespace,
body: obj,
});
}
diff --git a/modules/src/kube.test.js b/modules/src/kube.test.js
index 128f8582..0b38e3bd 100644
--- a/modules/src/kube.test.js
+++ b/modules/src/kube.test.js
@@ -17,10 +17,39 @@
under the License.
*/
-import { describe, it, expect } from "vitest";
-import { Annotation, Controlled, Namespace } from "./kube.js";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import {
+ Annotation,
+ Controlled,
+ Namespace,
+ kubeStatusCode,
+ markCertificateForRenewal,
+ Start,
+ TriggerCertificateRenewal,
+ ReplaceCertificate,
+ ReplaceSecret,
+ LoadCertificate,
+} from "./kube.js";
import { META_ANNOTATION_VMS_CONTROLLED, META_ANNOTATION_STATE_ID } from "./common.js";
+function createFakeK8s(api) {
+ class KubeConfig {
+ loadFromCluster() {}
+ loadFromDefault() {}
+ makeApiClient() {
+ return api;
+ }
+ }
+ return {
+ KubeConfig,
+ KubernetesObjectApi: { makeApiClient: () => api },
+ Watch: class {},
+ CoreV1Api: {},
+ AppsV1Api: {},
+ CustomObjectsApi: {},
+ };
+}
+
describe("kube helpers", () => {
it("Annotation reads metadata annotations", () => {
const obj = {
@@ -62,3 +91,120 @@ describe("kube helpers", () => {
expect(Namespace()).toBe("default");
});
});
+
+describe("kubeStatusCode", () => {
+ it("reads numeric status from error fields or HTTP-Code in the message", () => {
+ expect(kubeStatusCode({ statusCode: 409 })).toBe(409);
+ expect(kubeStatusCode({ code: 404 })).toBe(404);
+ expect(kubeStatusCode({ response: { statusCode: 500 } })).toBe(500);
+ expect(kubeStatusCode({ message: "HTTP-Code: 409 Conflict" })).toBe(409);
+ expect(kubeStatusCode({ message: "no status" })).toBeUndefined();
+ });
+});
+
+describe("markCertificateForRenewal", () => {
+ it("replaces an existing Issuing condition and records observedGeneration", () => {
+ const now = new Date("2026-09-08T12:00:00.000Z");
+ const cert = {
+ metadata: { name: "site-cert", generation: 7 },
+ status: {
+ notAfter: "2099-01-01T00:00:00Z",
+ conditions: [
+ { type: "Ready", status: "True" },
+ { type: "Issuing", status: "False", reason: "Old" },
+ ],
+ },
+ };
+
+ const marked = markCertificateForRenewal(cert, now);
+
+ expect(marked.status.notAfter).toBe("2099-01-01T00:00:00Z");
+ expect(marked.status.conditions).toEqual([
+ { type: "Ready", status: "True" },
+ {
+ type: "Issuing",
+ status: "True",
+ reason: "ManuallyTriggered",
+ message: "Certificate re-issuance manually triggered",
+ lastTransitionTime: now.toISOString(),
+ observedGeneration: 7,
+ },
+ ]);
+ expect(cert.status.conditions).toHaveLength(2);
+ });
+});
+
+describe("certificate and secret writes", () => {
+ let api;
+
+ beforeEach(async () => {
+ api = {
+ getNamespacedCustomObject: vi.fn(async () => ({
+ metadata: { name: "site-cert", generation: 3, namespace: "myns" },
+ status: { conditions: [] },
+ })),
+ replaceNamespacedCustomObjectStatus: vi.fn(async (args) => args.body),
+ replaceNamespacedCustomObject: vi.fn(async (args) => args.body),
+ replaceNamespacedSecret: vi.fn(async (args) => args.body),
+ };
+ await Start(createFakeK8s(api), { readFileSync: () => "myns" }, {}, "myns");
+ });
+
+ it("TriggerCertificateRenewal patches certificate status", async () => {
+ await TriggerCertificateRenewal("site-cert");
+
+ expect(api.getNamespacedCustomObject).toHaveBeenCalledWith(
+ expect.objectContaining({
+ plural: "certificates",
+ name: "site-cert",
+ namespace: "myns",
+ })
+ );
+ expect(api.replaceNamespacedCustomObjectStatus).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "site-cert",
+ namespace: "myns",
+ body: expect.objectContaining({
+ status: expect.objectContaining({
+ conditions: expect.arrayContaining([
+ expect.objectContaining({
+ type: "Issuing",
+ reason: "ManuallyTriggered",
+ observedGeneration: 3,
+ }),
+ ]),
+ }),
+ }),
+ })
+ );
+ });
+
+ it("ReplaceCertificate writes the certificate object", async () => {
+ const cert = { metadata: { name: "site-cert", namespace: "other-ns" } };
+ await ReplaceCertificate(cert);
+ expect(api.replaceNamespacedCustomObject).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: "site-cert",
+ namespace: "other-ns",
+ body: cert,
+ })
+ );
+ });
+
+ it("ReplaceSecret uses the provided namespace when set", async () => {
+ const secret = { metadata: { name: "tls-secret" } };
+ await ReplaceSecret("tls-secret", secret, "colo-ns");
+ expect(api.replaceNamespacedSecret).toHaveBeenCalledWith({
+ name: "tls-secret",
+ namespace: "colo-ns",
+ body: secret,
+ });
+ });
+
+ it("LoadCertificate reads the named certificate", async () => {
+ await LoadCertificate("site-cert");
+ expect(api.getNamespacedCustomObject).toHaveBeenCalledWith(
+ expect.objectContaining({ name: "site-cert", namespace: "myns" })
+ );
+ });
+});