Add http cert syncing for use with the controller

This commit is contained in:
Owen
2026-05-01 15:42:38 -07:00
parent 53e096f7cb
commit 4524bdc094
2 changed files with 396 additions and 178 deletions

View File

@@ -274,6 +274,216 @@ function detectWildcard(
return { wildcard: false, wildcardSan: null }; return { wildcard: false, wildcardSan: null };
} }
interface HttpCert {
wildcard: boolean;
altName: string;
certName: string;
commonName: string;
certFile: string;
keyFile: string;
}
async function syncAcmeCertsFromHttp(endpoint: string): Promise<void> {
let response: Response;
try {
response = await fetch(endpoint);
} catch (err) {
logger.debug(
`acmeCertSync: could not reach HTTP endpoint ${endpoint}: ${err}`
);
return;
}
if (!response.ok) {
logger.debug(
`acmeCertSync: HTTP endpoint returned status ${response.status}`
);
return;
}
let httpCerts: HttpCert[];
try {
httpCerts = await response.json();
} catch (err) {
logger.debug(
`acmeCertSync: could not parse JSON from HTTP endpoint: ${err}`
);
return;
}
if (!Array.isArray(httpCerts) || httpCerts.length === 0) {
logger.debug(
`acmeCertSync: no certificates returned from HTTP endpoint`
);
return;
}
for (const cert of httpCerts) {
const domain = cert?.certName;
if (!domain || typeof domain !== "string") {
logger.debug(
`acmeCertSync: skipping HTTP cert with missing certName`
);
continue;
}
const certPem = cert.certFile;
const keyPem = cert.keyFile;
if (!certPem?.trim() || !keyPem?.trim()) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - empty certFile or keyFile`
);
continue;
}
const firstCertPemForValidation = extractFirstCert(certPem);
if (!firstCertPemForValidation) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - no PEM certificate block found`
);
continue;
}
let validatedX509: crypto.X509Certificate;
try {
validatedX509 = new crypto.X509Certificate(
firstCertPemForValidation
);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid X.509 certificate: ${err}`
);
continue;
}
try {
crypto.createPrivateKey(keyPem);
} catch (err) {
logger.debug(
`acmeCertSync: skipping HTTP cert for ${domain} - invalid private key: ${err}`
);
continue;
}
const wildcard = cert.wildcard ?? false;
const existing = await db
.select()
.from(certificates)
.where(eq(certificates.domain, domain))
.limit(1);
let oldCertPem: string | null = null;
let oldKeyPem: string | null = null;
if (existing.length > 0 && existing[0].certFile) {
try {
const storedCertPem = decrypt(
existing[0].certFile,
config.getRawConfig().server.secret!
);
const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) {
continue;
}
oldCertPem = storedCertPem;
if (existing[0].keyFile) {
try {
oldKeyPem = decrypt(
existing[0].keyFile,
config.getRawConfig().server.secret!
);
} catch (keyErr) {
logger.debug(
`acmeCertSync: could not decrypt stored key for ${domain}: ${keyErr}`
);
}
}
} catch (err) {
logger.debug(
`acmeCertSync: could not decrypt stored cert for ${domain}, will update: ${err}`
);
}
}
let expiresAt: number | null = null;
try {
expiresAt = Math.floor(
new Date(validatedX509.validTo).getTime() / 1000
);
} catch (err) {
logger.debug(
`acmeCertSync: could not parse cert expiry for ${domain}: ${err}`
);
}
const encryptedCert = encrypt(
certPem,
config.getRawConfig().server.secret!
);
const encryptedKey = encrypt(
keyPem,
config.getRawConfig().server.secret!
);
const now = Math.floor(Date.now() / 1000);
const domainId = await findDomainId(domain);
if (domainId) {
logger.debug(
`acmeCertSync: resolved domainId "${domainId}" for HTTP cert domain "${domain}"`
);
} else {
logger.debug(
`acmeCertSync: no matching domain record found for HTTP cert domain "${domain}"`
);
}
if (existing.length > 0) {
logger.debug(
`acmeCertSync: updating existing certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db
.update(certificates)
.set({
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
updatedAt: now,
wildcard,
...(domainId !== null && { domainId })
})
.where(eq(certificates.domain, domain));
await pushCertUpdateToAffectedNewts(
domain,
domainId,
oldCertPem,
oldKeyPem
);
} else {
logger.debug(
`acmeCertSync: inserting new certificate (HTTP) for ${domain} (expires ${expiresAt ? new Date(expiresAt * 1000).toISOString() : "unknown"})`
);
await db.insert(certificates).values({
domain,
domainId,
certFile: encryptedCert,
keyFile: encryptedKey,
status: "valid",
expiresAt,
createdAt: now,
updatedAt: now,
wildcard
});
await pushCertUpdateToAffectedNewts(domain, domainId, null, null);
}
}
}
async function syncAcmeCerts(acmeJsonPath: string): Promise<void> { async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
let raw: string; let raw: string;
try { try {
@@ -389,11 +599,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
const existing = await db const existing = await db
.select() .select()
.from(certificates) .from(certificates)
.where( .where(and(eq(certificates.domain, domain)))
and(
eq(certificates.domain, domain)
)
)
.limit(1); .limit(1);
let oldCertPem: string | null = null; let oldCertPem: string | null = null;
@@ -408,7 +614,7 @@ async function syncAcmeCerts(acmeJsonPath: string): Promise<void> {
const wildcardUnchanged = existing[0].wildcard === wildcard; const wildcardUnchanged = existing[0].wildcard === wildcard;
if (storedCertPem === certPem && wildcardUnchanged) { if (storedCertPem === certPem && wildcardUnchanged) {
// logger.debug( // logger.debug(
// `acmeCertSync: cert for ${domain} is unchanged, skipping` // `acmeCertSync: cert for ${domain} is unchanged, skipping`
// ); // );
continue; continue;
} }
@@ -547,19 +753,32 @@ export function initAcmeCertSync(): void {
privateConfigData.acme?.acme_json_path ?? privateConfigData.acme?.acme_json_path ??
"config/letsencrypt/acme.json"; "config/letsencrypt/acme.json";
const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000; const intervalMs = privateConfigData.acme?.sync_interval_ms ?? 5000;
const httpEndpoint = privateConfigData.acme?.acme_http_endpoint;
logger.debug( logger.debug(
`acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms` `acmeCertSync: starting ACME cert sync from "${acmeJsonPath}" across all resolvers every ${intervalMs}ms`
); );
if (httpEndpoint) {
logger.debug(
`acmeCertSync: also syncing from HTTP endpoint "${httpEndpoint}" every ${intervalMs}ms`
);
}
const runSync = () => {
if (httpEndpoint) {
syncAcmeCertsFromHttp(httpEndpoint).catch((err) => {
logger.error(`acmeCertSync: error during HTTP sync: ${err}`);
});
} else {
// only run the file-based sync if the HTTP endpoint is not configured, to avoid doubling up
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}
};
// Run immediately on init, then on the configured interval // Run immediately on init, then on the configured interval
syncAcmeCerts(acmeJsonPath).catch((err) => { runSync();
logger.error(`acmeCertSync: error during initial sync: ${err}`);
});
setInterval(() => { setInterval(runSync, intervalMs);
syncAcmeCerts(acmeJsonPath).catch((err) => {
logger.error(`acmeCertSync: error during sync: ${err}`);
});
}, intervalMs);
} }

View File

@@ -21,173 +21,172 @@ import { getEnvOrYaml } from "@server/lib/getEnvOrYaml";
const portSchema = z.number().positive().gt(0).lte(65535); const portSchema = z.number().positive().gt(0).lte(65535);
export const privateConfigSchema = z.object({ export const privateConfigSchema = z
app: z .object({
.object({ app: z
region: z.string().optional().default("default"), .object({
base_domain: z.string().optional(), region: z.string().optional().default("default"),
identity_provider_mode: z.enum(["global", "org"]).optional() base_domain: z.string().optional(),
}) identity_provider_mode: z.enum(["global", "org"]).optional()
.optional() })
.default({ .optional()
region: "default" .default({
}), region: "default"
server: z }),
.object({ server: z
reo_client_id: z .object({
.string() reo_client_id: z
.optional() .string()
.transform(getEnvOrYaml("REO_CLIENT_ID")), .optional()
fossorial_api: z .transform(getEnvOrYaml("REO_CLIENT_ID")),
.string() fossorial_api: z
.optional() .string()
.default("https://api.fossorial.io"), .optional()
fossorial_api_key: z .default("https://api.fossorial.io"),
.string() fossorial_api_key: z
.optional() .string()
.transform(getEnvOrYaml("FOSSORIAL_API_KEY")) .optional()
}) .transform(getEnvOrYaml("FOSSORIAL_API_KEY"))
.optional() })
.prefault({}), .optional()
redis: z .prefault({}),
.object({ redis: z
host: z.string(), .object({
port: portSchema, host: z.string(),
password: z port: portSchema,
.string() password: z
.optional() .string()
.transform(getEnvOrYaml("REDIS_PASSWORD")), .optional()
db: z.int().nonnegative().optional().default(0), .transform(getEnvOrYaml("REDIS_PASSWORD")),
replicas: z db: z.int().nonnegative().optional().default(0),
.array( replicas: z
z.object({ .array(
host: z.string(), z.object({
port: portSchema, host: z.string(),
password: z.string().optional(), port: portSchema,
db: z.int().nonnegative().optional().default(0) password: z.string().optional(),
db: z.int().nonnegative().optional().default(0)
})
)
.optional(),
tls: z
.object({
rejectUnauthorized: z.boolean().optional().default(true)
}) })
) .optional()
.optional(), })
tls: z .optional(),
.object({ gerbil: z
rejectUnauthorized: z .object({
.boolean() local_exit_node_reachable_at: z
.optional() .string()
.default(true) .optional()
}) .default("http://gerbil:3004")
.optional() })
}) .optional()
.optional(), .prefault({}),
gerbil: z flags: z
.object({ .object({
local_exit_node_reachable_at: z enable_redis: z.boolean().optional().default(false),
.string() use_pangolin_dns: z.boolean().optional().default(false),
.optional() use_org_only_idp: z.boolean().optional(),
.default("http://gerbil:3004") enable_acme_cert_sync: z.boolean().optional().default(true)
}) })
.optional() .optional()
.prefault({}), .prefault({}),
flags: z acme: z
.object({ .object({
enable_redis: z.boolean().optional().default(false), acme_json_path: z
use_pangolin_dns: z.boolean().optional().default(false), .string()
use_org_only_idp: z.boolean().optional(), .optional()
enable_acme_cert_sync: z.boolean().optional().default(true) .default("config/letsencrypt/acme.json"),
}) acme_http_endpoint: z.string().optional(),
.optional() sync_interval_ms: z.number().optional().default(5000)
.prefault({}), })
acme: z .optional(),
.object({ branding: z
acme_json_path: z .object({
.string() app_name: z.string().optional(),
.optional() background_image_path: z.string().optional(),
.default("config/letsencrypt/acme.json"), colors: z
sync_interval_ms: z.number().optional().default(5000) .object({
}) light: colorsSchema.optional(),
.optional(), dark: colorsSchema.optional()
branding: z
.object({
app_name: z.string().optional(),
background_image_path: z.string().optional(),
colors: z
.object({
light: colorsSchema.optional(),
dark: colorsSchema.optional()
})
.optional(),
logo: z
.object({
light_path: z.string().optional(),
dark_path: z.string().optional(),
auth_page: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional(),
navbar: z
.object({
width: z.number().optional(),
height: z.number().optional()
})
.optional()
})
.optional(),
footer: z
.array(
z.object({
text: z.string(),
href: z.string().optional()
}) })
) .optional(),
.optional(), logo: z
hide_auth_layout_footer: z.boolean().optional().default(false), .object({
login_page: z light_path: z.string().optional(),
.object({ dark_path: z.string().optional(),
subtitle_text: z.string().optional() auth_page: z
}) .object({
.optional(), width: z.number().optional(),
signup_page: z height: z.number().optional()
.object({ })
subtitle_text: z.string().optional() .optional(),
}) navbar: z
.optional(), .object({
resource_auth_page: z width: z.number().optional(),
.object({ height: z.number().optional()
show_logo: z.boolean().optional(), })
hide_powered_by: z.boolean().optional(), .optional()
title_text: z.string().optional(), })
subtitle_text: z.string().optional() .optional(),
}) footer: z
.optional(), .array(
emails: z z.object({
.object({ text: z.string(),
signature: z.string().optional(), href: z.string().optional()
colors: z
.object({
primary: z.string().optional()
}) })
.optional() )
}) .optional(),
.optional() hide_auth_layout_footer: z.boolean().optional().default(false),
}) login_page: z
.optional(), .object({
stripe: z subtitle_text: z.string().optional()
.object({ })
secret_key: z .optional(),
.string() signup_page: z
.optional() .object({
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")), subtitle_text: z.string().optional()
webhook_secret: z })
.string() .optional(),
.optional() resource_auth_page: z
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET")), .object({
// s3Bucket: z.string(), show_logo: z.boolean().optional(),
// s3Region: z.string().default("us-east-1"), hide_powered_by: z.boolean().optional(),
// localFilePath: z.string().optional() title_text: z.string().optional(),
}) subtitle_text: z.string().optional()
.optional() })
}) .optional(),
emails: z
.object({
signature: z.string().optional(),
colors: z
.object({
primary: z.string().optional()
})
.optional()
})
.optional()
})
.optional(),
stripe: z
.object({
secret_key: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_SECRET_KEY")),
webhook_secret: z
.string()
.optional()
.transform(getEnvOrYaml("STRIPE_WEBHOOK_SECRET"))
// s3Bucket: z.string(),
// s3Region: z.string().default("us-east-1"),
// localFilePath: z.string().optional()
})
.optional()
})
.transform((data) => { .transform((data) => {
// this to maintain backwards compatibility with the old config file // this to maintain backwards compatibility with the old config file
const identityProviderMode = data.app?.identity_provider_mode; const identityProviderMode = data.app?.identity_provider_mode;