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<
string,
{
exists: boolean; exists: boolean;
lastModified: Date | null; lastModified: Date | null;
expiresAt: 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,11 +108,16 @@ 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<
Map<
string,
{
exists: boolean; exists: boolean;
lastModified: Date | null; lastModified: Date | null;
expiresAt: 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;
@ -127,7 +146,9 @@ export class TraefikConfigManager {
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
@ -164,15 +185,20 @@ export class TraefikConfigManager {
// 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;
} }
@ -181,15 +207,21 @@ export class TraefikConfigManager {
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;
} }
} }
@ -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,18 +281,32 @@ 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,7 +380,9 @@ export class TraefikConfigManager {
domains: Set<string>; domains: Set<string>;
traefikConfig: any; traefikConfig: any;
} | null> { } | null> {
let traefikConfig;
try { try {
if (config.isHybridMode()) {
const resp = await axios.get( const resp = await axios.get(
`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`, `${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`,
await tokenManager.getAuthHeader() await tokenManager.getAuthHeader()
@ -334,7 +396,15 @@ export class TraefikConfigManager {
return null; return null;
} }
const traefikConfig = resp.data.data; traefikConfig = resp.data.data;
} else {
const currentExitNode = await getCurrentExitNodeId();
traefikConfig = await getTraefikConfig(
currentExitNode,
config.getRawConfig().traefik.site_types
);
}
const domains = new Set<string>(); const domains = new Set<string>();
if (traefikConfig?.http?.routers) { if (traefikConfig?.http?.routers) {
@ -445,8 +515,11 @@ 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: [] } };
@ -454,7 +527,8 @@ export class TraefikConfigManager {
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
*/ */

View file

@ -11,17 +11,10 @@ let currentExitNodeId: number;
const redirectHttpsMiddlewareName = "redirect-to-https"; const redirectHttpsMiddlewareName = "redirect-to-https";
const badgerMiddlewareName = "badger"; const badgerMiddlewareName = "badger";
export async function traefikConfigProvider( export async function getCurrentExitNodeId(): Promise<number> {
_: Request,
res: Response
): Promise<any> {
try {
// First query to get resources with site and org info
// Get the current exit node name from config
if (!currentExitNodeId) { if (!currentExitNodeId) {
if (config.getRawConfig().gerbil.exit_node_name) { if (config.getRawConfig().gerbil.exit_node_name) {
const exitNodeName = const exitNodeName = config.getRawConfig().gerbil.exit_node_name!;
config.getRawConfig().gerbil.exit_node_name!;
const [exitNode] = await db const [exitNode] = await db
.select({ .select({
exitNodeId: exitNodes.exitNodeId exitNodeId: exitNodes.exitNodeId
@ -44,8 +37,22 @@ export async function traefikConfigProvider(
} }
} }
} }
return currentExitNodeId;
}
let traefikConfig = await getTraefikConfig(currentExitNodeId, config.getRawConfig().traefik.site_types); export async function traefikConfigProvider(
_: Request,
res: Response
): Promise<any> {
try {
// First query to get resources with site and org info
// Get the current exit node name from config
await getCurrentExitNodeId();
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;
} }
} }