Also allow local traefikConfig

This commit is contained in:
Owen 2025-08-17 21:44:28 -07:00
parent 8c8a981452
commit fbefcfedb9
No known key found for this signature in database
GPG key ID: 8271FDFFD9E0CCBD
4 changed files with 253 additions and 148 deletions

View file

@ -0,0 +1,78 @@
import axios from "axios";
import { tokenManager } from "../tokenManager";
import logger from "@server/logger";
import config from "../config";
/**
* Get valid certificates for the specified domains
*/
export async function getValidCertificatesForDomainsHybrid(domains: Set<string>): Promise<
Array<{
id: number;
domain: string;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
updatedAt?: Date | null;
}>
> {
if (domains.size === 0) {
return [];
}
const domainArray = Array.from(domains);
try {
const response = await axios.get(
`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/certificates/domains`,
{
params: {
domains: domainArray
},
headers: (await tokenManager.getAuthHeader()).headers
}
);
if (response.status !== 200) {
logger.error(
`Failed to fetch certificates for domains: ${response.status} ${response.statusText}`,
{ responseData: response.data, domains: domainArray }
);
return [];
}
// logger.debug(
// `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains`
// );
return response.data.data;
} catch (error) {
// pull data out of the axios error to log
if (axios.isAxiosError(error)) {
logger.error("Error getting certificates:", {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method
});
} else {
logger.error("Error getting certificates:", error);
}
return [];
}
}
export async function getValidCertificatesForDomains(domains: Set<string>): Promise<
Array<{
id: number;
domain: string;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
updatedAt?: Date | null;
}>
> {
return []; // stub
}

View file

@ -0,0 +1 @@
export * from "./certificates";

View file

