diff --git a/charts/helmfile/resources/db-setup.sql b/charts/helmfile/resources/db-setup.sql index 3d31d13c..6801c1b7 100644 --- a/charts/helmfile/resources/db-setup.sql +++ b/charts/helmfile/resources/db-setup.sql @@ -138,7 +138,8 @@ CREATE TABLE TlsCertificates ( SignedBy UUID REFERENCES TlsCertificates, -- NULL => signed by the Root Issuer Expiration timestamptz, RenewalTime timestamptz, - Generation integer DEFAULT 0, + RotationOrdinal integer NOT NULL DEFAULT 0, -- successor ordinal = predecessor.RotationOrdinal + 1 + Supercedes UUID UNIQUE REFERENCES TlsCertificates, -- the TlsCertificate this row replaced; NULL for first issue Label text ); @@ -167,7 +168,7 @@ CREATE TABLE Backbones ( Certificate UUID REFERENCES TlsCertificates ON DELETE CASCADE, CoLocatedNamespace text UNIQUE DEFAULT NULL, Owner UUID REFERENCES Users, - OwnerGroup text + OwnerGroup text ); -- @@ -370,6 +371,12 @@ CREATE TABLE CertificateRequests ( -- DurationHours integer, + -- + -- If set, this request replaces an existing TlsCertificates row (rotation) + -- rather than first-issue. secretAdded uses this to preserve owner Lifecycle. + -- + Supercedes UUID REFERENCES TlsCertificates, + -- -- Link to the requesting -- @@ -387,7 +394,7 @@ CREATE TABLE CertificateRequests ( -- Pre-populate the database with some test data. -- INSERT INTO Configuration (Id, RootIssuer, DefaultCaExpiration, DefaultCertExpiration, BackboneCaExpiration, SiteControllerImage, CertOrganization) - VALUES (0, 'vms-root', '30 days', '1 week', '1 year', 'quay.io/skupper/vms-site-controller:latest', 'enterprise.com'); + VALUES (0, 'vms-root', '1 year', '90 days', '5 years', 'quay.io/skupper/vms-site-controller:latest', 'enterprise.com'); INSERT INTO TargetPlatforms (ShortName, LongName) VALUES ('sk2', 'Kubernetes/OpenShift'), @@ -409,8 +416,8 @@ CREATE POLICY user_access_backbones_policy ON Backbones FOR ALL USING ( - Owner = NULLIF(current_setting('session.user_id', true), '')::uuid - OR + Owner = NULLIF(current_setting('session.user_id', true), '')::uuid + OR is_admin() ); @@ -427,8 +434,8 @@ CREATE POLICY user_access_application_networks_policy ON ApplicationNetworks FOR ALL USING ( - Owner = NULLIF(current_setting('session.user_id', true), '')::uuid - OR + Owner = NULLIF(current_setting('session.user_id', true), '')::uuid + OR is_admin() ); @@ -445,8 +452,8 @@ CREATE POLICY user_access_backbone_access_points_policy ON BackboneAccessPoints FOR ALL USING ( - Owner = NULLIF(current_setting('session.user_id', true), '')::uuid - OR + Owner = NULLIF(current_setting('session.user_id', true), '')::uuid + OR is_admin() ); @@ -463,8 +470,8 @@ CREATE POLICY user_access_interior_sites_policy ON InteriorSites FOR ALL USING ( - Owner = NULLIF(current_setting('session.user_id', true), '')::uuid - OR + Owner = NULLIF(current_setting('session.user_id', true), '')::uuid + OR is_admin() ); @@ -481,8 +488,8 @@ CREATE POLICY user_access_inter_router_links_policy ON InterRouterLinks FOR ALL USING ( - Owner = NULLIF(current_setting('session.user_id', true), '')::uuid - OR + Owner = NULLIF(current_setting('session.user_id', true), '')::uuid + OR is_admin() ); diff --git a/charts/management-server/templates/rbac.yaml b/charts/management-server/templates/rbac.yaml index 7878dead..15ce30ec 100644 --- a/charts/management-server/templates/rbac.yaml +++ b/charts/management-server/templates/rbac.yaml @@ -30,6 +30,14 @@ rules: - update - delete - patch +- apiGroups: + - cert-manager.io + resources: + - certificates/status + verbs: + - get + - patch + - update --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding diff --git a/components/console/src/index.css b/components/console/src/index.css index 44e85726..87c15d8a 100644 --- a/components/console/src/index.css +++ b/components/console/src/index.css @@ -68,4 +68,12 @@ body { color: var(--cds-text-secondary); } +tr.tls-cert-row--superseded { + opacity: 0.55; +} + +.cds--data-table tr.tls-cert-row--superseded td { + color: var(--cds-text-disabled); +} + /* Made with Bob */ diff --git a/components/console/src/pages/TLS/TLS.jsx b/components/console/src/pages/TLS/TLS.jsx index 97092de8..62227718 100644 --- a/components/console/src/pages/TLS/TLS.jsx +++ b/components/console/src/pages/TLS/TLS.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useMemo } from "react"; import { Breadcrumb, BreadcrumbItem, @@ -19,6 +19,73 @@ import { OverflowMenuItem, } from "@carbon/react"; import { Certificate, DocumentSigned } from "@carbon/icons-react"; +import { CancelWatch, CreateWatch } from "../../tools/watch"; + +const certsUrl = ({ signedBy } = {}) => { + if (signedBy) { + return `/api/v1alpha1/certs?signedby=${signedBy}`; + } + return "/api/v1alpha1/certs"; +}; + +const collectKnownCerts = (rootCerts, childrenByIssuer) => { + const byId = new Map(); + for (const cert of rootCerts) { + byId.set(cert.id, cert); + } + for (const children of Object.values(childrenByIssuer)) { + if (!Array.isArray(children)) { + continue; + } + for (const cert of children) { + byId.set(cert.id, cert); + } + } + return [...byId.values()]; +}; + +const isCertSuperseded = (cert, knownCerts) => { + if (cert.superseded === true) { + return true; + } + if (knownCerts.some((other) => other.supercedes === cert.id)) { + return true; + } + if (!cert.objectname) { + return false; + } + const ordinal = cert.rotationordinal ?? 0; + return knownCerts.some( + (other) => other.objectname === cert.objectname && (other.rotationordinal ?? 0) > ordinal + ); +}; + +const sortCertsActiveFirst = (certs, knownCerts) => + [...certs].sort((a, b) => { + const aSuperseded = isCertSuperseded(a, knownCerts); + const bSuperseded = isCertSuperseded(b, knownCerts); + if (aSuperseded !== bSuperseded) { + return aSuperseded ? 1 : -1; + } + return (a.label || a.id).localeCompare(b.label || b.id); + }); + +const postCertAction = async (certId, action) => { + const response = await fetch(`/api/v1alpha1/certs/${certId}/${action}`, { + method: "POST", + }); + if (!response.ok) { + const text = await response.text(); + const error = new Error(text || `HTTP error! status: ${response.status}`); + error.status = response.status; + throw error; + } + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("application/json")) { + return response.json(); + } + return null; +}; const TLS = () => { const [certificates, setCertificates] = useState([]); @@ -27,52 +94,89 @@ const TLS = () => { const [expandedRows, setExpandedRows] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [actionNotice, setActionNotice] = useState(null); + const [actionBusy, setActionBusy] = useState(false); - useEffect(() => { - fetchCertificates(); - }, []); + const expandedIssuerIds = useMemo( + () => + Object.entries(expandedRows) + .filter(([, isExpanded]) => isExpanded) + .map(([id]) => id) + .sort((a, b) => a.localeCompare(b)) + .join(","), + [expandedRows] + ); - const fetchCertificates = async () => { - try { - setLoading(true); - setError(null); - const response = await fetch("/api/v1alpha1/certs"); + useEffect(() => { + setExpandedRows({}); + setChildCerts({}); + setLoadingChildren({}); + setLoading(true); + setError(null); - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const watchContext = CreateWatch(certsUrl(), function (message) { + const body = message.body; + if (body.method === "GET" || body.method === "UPDATE") { + if (body.statusCode >= 200 && body.statusCode < 300) { + setCertificates(body.content); + setError(null); + setLoading(false); + } else { + setError(body.content); + setLoading(false); + } } + }); - const data = await response.json(); - setCertificates(data); - } catch (err) { - setError(err.message); - console.error("Error fetching certificates:", err); - } finally { - setLoading(false); - } - }; + return () => { + CancelWatch(watchContext); + }; + }, []); - const fetchChildCertificates = async (issuerId) => { - if (childCerts[issuerId]) { - return; // Already fetched + useEffect(() => { + if (!expandedIssuerIds) { + return undefined; } - try { - setLoadingChildren((prev) => ({ ...prev, [issuerId]: true })); - const response = await fetch(`/api/v1alpha1/certs?signedby=${issuerId}`); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); + const issuerIds = expandedIssuerIds.split(","); + setLoadingChildren((prev) => { + const next = { ...prev }; + let changed = false; + for (const issuerId of issuerIds) { + if (next[issuerId] !== false) { + next[issuerId] = true; + changed = true; + } } + return changed ? next : prev; + }); - const data = await response.json(); - setChildCerts((prev) => ({ ...prev, [issuerId]: data })); - } catch (err) { - console.error("Error fetching child certificates:", err); - } finally { - setLoadingChildren((prev) => ({ ...prev, [issuerId]: false })); - } - }; + const watches = issuerIds.map((issuerId) => + CreateWatch(certsUrl({ signedBy: issuerId }), function (message) { + const body = message.body; + if (body.method === "GET" || body.method === "UPDATE") { + if (body.statusCode >= 200 && body.statusCode < 300) { + setChildCerts((prev) => ({ ...prev, [issuerId]: body.content })); + } + setLoadingChildren((prev) => ({ ...prev, [issuerId]: false })); + } + }) + ); + + return () => { + watches.forEach(CancelWatch); + }; + }, [expandedIssuerIds]); + + const knownCerts = useMemo( + () => collectKnownCerts(certificates, childCerts), + [certificates, childCerts] + ); + + const sortedRootCerts = useMemo( + () => sortCertsActiveFirst(certificates, knownCerts), + [certificates, knownCerts] + ); const formatDate = (dateString) => { if (!dateString) return "N/A"; @@ -113,22 +217,74 @@ const TLS = () => { ); }; - const handleRevokeAndRotate = (cert) => { - // TODO: Implement revoke and rotate functionality - console.log("Revoke and Rotate:", cert); + const handleRotate = async (cert) => { + if (isCertSuperseded(cert, knownCerts)) { + return; + } + try { + setActionBusy(true); + setActionNotice(null); + await postCertAction(cert.id, "rotate"); + setActionNotice({ + kind: "success", + title: "Certificate rotation requested", + subtitle: cert.label || cert.id, + }); + } catch (err) { + setActionNotice({ + kind: "error", + title: + err.status === 409 ? "Cannot rotate certificate" : "Error rotating certificate", + subtitle: err.message, + }); + } finally { + setActionBusy(false); + } }; - const handleRevoke = (cert) => { - // TODO: Implement revoke functionality - console.log("Revoke:", cert); + const handleRevoke = (_cert) => { + // Revocation is not implemented yet. }; + const renderCertActions = (cert) => { + const superseded = isCertSuperseded(cert, knownCerts); + return ( + + handleRotate(cert)} + /> + handleRevoke(cert)} + /> + + ); + }; + + const renderGenerationCell = (cert, superseded) => ( + +
+ {cert.rotationordinal ?? 0} + {superseded && ( + + Superseded + + )} +
+
+ ); + const headers = [ { key: "type", header: "Type" }, { key: "label", header: "Label" }, { key: "expiration", header: "Expiration" }, { key: "renewaltime", header: "Renewal Time" }, - { key: "generation", header: "Gen" }, + { key: "rotationordinal", header: "Gen" }, { key: "actions", header: "" }, ]; @@ -137,16 +293,14 @@ const TLS = () => { const isExpanded = expandedRows[cert.id] || false; const children = childCerts[cert.id] || []; const isLoadingChildren = loadingChildren[cert.id] || false; + const superseded = isCertSuperseded(cert, knownCerts); + const rowClassName = superseded ? "tls-cert-row--superseded" : undefined; const handleExpand = () => { setExpandedRows((prev) => ({ ...prev, [cert.id]: !prev[cert.id], })); - - if (!childCerts[cert.id] && !isExpanded) { - fetchChildCertificates(cert.id); - } }; const indentStyle = { @@ -157,7 +311,11 @@ const TLS = () => { // CA certificates - use TableExpandRow return ( - +
{renderIcon(true)} @@ -171,20 +329,8 @@ const TLS = () => { {formatDate(cert.renewaltime)} - {cert.generation} - - - handleRevokeAndRotate(cert)} - /> - handleRevoke(cert)} - /> - - + {renderGenerationCell(cert, superseded)} + {renderCertActions(cert)} {isExpanded && isLoadingChildren && ( @@ -203,7 +349,7 @@ const TLS = () => { {isExpanded && !isLoadingChildren && children.length > 0 && - children.map((childCert) => ( + sortCertsActiveFirst(children, knownCerts).map((childCert) => ( ))} @@ -211,7 +357,7 @@ const TLS = () => { } else { // Non-CA certificates - always need empty cell for expand column return ( - +
@@ -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" }) + ); + }); +});