Merge branch 'hybrid' into dev

This commit is contained in:
Owen
2025-08-18 15:29:23 -07:00
54 changed files with 3959 additions and 1319 deletions

View File

@@ -103,9 +103,7 @@ export class Config {
private async checkKeyStatus() {
const licenseStatus = await license.check();
if (
!licenseStatus.isHostLicensed
) {
if (!licenseStatus.isHostLicensed) {
this.checkSupporterKey();
}
}
@@ -147,6 +145,10 @@ export class Config {
return false;
}
public isHybridMode() {
return typeof this.rawConfig?.hybrid === "object";
}
public async checkSupporterKey() {
const [key] = await db.select().from(supporterKey).limit(1);

View File

@@ -0,0 +1,86 @@
import axios from "axios";
import logger from "@server/logger";
import { ExitNode } from "@server/db";
interface ExitNodeRequest {
remoteType: string;
localPath: string;
method?: "POST" | "DELETE" | "GET" | "PUT";
data?: any;
queryParams?: Record<string, string>;
}
/**
* Sends a request to an exit node, handling both remote and local exit nodes
* @param exitNode The exit node to send the request to
* @param request The request configuration
* @returns Promise<any> Response data for local nodes, undefined for remote nodes
*/
export async function sendToExitNode(
exitNode: ExitNode,
request: ExitNodeRequest
): Promise<any> {
if (!exitNode.reachableAt) {
throw new Error(
`Exit node with ID ${exitNode.exitNodeId} is not reachable`
);
}
// Handle local exit node with HTTP API
const method = request.method || "POST";
let url = `${exitNode.reachableAt}${request.localPath}`;
// Add query parameters if provided
if (request.queryParams) {
const params = new URLSearchParams(request.queryParams);
url += `?${params.toString()}`;
}
try {
let response;
switch (method) {
case "POST":
response = await axios.post(url, request.data, {
headers: {
"Content-Type": "application/json"
}
});
break;
case "DELETE":
response = await axios.delete(url);
break;
case "GET":
response = await axios.get(url);
break;
case "PUT":
response = await axios.put(url, request.data, {
headers: {
"Content-Type": "application/json"
}
});
break;
default:
throw new Error(`Unsupported HTTP method: ${method}`);
}
logger.info(`Exit node request successful:`, {
method,
url,
status: response.data.status
});
return response.data;
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error(
`Error making ${method} request (can Pangolin see Gerbil HTTP API?) for exit node at ${exitNode.reachableAt} (status: ${error.response?.status}): ${error.message}`
);
} else {
logger.error(
`Error making ${method} request for exit node at ${exitNode.reachableAt}: ${error}`
);
}
throw error;
}
}

View File

@@ -0,0 +1,59 @@
import { db, exitNodes } from "@server/db";
import logger from "@server/logger";
import { ExitNodePingResult } from "@server/routers/newt";
import { eq } from "drizzle-orm";
export async function verifyExitNodeOrgAccess(
exitNodeId: number,
orgId: string
) {
const [exitNode] = await db
.select()
.from(exitNodes)
.where(eq(exitNodes.exitNodeId, exitNodeId));
// For any other type, deny access
return { hasAccess: true, exitNode };
}
export async function listExitNodes(orgId: string, filterOnline = false) {
// TODO: pick which nodes to send and ping better than just all of them that are not remote
const allExitNodes = await db
.select({
exitNodeId: exitNodes.exitNodeId,
name: exitNodes.name,
address: exitNodes.address,
endpoint: exitNodes.endpoint,
publicKey: exitNodes.publicKey,
listenPort: exitNodes.listenPort,
reachableAt: exitNodes.reachableAt,
maxConnections: exitNodes.maxConnections,
online: exitNodes.online,
lastPing: exitNodes.lastPing,
type: exitNodes.type
})
.from(exitNodes);
// Filter the nodes. If there are NO remoteExitNodes then do nothing. If there are then remove all of the non-remoteExitNodes
if (allExitNodes.length === 0) {
logger.warn("No exit nodes found!");
return [];
}
return allExitNodes;
}
export function selectBestExitNode(
pingResults: ExitNodePingResult[]
): ExitNodePingResult | null {
if (!pingResults || pingResults.length === 0) {
logger.warn("No ping results provided");
return null;
}
return pingResults[0];
}
export async function checkExitNodeOrg(exitNodeId: number, orgId: string) {
return false;
}

View File

@@ -0,0 +1,2 @@
export * from "./exitNodes";
export * from "./shared";

View File

@@ -0,0 +1,30 @@
import { db, exitNodes } from "@server/db";
import config from "@server/lib/config";
import { findNextAvailableCidr } from "@server/lib/ip";
export async function getNextAvailableSubnet(): Promise<string> {
// Get all existing subnets from routes table
const existingAddresses = await db
.select({
address: exitNodes.address
})
.from(exitNodes);
const addresses = existingAddresses.map((a) => a.address);
let subnet = findNextAvailableCidr(
addresses,
config.getRawConfig().gerbil.block_size,
config.getRawConfig().gerbil.subnet_group
);
if (!subnet) {
throw new Error("No available subnets remaining in space");
}
// replace the last octet with 1
subnet =
subnet.split(".").slice(0, 3).join(".") +
".1" +
"/" +
subnet.split("/")[1];
return subnet;
}

View File

@@ -1 +1,2 @@
export * from "./response";
export { tokenManager, TokenManager } from "./tokenManager";

View File

@@ -32,6 +32,15 @@ export const configSchema = z
.optional()
.default({})
}),
hybrid: z
.object({
name: z.string().optional(),
id: z.string().optional(),
secret: z.string().optional(),
endpoint: z.string().optional(),
redirect_endpoint: z.string().optional()
})
.optional(),
domains: z
.record(
z.string(),
@@ -135,7 +144,20 @@ export const configSchema = z
https_entrypoint: z.string().optional().default("websecure"),
additional_middlewares: z.array(z.string()).optional(),
cert_resolver: z.string().optional().default("letsencrypt"),
prefer_wildcard_cert: z.boolean().optional().default(false)
prefer_wildcard_cert: z.boolean().optional().default(false),
certificates_path: z.string().default("/var/certificates"),
monitor_interval: z.number().default(5000),
dynamic_cert_config_path: z
.string()
.optional()
.default("/var/dynamic/cert_config.yml"),
dynamic_router_config_path: z
.string()
.optional()
.default("/var/dynamic/router_config.yml"),
static_domains: z.array(z.string()).optional().default([]),
site_types: z.array(z.string()).optional().default(["newt", "wireguard", "local"]),
file_mode: z.boolean().optional().default(false)
})
.optional()
.default({}),

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";