@ -5,7 +5,16 @@ import logger from "@server/logger";
import * as yaml from "js-yaml"; import * as yaml from "js-yaml";
import axios from "axios"; import axios from "axios";
import { db, exitNodes } from "@server/db"; import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import { tokenManager } from "./tokenManager"; import { tokenManager } from "./tokenManager";
import {
getCurrentExitNodeId,
getTraefikConfig
} from "@server/routers/traefik";
import {
getValidCertificatesForDomains,
getValidCertificatesForDomainsHybrid
} from "./remoteCertificates";
export class TraefikConfigManager { export class TraefikConfigManager {
private intervalId: NodeJS.Timeout | null = null; private intervalId: NodeJS.Timeout | null = null;
@ -14,11 +23,14 @@ export class TraefikConfigManager {
private timeoutId: NodeJS.Timeout | null = null; private timeoutId: NodeJS.Timeout | null = null;
private lastCertificateFetch: Date | null = null; private lastCertificateFetch: Date | null = null;
private lastKnownDomains = new Set<string>(); private lastKnownDomains = new Set<string>();
private lastLocalCertificateState = new Map<string, { private lastLocalCertificateState = new Map<
exists: boolean; string,
lastModified: Date | null; {
expiresAt: Date | null; exists: boolean;
}>(); lastModified: Date | null;
expiresAt: Date | null;
}
>();
constructor() {} constructor() {}
@ -59,7 +71,9 @@ export class TraefikConfigManager {
// Initialize local certificate state // Initialize local certificate state
this.lastLocalCertificateState = await this.scanLocalCertificateState(); this.lastLocalCertificateState = await this.scanLocalCertificateState();
logger.info(`Found ${this.lastLocalCertificateState.size} existing certificate directories`); logger.info(
`Found ${this.lastLocalCertificateState.size} existing certificate directories`
);
// Run initial check // Run initial check
await this.HandleTraefikConfig(); await this.HandleTraefikConfig();
@ -94,40 +108,47 @@ export class TraefikConfigManager {
/** /**
* Scan local certificate directories to build current state * Scan local certificate directories to build current state
*/ */
private async scanLocalCertificateState(): Promise<Map<string, { private async scanLocalCertificateState(): Promise<
exists: boolean; Map<
lastModified: Date | null; string,
expiresAt: Date | null; {
}>> { exists: boolean;
lastModified: Date | null;
expiresAt: Date | null;
}
>
> {
const state = new Map(); const state = new Map();
const certsPath = config.getRawConfig().traefik.certificates_path; const certsPath = config.getRawConfig().traefik.certificates_path;
try { try {
if (!fs.existsSync(certsPath)) { if (!fs.existsSync(certsPath)) {
return state; return state;
} }
const certDirs = fs.readdirSync(certsPath, { withFileTypes: true }); const certDirs = fs.readdirSync(certsPath, { withFileTypes: true });
for (const dirent of certDirs) { for (const dirent of certDirs) {
if (!dirent.isDirectory()) continue; if (!dirent.isDirectory()) continue;
const domain = dirent.name; const domain = dirent.name;
const domainDir = path.join(certsPath, domain); const domainDir = path.join(certsPath, domain);
const certPath = path.join(domainDir, "cert.pem"); const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem"); const keyPath = path.join(domainDir, "key.pem");
const lastUpdatePath = path.join(domainDir, ".last_update"); const lastUpdatePath = path.join(domainDir, ".last_update");
const certExists = await this.fileExists(certPath); const certExists = await this.fileExists(certPath);
const keyExists = await this.fileExists(keyPath); const keyExists = await this.fileExists(keyPath);
const lastUpdateExists = await this.fileExists(lastUpdatePath); const lastUpdateExists = await this.fileExists(lastUpdatePath);
let lastModified: Date | null = null; let lastModified: Date | null = null;
let expiresAt: Date | null = null; let expiresAt: Date | null = null;
if (lastUpdateExists) { if (lastUpdateExists) {
try { try {
const lastUpdateStr = fs.readFileSync(lastUpdatePath, "utf8").trim(); const lastUpdateStr = fs
.readFileSync(lastUpdatePath, "utf8")
.trim();
lastModified = new Date(lastUpdateStr); lastModified = new Date(lastUpdateStr);
} catch { } catch {
// If we can't read the last update, fall back to file stats // If we can't read the last update, fall back to file stats
@ -139,7 +160,7 @@ export class TraefikConfigManager {
} }
} }
} }
state.set(domain, { state.set(domain, {
exists: certExists && keyExists, exists: certExists && keyExists,
lastModified, lastModified,
@ -149,7 +170,7 @@ export class TraefikConfigManager {
} catch (error) { } catch (error) {
logger.error("Error scanning local certificate state:", error); logger.error("Error scanning local certificate state:", error);
} }
return state; return state;
} }
@ -161,40 +182,51 @@ export class TraefikConfigManager {
if (!this.lastCertificateFetch) { if (!this.lastCertificateFetch) {
return true; return true;
} }
// Fetch if it's been more than 24 hours (for renewals) // Fetch if it's been more than 24 hours (for renewals)
const dayInMs = 24 * 60 * 60 * 1000; const dayInMs = 24 * 60 * 60 * 1000;
const timeSinceLastFetch = Date.now() - this.lastCertificateFetch.getTime(); const timeSinceLastFetch =
Date.now() - this.lastCertificateFetch.getTime();
if (timeSinceLastFetch > dayInMs) { if (timeSinceLastFetch > dayInMs) {
logger.info("Fetching certificates due to 24-hour renewal check"); logger.info("Fetching certificates due to 24-hour renewal check");
return true; return true;
} }
// Fetch if domains have changed // Fetch if domains have changed
if (this.lastKnownDomains.size !== currentDomains.size || if (
!Array.from(this.lastKnownDomains).every(domain => currentDomains.has(domain))) { this.lastKnownDomains.size !== currentDomains.size ||
!Array.from(this.lastKnownDomains).every((domain) =>
currentDomains.has(domain)
)
) {
logger.info("Fetching certificates due to domain changes"); logger.info("Fetching certificates due to domain changes");
return true; return true;
} }
// Check if any local certificates are missing or appear to be outdated // Check if any local certificates are missing or appear to be outdated
for (const domain of currentDomains) { for (const domain of currentDomains) {
const localState = this.lastLocalCertificateState.get(domain); const localState = this.lastLocalCertificateState.get(domain);
if (!localState || !localState.exists) { if (!localState || !localState.exists) {
logger.info(`Fetching certificates due to missing local cert for ${domain}`); logger.info(
`Fetching certificates due to missing local cert for ${domain}`
);
return true; return true;
} }
// Check if certificate is expiring soon (within 30 days) // Check if certificate is expiring soon (within 30 days)
if (localState.expiresAt) { if (localState.expiresAt) {
const daysUntilExpiry = (localState.expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24); const daysUntilExpiry =
(localState.expiresAt.getTime() - Date.now()) /
(1000 * 60 * 60 * 24);
if (daysUntilExpiry < 30) { if (daysUntilExpiry < 30) {
logger.info(`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`); logger.info(
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
);
return true; return true;
} }
} }
} }
return false; return false;
} }
@ -234,7 +266,8 @@ export class TraefikConfigManager {
} }
// Scan current local certificate state // Scan current local certificate state
this.lastLocalCertificateState = await this.scanLocalCertificateState(); this.lastLocalCertificateState =
await this.scanLocalCertificateState();
// Only fetch certificates if needed (domain changes, missing certs, or daily renewal check) // Only fetch certificates if needed (domain changes, missing certs, or daily renewal check)
let validCertificates: Array<{ let validCertificates: Array<{
@ -248,19 +281,33 @@ export class TraefikConfigManager {
if (this.shouldFetchCertificates(domains)) { if (this.shouldFetchCertificates(domains)) {
// Get valid certificates for active domains // Get valid certificates for active domains
validCertificates = await this.getValidCertificatesForDomains(domains); if (config.isHybridMode()) {
validCertificates =
await getValidCertificatesForDomainsHybrid(domains);
} else {
validCertificates =
await getValidCertificatesForDomains(domains);
}
this.lastCertificateFetch = new Date(); this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains); this.lastKnownDomains = new Set(domains);
logger.info(`Fetched ${validCertificates.length} certificates from remote`); logger.info(
`Fetched ${validCertificates.length} certificates from remote`
);
// Download and decrypt new certificates // Download and decrypt new certificates
await this.processValidCertificates(validCertificates); await this.processValidCertificates(validCertificates);
} else { } else {
const timeSinceLastFetch = this.lastCertificateFetch ? const timeSinceLastFetch = this.lastCertificateFetch
Math.round((Date.now() - this.lastCertificateFetch.getTime()) / (1000 * 60)) : 0; ? Math.round(
logger.debug(`Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`); (Date.now() - this.lastCertificateFetch.getTime()) /
(1000 * 60)
)
: 0;
logger.debug(
`Skipping certificate fetch - no changes detected and within 24-hour window (last fetch: ${timeSinceLastFetch} minutes ago)`
);
// Still need to ensure config is up to date with existing certificates // Still need to ensure config is up to date with existing certificates
await this.updateDynamicConfigFromLocalCerts(domains); await this.updateDynamicConfigFromLocalCerts(domains);
} }
@ -276,7 +323,18 @@ export class TraefikConfigManager {
// Send domains to SNI proxy // Send domains to SNI proxy
try { try {
const [exitNode] = await db.select().from(exitNodes).limit(1); let exitNode;
if (config.getRawConfig().gerbil.exit_node_name) {
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name!;
[exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.name, exitNodeName))
.limit(1);
} else {
[exitNode] = await db.select().from(exitNodes).limit(1);
}
if (exitNode) { if (exitNode) {
try { try {
await axios.post( await axios.post(
@ -300,7 +358,9 @@ export class TraefikConfigManager {
} }
} }
} else { } else {
logger.error("No exit node found. Has gerbil registered yet?"); logger.error(
"No exit node found. Has gerbil registered yet?"
);
} }
} catch (err) { } catch (err) {
logger.error("Failed to post domains to SNI proxy:", err); logger.error("Failed to post domains to SNI proxy:", err);
@ -320,21 +380,31 @@ export class TraefikConfigManager {
domains: Set<string>; domains: Set<string>;
traefikConfig: any; traefikConfig: any;
} | null> { } | null> {
let traefikConfig;
try { try {
const resp = await axios.get( if (config.isHybridMode()) {
`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`, const resp = await axios.get(
await tokenManager.getAuthHeader() `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`,
); await tokenManager.getAuthHeader()
);
if (resp.status !== 200) {
logger.error( if (resp.status !== 200) {
`Failed to fetch traefik config: ${resp.status} ${resp.statusText}`, logger.error(
{ responseData: resp.data } `Failed to fetch traefik config: ${resp.status} ${resp.statusText}`,
{ responseData: resp.data }
);
return null;
}
traefikConfig = resp.data.data;
} else {
const currentExitNode = await getCurrentExitNodeId();
traefikConfig = await getTraefikConfig(
currentExitNode,
config.getRawConfig().traefik.site_types
); );
return null;
} }
const traefikConfig = resp.data.data;
const domains = new Set<string>(); const domains = new Set<string>();
if (traefikConfig?.http?.routers) { if (traefikConfig?.http?.routers) {
@ -445,16 +515,20 @@ export class TraefikConfigManager {
/** /**
* Update dynamic config from existing local certificates without fetching from remote * Update dynamic config from existing local certificates without fetching from remote
*/ */
private async updateDynamicConfigFromLocalCerts(domains: Set<string>): Promise<void> { private async updateDynamicConfigFromLocalCerts(
const dynamicConfigPath = config.getRawConfig().traefik.dynamic_cert_config_path; domains: Set<string>
): Promise<void> {
const dynamicConfigPath =
config.getRawConfig().traefik.dynamic_cert_config_path;
// Load existing dynamic config if it exists, otherwise initialize // Load existing dynamic config if it exists, otherwise initialize
let dynamicConfig: any = { tls: { certificates: [] } }; let dynamicConfig: any = { tls: { certificates: [] } };
if (fs.existsSync(dynamicConfigPath)) { if (fs.existsSync(dynamicConfigPath)) {
try { try {
const fileContent = fs.readFileSync(dynamicConfigPath, "utf8"); const fileContent = fs.readFileSync(dynamicConfigPath, "utf8");
dynamicConfig = yaml.load(fileContent) || dynamicConfig; dynamicConfig = yaml.load(fileContent) || dynamicConfig;
if (!dynamicConfig.tls) dynamicConfig.tls = { certificates: [] }; if (!dynamicConfig.tls)
dynamicConfig.tls = { certificates: [] };
if (!Array.isArray(dynamicConfig.tls.certificates)) { if (!Array.isArray(dynamicConfig.tls.certificates)) {
dynamicConfig.tls.certificates = []; dynamicConfig.tls.certificates = [];
} }
@ -495,67 +569,6 @@ export class TraefikConfigManager {
} }
} }
/**
* Get valid certificates for the specified domains
*/
private async getValidCertificatesForDomains(domains: Set<string>): Promise<
Array<{
id: number;
domain: string;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
updatedAt?: Date | null;
}>
> {
if (domains.size === 0) {
return [];
}
const domainArray = Array.from(domains);
try {
const response = await axios.get(
`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/certificates/domains`,
{
params: {
domains: domainArray
},
headers: (await tokenManager.getAuthHeader()).headers
}
);
if (response.status !== 200) {
logger.error(
`Failed to fetch certificates for domains: ${response.status} ${response.statusText}`,
{ responseData: response.data, domains: domainArray }
);
return [];
}
// logger.debug(
// `Successfully retrieved ${response.data.data?.length || 0} certificates for ${domainArray.length} domains`
// );
return response.data.data;
} catch (error) {
// pull data out of the axios error to log
if (axios.isAxiosError(error)) {
logger.error("Error getting certificates:", {
message: error.message,
code: error.code,
status: error.response?.status,
statusText: error.response?.statusText,
url: error.config?.url,
method: error.config?.method
});
} else {
logger.error("Error getting certificates:", error);
}
return [];
}
}
/** /**
* Process valid certificates - download and decrypt them * Process valid certificates - download and decrypt them
*/ */
@ -640,7 +653,7 @@ export class TraefikConfigManager {
logger.info( logger.info(
`Certificate updated for domain: ${cert.domain}` `Certificate updated for domain: ${cert.domain}`
); );
// Update local state tracking // Update local state tracking
this.lastLocalCertificateState.set(cert.domain, { this.lastLocalCertificateState.set(cert.domain, {
exists: true, exists: true,

View file

@ -11,6 +11,35 @@ let currentExitNodeId: number;
const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger"; const badgerMiddlewareName = "badger";
export async function getCurrentExitNodeId(): Promise<number> {
if (!currentExitNodeId) {
if (config.getRawConfig().gerbil.exit_node_name) {
const exitNodeName = config.getRawConfig().gerbil.exit_node_name!;
const [exitNode] = await db
.select({
exitNodeId: exitNodes.exitNodeId
})
.from(exitNodes)
.where(eq(exitNodes.name, exitNodeName));
if (exitNode) {
currentExitNodeId = exitNode.exitNodeId;
}
} else {
const [exitNode] = await db
.select({
exitNodeId: exitNodes.exitNodeId
})
.from(exitNodes)
.limit(1);
if (exitNode) {
currentExitNodeId = exitNode.exitNodeId;
}
}
}
return currentExitNodeId;
}
export async function traefikConfigProvider( export async function traefikConfigProvider(
_: Request, _: Request,
res: Response res: Response
@ -18,34 +47,12 @@ export async function traefikConfigProvider(
try { try {
// First query to get resources with site and org info // First query to get resources with site and org info
// Get the current exit node name from config // Get the current exit node name from config
if (!currentExitNodeId) { await getCurrentExitNodeId();
if (config.getRawConfig().gerbil.exit_node_name) {
const exitNodeName =
config.getRawConfig().gerbil.exit_node_name!;
const [exitNode] = await db
.select({
exitNodeId: exitNodes.exitNodeId
})
.from(exitNodes)
.where(eq(exitNodes.name, exitNodeName));
if (exitNode) {
currentExitNodeId = exitNode.exitNodeId;
}
} else {
const [exitNode] = await db
.select({
exitNodeId: exitNodes.exitNodeId
})
.from(exitNodes)
.limit(1);
if (exitNode) { let traefikConfig = await getTraefikConfig(
currentExitNodeId = exitNode.exitNodeId; currentExitNodeId,
} config.getRawConfig().traefik.site_types
} );
}
let traefikConfig = await getTraefikConfig(currentExitNodeId, config.getRawConfig().traefik.site_types);
traefikConfig.http.middlewares[badgerMiddlewareName] = { traefikConfig.http.middlewares[badgerMiddlewareName] = {
plugin: { plugin: {
@ -80,7 +87,10 @@ export async function traefikConfigProvider(
} }
} }
export async function getTraefikConfig(exitNodeId: number, siteTypes: string[]): Promise<any> { export async function getTraefikConfig(
exitNodeId: number,
siteTypes: string[]
): Promise<any> {
// Define extended target type with site information // Define extended target type with site information
type TargetWithSite = Target & { type TargetWithSite = Target & {
site: { site: {
@ -135,7 +145,7 @@ export async function getTraefikConfig(exitNodeId: number, siteTypes: string[]):
eq(sites.exitNodeId, exitNodeId), eq(sites.exitNodeId, exitNodeId),
isNull(sites.exitNodeId) isNull(sites.exitNodeId)
), ),
inArray(sites.type, siteTypes), inArray(sites.type, siteTypes)
) )
); );
@ -438,7 +448,10 @@ export async function getTraefikConfig(exitNodeId: number, siteTypes: string[]):
return false; return false;
} }
} else if (target.site.type === "newt") { } else if (target.site.type === "newt") {
if (!target.internalPort || !target.site.subnet) { if (
!target.internalPort ||
!target.site.subnet
) {
return false; return false;
} }
} }