73
server/lib/remoteProxy.ts Normal file
View File

@@ -0,0 +1,73 @@
import { Request, Response, NextFunction } from "express";
import { Router } from "express";
import axios from "axios";
import HttpCode from "@server/types/HttpCode";
import createHttpError from "http-errors";
import logger from "@server/logger";
import config from "@server/lib/config";
import { tokenManager } from "./tokenManager";
/**
* Proxy function that forwards requests to the remote cloud server
*/
export const proxyToRemote = async (
req: Request,
res: Response,
next: NextFunction,
endpoint: string
): Promise<any> => {
try {
const remoteUrl = `${config.getRawConfig().hybrid?.endpoint?.replace(/\/$/, '')}/api/v1/${endpoint}`;
logger.debug(`Proxying request to remote server: ${remoteUrl}`);
// Forward the request to the remote server
const response = await axios({
method: req.method as any,
url: remoteUrl,
data: req.body,
headers: {
'Content-Type': 'application/json',
...(await tokenManager.getAuthHeader()).headers
},
params: req.query,
timeout: 30000, // 30 second timeout
validateStatus: () => true // Don't throw on non-2xx status codes
});
logger.debug(`Proxy response: ${JSON.stringify(response.data)}`);
// Forward the response status and data
return res.status(response.status).json(response.data);
} catch (error) {
logger.error("Error proxying request to remote server:", error);
if (axios.isAxiosError(error)) {
if (error.code === 'ECONNREFUSED' || error.code === 'ENOTFOUND') {
return next(
createHttpError(
HttpCode.SERVICE_UNAVAILABLE,
"Remote server is unavailable"
)
);
}
if (error.code === 'ECONNABORTED') {
return next(
createHttpError(
HttpCode.REQUEST_TIMEOUT,
"Request to remote server timed out"
)
);
}
}
return next(
createHttpError(
HttpCode.INTERNAL_SERVER_ERROR,
"Error communicating with remote server"
)
);
}
}

274
server/lib/tokenManager.ts Normal file
View File

@@ -0,0 +1,274 @@
import axios from "axios";
import config from "@server/lib/config";
import logger from "@server/logger";
export interface TokenResponse {
success: boolean;
message?: string;
data: {
token: string;
};
}
/**
* Token Manager - Handles automatic token refresh for hybrid server authentication
*
* Usage throughout the application:
* ```typescript
* import { tokenManager } from "@server/lib/tokenManager";
*
* // Get the current valid token
* const token = await tokenManager.getToken();
*
* // Force refresh if needed
* await tokenManager.refreshToken();
* ```
*
* The token manager automatically refreshes tokens every 24 hours by default
* and is started once in the privateHybridServer.ts file.
*/
export class TokenManager {
private token: string | null = null;
private refreshInterval: NodeJS.Timeout | null = null;
private isRefreshing: boolean = false;
private refreshIntervalMs: number;
private retryInterval: NodeJS.Timeout | null = null;
private retryIntervalMs: number;
private tokenAvailablePromise: Promise<void> | null = null;
private tokenAvailableResolve: (() => void) | null = null;
constructor(refreshIntervalMs: number = 24 * 60 * 60 * 1000, retryIntervalMs: number = 5000) {
// Default to 24 hours for refresh, 5 seconds for retry
this.refreshIntervalMs = refreshIntervalMs;
this.retryIntervalMs = retryIntervalMs;
this.setupTokenAvailablePromise();
}
/**
* Set up promise that resolves when token becomes available
*/
private setupTokenAvailablePromise(): void {
this.tokenAvailablePromise = new Promise((resolve) => {
this.tokenAvailableResolve = resolve;
});
}
/**
* Resolve the token available promise
*/
private resolveTokenAvailable(): void {
if (this.tokenAvailableResolve) {
this.tokenAvailableResolve();
this.tokenAvailableResolve = null;
}
}
/**
* Start the token manager - gets initial token and sets up refresh interval
* If initial token fetch fails, keeps retrying every few seconds until successful
*/
async start(): Promise<void> {
logger.info("Starting token manager...");
try {
await this.refreshToken();
this.setupRefreshInterval();
this.resolveTokenAvailable();
logger.info("Token manager started successfully");
} catch (error) {
logger.warn(`Failed to get initial token, will retry in ${this.retryIntervalMs / 1000} seconds:`, error);
this.setupRetryInterval();
}
}
/**
* Set up retry interval for initial token acquisition
*/
private setupRetryInterval(): void {
if (this.retryInterval) {
clearInterval(this.retryInterval);
}
this.retryInterval = setInterval(async () => {
try {
logger.debug("Retrying initial token acquisition");
await this.refreshToken();
this.setupRefreshInterval();
this.clearRetryInterval();
this.resolveTokenAvailable();
logger.info("Token manager started successfully after retry");
} catch (error) {
logger.debug("Token acquisition retry failed, will try again");
}
}, this.retryIntervalMs);
}
/**
* Clear retry interval
*/
private clearRetryInterval(): void {
if (this.retryInterval) {
clearInterval(this.retryInterval);
this.retryInterval = null;
}
}
/**
* Stop the token manager and clear all intervals
*/
stop(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
this.refreshInterval = null;
}
this.clearRetryInterval();
logger.info("Token manager stopped");
}
/**
* Get the current valid token
*/
// TODO: WE SHOULD NOT BE GETTING A TOKEN EVERY TIME WE REQUEST IT
async getToken(): Promise<string> {
// If we don't have a token yet, wait for it to become available
if (!this.token && this.tokenAvailablePromise) {
await this.tokenAvailablePromise;
}
if (!this.token) {
if (this.isRefreshing) {
// Wait for current refresh to complete
await this.waitForRefresh();
} else {
throw new Error("No valid token available");
}
}
if (!this.token) {
throw new Error("No valid token available");
}
return this.token;
}
async getAuthHeader() {
return {
headers: {
Authorization: `Bearer ${await this.getToken()}`,
"X-CSRF-Token": "x-csrf-protection",
}
};
}
/**
* Force refresh the token
*/
async refreshToken(): Promise<void> {
if (this.isRefreshing) {
await this.waitForRefresh();
return;
}
this.isRefreshing = true;
try {
const hybridConfig = config.getRawConfig().hybrid;
if (
!hybridConfig?.id ||
!hybridConfig?.secret ||
!hybridConfig?.endpoint
) {
throw new Error("Hybrid configuration is not defined");
}
const tokenEndpoint = `${hybridConfig.endpoint}/api/v1/auth/remoteExitNode/get-token`;
const tokenData = {
remoteExitNodeId: hybridConfig.id,
secret: hybridConfig.secret
};
logger.debug("Requesting new token from server");
const response = await axios.post<TokenResponse>(
tokenEndpoint,
tokenData,
{
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": "x-csrf-protection"
},
timeout: 10000 // 10 second timeout
}
);
if (!response.data.success) {
throw new Error(
`Failed to get token: ${response.data.message}`
);
}
if (!response.data.data.token) {
throw new Error("Received empty token from server");
}
this.token = response.data.data.token;
logger.debug("Token refreshed successfully");
} catch (error) {
if (axios.isAxiosError(error)) {
logger.error("Error updating proxy mapping:", {
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 updating proxy mapping:", error);
}
throw new Error("Failed to refresh token");
} finally {
this.isRefreshing = false;
}
}
/**
* Set up automatic token refresh interval
*/
private setupRefreshInterval(): void {
if (this.refreshInterval) {
clearInterval(this.refreshInterval);
}
this.refreshInterval = setInterval(async () => {
try {
logger.debug("Auto-refreshing token");
await this.refreshToken();
} catch (error) {
logger.error("Failed to auto-refresh token:", error);
}
}, this.refreshIntervalMs);
}
/**
* Wait for current refresh operation to complete
*/
private async waitForRefresh(): Promise<void> {
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this.isRefreshing) {
clearInterval(checkInterval);
resolve();
}
}, 100);
});
}
}
// Export a singleton instance for use throughout the application
export const tokenManager = new TokenManager();

907
server/lib/traefikConfig.ts Normal file
View File

@@ -0,0 +1,907 @@
import * as fs from "fs";
import * as path from "path";
import config from "@server/lib/config";
import logger from "@server/logger";
import * as yaml from "js-yaml";
import axios from "axios";
import { db, exitNodes } from "@server/db";
import { eq } from "drizzle-orm";
import { tokenManager } from "./tokenManager";
import {
getCurrentExitNodeId,
getTraefikConfig
} from "@server/routers/traefik";
import {
getValidCertificatesForDomains,
getValidCertificatesForDomainsHybrid
} from "./remoteCertificates";
export class TraefikConfigManager {
private intervalId: NodeJS.Timeout | null = null;
private isRunning = false;
private activeDomains = new Set<string>();
private timeoutId: NodeJS.Timeout | null = null;
private lastCertificateFetch: Date | null = null;
private lastKnownDomains = new Set<string>();
private lastLocalCertificateState = new Map<
string,
{
exists: boolean;
lastModified: Date | null;
expiresAt: Date | null;
}
>();
constructor() {}
/**
* Start monitoring certificates
*/
private scheduleNextExecution(): void {
const intervalMs = config.getRawConfig().traefik.monitor_interval;
const now = Date.now();
const nextExecution = Math.ceil(now / intervalMs) * intervalMs;
const delay = nextExecution - now;
this.timeoutId = setTimeout(async () => {
try {
await this.HandleTraefikConfig();
} catch (error) {
logger.error("Error during certificate monitoring:", error);
}
if (this.isRunning) {
this.scheduleNextExecution(); // Schedule the next execution
}
}, delay);
}
async start(): Promise<void> {
if (this.isRunning) {
logger.info("Certificate monitor is already running");
return;
}
this.isRunning = true;
logger.info(`Starting certificate monitor for exit node`);
// Ensure certificates directory exists
await this.ensureDirectoryExists(
config.getRawConfig().traefik.certificates_path
);
// Initialize local certificate state
this.lastLocalCertificateState = await this.scanLocalCertificateState();
logger.info(
`Found ${this.lastLocalCertificateState.size} existing certificate directories`
);
// Run initial check
await this.HandleTraefikConfig();
// Start synchronized scheduling
this.scheduleNextExecution();
logger.info(
`Certificate monitor started with synchronized ${
config.getRawConfig().traefik.monitor_interval
}ms interval`
);
}
/**
* Stop monitoring certificates
*/
stop(): void {
if (!this.isRunning) {
logger.info("Certificate monitor is not running");
return;
}
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
this.isRunning = false;
logger.info("Certificate monitor stopped");
}
/**
* Scan local certificate directories to build current state
*/
private async scanLocalCertificateState(): Promise<
Map<
string,
{
exists: boolean;
lastModified: Date | null;
expiresAt: Date | null;
}
>
> {
const state = new Map();
const certsPath = config.getRawConfig().traefik.certificates_path;
try {
if (!fs.existsSync(certsPath)) {
return state;
}
const certDirs = fs.readdirSync(certsPath, { withFileTypes: true });
for (const dirent of certDirs) {
if (!dirent.isDirectory()) continue;
const domain = dirent.name;
const domainDir = path.join(certsPath, domain);
const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem");
const lastUpdatePath = path.join(domainDir, ".last_update");
const certExists = await this.fileExists(certPath);
const keyExists = await this.fileExists(keyPath);
const lastUpdateExists = await this.fileExists(lastUpdatePath);
let lastModified: Date | null = null;
let expiresAt: Date | null = null;
if (lastUpdateExists) {
try {
const lastUpdateStr = fs
.readFileSync(lastUpdatePath, "utf8")
.trim();
lastModified = new Date(lastUpdateStr);
} catch {
// If we can't read the last update, fall back to file stats
try {
const stats = fs.statSync(certPath);
lastModified = stats.mtime;
} catch {
lastModified = null;
}
}
}
state.set(domain, {
exists: certExists && keyExists,
lastModified,
expiresAt
});
}
} catch (error) {
logger.error("Error scanning local certificate state:", error);
}
return state;
}
/**
* Check if we need to fetch certificates from remote
*/
private shouldFetchCertificates(currentDomains: Set<string>): boolean {
// Always fetch on first run
if (!this.lastCertificateFetch) {
return true;
}
// Fetch if it's been more than 24 hours (for renewals)
const dayInMs = 24 * 60 * 60 * 1000;
const timeSinceLastFetch =
Date.now() - this.lastCertificateFetch.getTime();
if (timeSinceLastFetch > dayInMs) {
logger.info("Fetching certificates due to 24-hour renewal check");
return true;
}
// Fetch if domains have changed
if (
this.lastKnownDomains.size !== currentDomains.size ||
!Array.from(this.lastKnownDomains).every((domain) =>
currentDomains.has(domain)
)
) {
logger.info("Fetching certificates due to domain changes");
return true;
}
// Check if any local certificates are missing or appear to be outdated
for (const domain of currentDomains) {
const localState = this.lastLocalCertificateState.get(domain);
if (!localState || !localState.exists) {
logger.info(
`Fetching certificates due to missing local cert for ${domain}`
);
return true;
}
// Check if certificate is expiring soon (within 30 days)
if (localState.expiresAt) {
const daysUntilExpiry =
(localState.expiresAt.getTime() - Date.now()) /
(1000 * 60 * 60 * 24);
if (daysUntilExpiry < 30) {
logger.info(
`Fetching certificates due to upcoming expiry for ${domain} (${Math.round(daysUntilExpiry)} days remaining)`
);
return true;
}
}
}
return false;
}
/**
* Main monitoring logic
*/
lastActiveDomains: Set<string> = new Set();
public async HandleTraefikConfig(): Promise<void> {
try {
// Get all active domains for this exit node via HTTP call
const getTraefikConfig = await this.getTraefikConfig();
if (!getTraefikConfig) {
logger.error(
"Failed to fetch active domains from traefik config"
);
return;
}
const { domains, traefikConfig } = getTraefikConfig;
// Add static domains from config
// const staticDomains = [config.getRawConfig().app.dashboard_url];
// staticDomains.forEach((domain) => domains.add(domain));
// Log if domains changed
if (
this.lastActiveDomains.size !== domains.size ||
!Array.from(this.lastActiveDomains).every((domain) =>
domains.has(domain)
)
) {
logger.info(
`Active domains changed for exit node: ${Array.from(domains).join(", ")}`
);
this.lastActiveDomains = new Set(domains);
}
// Scan current local certificate state
this.lastLocalCertificateState =
await this.scanLocalCertificateState();
// Only fetch certificates if needed (domain changes, missing certs, or daily renewal check)
let validCertificates: Array<{
id: number;
domain: string;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
updatedAt?: Date | null;
}> = [];
if (this.shouldFetchCertificates(domains)) {
// Get valid certificates for active domains
if (config.isHybridMode()) {
validCertificates =
await getValidCertificatesForDomainsHybrid(domains);
} else {
validCertificates =
await getValidCertificatesForDomains(domains);
}
this.lastCertificateFetch = new Date();
this.lastKnownDomains = new Set(domains);
logger.info(
`Fetched ${validCertificates.length} certificates from remote`
);
// Download and decrypt new certificates
await this.processValidCertificates(validCertificates);
} else {
const timeSinceLastFetch = this.lastCertificateFetch
? Math.round(
(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
await this.updateDynamicConfigFromLocalCerts(domains);
}
// Clean up certificates for domains no longer in use
await this.cleanupUnusedCertificates(domains);
// wait 1 second for traefik to pick up the new certificates
await new Promise((resolve) => setTimeout(resolve, 500));
// Write traefik config as YAML to a second dynamic config file if changed
await this.writeTraefikDynamicConfig(traefikConfig);
// Send domains to SNI proxy
try {
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) {
try {
await axios.post(
`${exitNode.reachableAt}/update-local-snis`,
{ fullDomains: Array.from(domains) },
{ headers: { "Content-Type": "application/json" } }
);
} catch (error) {
// pull data out of the axios error to log
if (axios.isAxiosError(error)) {
logger.error("Error updating local SNI:", {
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 updating local SNI:", error);
}
}
} else {
logger.error(
"No exit node found. Has gerbil registered yet?"
);
}
} catch (err) {
logger.error("Failed to post domains to SNI proxy:", err);
}
// Update active domains tracking
this.activeDomains = domains;
} catch (error) {
logger.error("Error in traefik config monitoring cycle:", error);
}
}
/**
* Get all domains currently in use from traefik config API
*/
private async getTraefikConfig(): Promise<{
domains: Set<string>;
traefikConfig: any;
} | null> {
let traefikConfig;
try {
if (config.isHybridMode()) {
const resp = await axios.get(
`${config.getRawConfig().hybrid?.endpoint}/api/v1/hybrid/traefik-config`,
await tokenManager.getAuthHeader()
);
if (resp.status !== 200) {
logger.error(
`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
);
}
const domains = new Set<string>();
if (traefikConfig?.http?.routers) {
for (const router of Object.values<any>(
traefikConfig.http.routers
)) {
if (router.rule && typeof router.rule === "string") {
// Match Host(`domain`)
const match = router.rule.match(/Host\(`([^`]+)`\)/);
if (match && match[1]) {
domains.add(match[1]);
}
}
}
}
// logger.debug(
// `Successfully retrieved traefik config: ${JSON.stringify(traefikConfig)}`
// );
const badgerMiddlewareName = "badger";
if (traefikConfig?.http?.middlewares) {
traefikConfig.http.middlewares[badgerMiddlewareName] = {
plugin: {
[badgerMiddlewareName]: {
apiBaseUrl: new URL(
"/api/v1",
`http://${
config.getRawConfig().server
.internal_hostname
}:${config.getRawConfig().server.internal_port}`
).href,
userSessionCookieName:
config.getRawConfig().server
.session_cookie_name,
// deprecated
accessTokenQueryParam:
config.getRawConfig().server
.resource_access_token_param,
resourceSessionRequestParam:
config.getRawConfig().server
.resource_session_request_param
}
}
};
}
return { domains, traefikConfig };
} catch (error) {
// pull data out of the axios error to log
if (axios.isAxiosError(error)) {
logger.error("Error fetching traefik config:", {
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 fetching traefik config:", error);
}
return null;
}
}
/**
* Write traefik config as YAML to a second dynamic config file if changed
*/
private async writeTraefikDynamicConfig(traefikConfig: any): Promise<void> {
const traefikDynamicConfigPath =
config.getRawConfig().traefik.dynamic_router_config_path;
let shouldWrite = false;
let oldJson = "";
if (fs.existsSync(traefikDynamicConfigPath)) {
try {
const oldContent = fs.readFileSync(
traefikDynamicConfigPath,
"utf8"
);
// Try to parse as YAML then JSON.stringify for comparison
const oldObj = yaml.load(oldContent);
oldJson = JSON.stringify(oldObj);
} catch {
oldJson = "";
}
}
const newJson = JSON.stringify(traefikConfig);
if (oldJson !== newJson) {
shouldWrite = true;
}
if (shouldWrite) {
try {
fs.writeFileSync(
traefikDynamicConfigPath,
yaml.dump(traefikConfig, { noRefs: true }),
"utf8"
);
logger.info("Traefik dynamic config updated");
} catch (err) {
logger.error("Failed to write traefik dynamic config:", err);
}
}
}
/**
* Update dynamic config from existing local certificates without fetching from remote
*/
private async updateDynamicConfigFromLocalCerts(
domains: Set<string>
): Promise<void> {
const dynamicConfigPath =
config.getRawConfig().traefik.dynamic_cert_config_path;
// Load existing dynamic config if it exists, otherwise initialize
let dynamicConfig: any = { tls: { certificates: [] } };
if (fs.existsSync(dynamicConfigPath)) {
try {
const fileContent = fs.readFileSync(dynamicConfigPath, "utf8");
dynamicConfig = yaml.load(fileContent) || dynamicConfig;
if (!dynamicConfig.tls)
dynamicConfig.tls = { certificates: [] };
if (!Array.isArray(dynamicConfig.tls.certificates)) {
dynamicConfig.tls.certificates = [];
}
} catch (err) {
logger.error("Failed to load existing dynamic config:", err);
}
}
// Keep a copy of the original config for comparison
const originalConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
// Clear existing certificates and rebuild from local state
dynamicConfig.tls.certificates = [];
for (const domain of domains) {
const localState = this.lastLocalCertificateState.get(domain);
if (localState && localState.exists) {
const domainDir = path.join(
config.getRawConfig().traefik.certificates_path,
domain
);
const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem");
const certEntry = {
certFile: certPath,
keyFile: keyPath
};
dynamicConfig.tls.certificates.push(certEntry);
}
}
// Only write the config if it has changed
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
if (newConfigYaml !== originalConfigYaml) {
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8");
logger.info("Dynamic cert config updated from local certificates");
}
}
/**
* Process valid certificates - download and decrypt them
*/
private async processValidCertificates(
validCertificates: Array<{
id: number;
domain: string;
certFile: string | null;
keyFile: string | null;
expiresAt: Date | null;
updatedAt?: Date | null;
}>
): Promise<void> {
const dynamicConfigPath =
config.getRawConfig().traefik.dynamic_cert_config_path;
// Load existing dynamic config if it exists, otherwise initialize
let dynamicConfig: any = { tls: { certificates: [] } };
if (fs.existsSync(dynamicConfigPath)) {
try {
const fileContent = fs.readFileSync(dynamicConfigPath, "utf8");
dynamicConfig = yaml.load(fileContent) || dynamicConfig;
if (!dynamicConfig.tls)
dynamicConfig.tls = { certificates: [] };
if (!Array.isArray(dynamicConfig.tls.certificates)) {
dynamicConfig.tls.certificates = [];
}
} catch (err) {
logger.error("Failed to load existing dynamic config:", err);
}
}
// Keep a copy of the original config for comparison
const originalConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
for (const cert of validCertificates) {
try {
if (!cert.certFile || !cert.keyFile) {
logger.warn(
`Certificate for domain ${cert.domain} is missing cert or key file`
);
continue;
}
const domainDir = path.join(
config.getRawConfig().traefik.certificates_path,
cert.domain
);
await this.ensureDirectoryExists(domainDir);
const certPath = path.join(domainDir, "cert.pem");
const keyPath = path.join(domainDir, "key.pem");
const lastUpdatePath = path.join(domainDir, ".last_update");
// Check if we need to update the certificate
const shouldUpdate = await this.shouldUpdateCertificate(
cert,
certPath,
keyPath,
lastUpdatePath
);
if (shouldUpdate) {
logger.info(
`Processing certificate for domain: ${cert.domain}`
);
fs.writeFileSync(certPath, cert.certFile, "utf8");
fs.writeFileSync(keyPath, cert.keyFile, "utf8");
// Set appropriate permissions (readable by owner only for key file)
fs.chmodSync(certPath, 0o644);
fs.chmodSync(keyPath, 0o600);
// Write/update .last_update file with current timestamp
fs.writeFileSync(
lastUpdatePath,
new Date().toISOString(),
"utf8"
);
logger.info(
`Certificate updated for domain: ${cert.domain}`
);
// Update local state tracking
this.lastLocalCertificateState.set(cert.domain, {
exists: true,
lastModified: new Date(),
expiresAt: cert.expiresAt
});
}
// Always ensure the config entry exists and is up to date
const certEntry = {
certFile: certPath,
keyFile: keyPath
};
// Remove any existing entry for this cert/key path
dynamicConfig.tls.certificates =
dynamicConfig.tls.certificates.filter(
(entry: any) =>
entry.certFile !== certEntry.certFile ||
entry.keyFile !== certEntry.keyFile
);
dynamicConfig.tls.certificates.push(certEntry);
} catch (error) {
logger.error(
`Error processing certificate for domain ${cert.domain}:`,
error
);
}
}
// Only write the config if it has changed
const newConfigYaml = yaml.dump(dynamicConfig, { noRefs: true });
if (newConfigYaml !== originalConfigYaml) {
fs.writeFileSync(dynamicConfigPath, newConfigYaml, "utf8");
logger.info("Dynamic cert config updated");
}
}
/**
* Check if certificate should be updated
*/
private async shouldUpdateCertificate(
cert: {
id: number;
domain: string;
expiresAt: Date | null;
updatedAt?: Date | null;
},
certPath: string,
keyPath: string,
lastUpdatePath: string
): Promise<boolean> {
try {
// If files don't exist, we need to create them
const certExists = await this.fileExists(certPath);
const keyExists = await this.fileExists(keyPath);
const lastUpdateExists = await this.fileExists(lastUpdatePath);
if (!certExists || !keyExists || !lastUpdateExists) {
return true;
}
// Read last update time from .last_update file
let lastUpdateTime: Date | null = null;
try {
const lastUpdateStr = fs
.readFileSync(lastUpdatePath, "utf8")
.trim();
lastUpdateTime = new Date(lastUpdateStr);
} catch {
lastUpdateTime = null;
}
// Use updatedAt from cert, fallback to expiresAt if not present
const dbUpdateTime = cert.updatedAt ?? cert.expiresAt;
if (!dbUpdateTime) {
// If no update time in DB, always update
return true;
}
// If DB updatedAt is newer than last update file, update
if (!lastUpdateTime || dbUpdateTime > lastUpdateTime) {
return true;
}
return false;
} catch (error) {
logger.error(
`Error checking certificate update status for ${cert.domain}:`,
error
);
return true; // When in doubt, update
}
}
/**
* Clean up certificates for domains no longer in use
*/
private async cleanupUnusedCertificates(
currentActiveDomains: Set<string>
): Promise<void> {
try {
const certsPath = config.getRawConfig().traefik.certificates_path;
const dynamicConfigPath =
config.getRawConfig().traefik.dynamic_cert_config_path;
// Load existing dynamic config if it exists
let dynamicConfig: any = { tls: { certificates: [] } };
if (fs.existsSync(dynamicConfigPath)) {
try {
const fileContent = fs.readFileSync(
dynamicConfigPath,
"utf8"
);
dynamicConfig = yaml.load(fileContent) || dynamicConfig;
if (!dynamicConfig.tls)
dynamicConfig.tls = { certificates: [] };
if (!Array.isArray(dynamicConfig.tls.certificates)) {
dynamicConfig.tls.certificates = [];
}
} catch (err) {
logger.error(
"Failed to load existing dynamic config:",
err
);
}
}
const certDirs = fs.readdirSync(certsPath, {
withFileTypes: true
});
let configChanged = false;
for (const dirent of certDirs) {
if (!dirent.isDirectory()) continue;
const dirName = dirent.name;
// Only delete if NO current domain is exactly the same or ends with `.${dirName}`
const shouldDelete = !Array.from(currentActiveDomains).some(
(domain) =>
domain === dirName || domain.endsWith(`.${dirName}`)
);
if (shouldDelete) {
const domainDir = path.join(certsPath, dirName);
logger.info(
`Cleaning up unused certificate directory: ${dirName}`
);
fs.rmSync(domainDir, { recursive: true, force: true });
// Remove from local state tracking
this.lastLocalCertificateState.delete(dirName);
// Remove from dynamic config
const certFilePath = path.join(
domainDir,
"cert.pem"
);
const keyFilePath = path.join(
domainDir,
"key.pem"
);
const before = dynamicConfig.tls.certificates.length;
dynamicConfig.tls.certificates =
dynamicConfig.tls.certificates.filter(
(entry: any) =>
entry.certFile !== certFilePath &&
entry.keyFile !== keyFilePath
);
if (dynamicConfig.tls.certificates.length !== before) {
configChanged = true;
}
}
}
if (configChanged) {
try {
fs.writeFileSync(
dynamicConfigPath,
yaml.dump(dynamicConfig, { noRefs: true }),
"utf8"
);
logger.info("Dynamic config updated after cleanup");
} catch (err) {
logger.error(
"Failed to update dynamic config after cleanup:",
err
);
}
}
} catch (error) {
logger.error("Error during certificate cleanup:", error);
}
}
/**
* Ensure directory exists
*/
private async ensureDirectoryExists(dirPath: string): Promise<void> {
try {
fs.mkdirSync(dirPath, { recursive: true });
} catch (error) {
logger.error(`Error creating directory ${dirPath}:`, error);
throw error;
}
}
/**
* Check if file exists
*/
private async fileExists(filePath: string): Promise<boolean> {
try {
fs.accessSync(filePath);
return true;
} catch {
return false;
}
}
/**
* Force a certificate refresh regardless of cache state
*/
public async forceCertificateRefresh(): Promise<void> {
logger.info("Forcing certificate refresh");
this.lastCertificateFetch = null;
this.lastKnownDomains = new Set();
await this.HandleTraefikConfig();
}
/**
* Get current status
*/
getStatus(): {
isRunning: boolean;
activeDomains: string[];
monitorInterval: number;
lastCertificateFetch: Date | null;
localCertificateCount: number;
} {
return {
isRunning: this.isRunning,
activeDomains: Array.from(this.activeDomains),
monitorInterval:
config.getRawConfig().traefik.monitor_interval || 5000,
lastCertificateFetch: this.lastCertificateFetch,
localCertificateCount: this.lastLocalCertificateState.size
};
}
}