import db, { nowIso } from "./db"; import { accessListEntries, accessLists, caCertificates, certificates, issuedClientCertificates, l4ProxyHosts, proxyHosts } from "./db/schema"; import { getSetting, setSetting } from "./settings"; import { recordInstanceSyncResult, updateInstance } from "./models/instances"; import { decryptSecret, encryptSecret, isEncryptedSecret } from "./secret"; import { applyL4Ports, getL4PortsDiff } from "./l4-ports"; export type InstanceMode = "standalone" | "master" | "slave"; export type SyncSettings = { general: unknown | null; cloudflare: unknown | null; authentik: unknown | null; metrics: unknown | null; logging: unknown | null; dns: unknown | null; upstream_dns_resolution: unknown | null; waf: unknown | null; geoblock: unknown | null; }; export type SyncPayload = { generated_at: string; settings: SyncSettings; data: { certificates: Array; caCertificates: Array; issuedClientCertificates: Array; accessLists: Array; accessListEntries: Array; proxyHosts: Array; /** Optional — not present in payloads from older master instances */ l4ProxyHosts?: Array; }; }; const INSTANCE_MODE_KEY = "instance_mode"; const MASTER_TOKEN_KEY = "instance_master_token"; const SYNCED_PREFIX = "synced:"; const SLAVE_LAST_SYNC_AT_KEY = "instance_last_sync_at"; const SLAVE_LAST_SYNC_ERROR_KEY = "instance_last_sync_error"; /** * Environment variable names for instance sync configuration. * These take precedence over database settings when set. */ const ENV_INSTANCE_MODE = "INSTANCE_MODE"; const ENV_INSTANCE_SYNC_TOKEN = "INSTANCE_SYNC_TOKEN"; const ENV_INSTANCE_SLAVES = "INSTANCE_SLAVES"; const ENV_SYNC_INTERVAL = "INSTANCE_SYNC_INTERVAL"; const ENV_SYNC_ALLOW_HTTP = "INSTANCE_SYNC_ALLOW_HTTP"; /** * Type for slave instances configured via environment variable. */ export type EnvSlaveInstance = { name: string; url: string; token: string; }; /** * Parses INSTANCE_SLAVES environment variable. * Expected format: JSON array of {name, url, token} objects * Example: [{"name":"slave1","url":"http://slave:3000","token":"secret"}] */ export function getEnvSlaveInstances(): EnvSlaveInstance[] { const envValue = process.env[ENV_INSTANCE_SLAVES]; if (!envValue || envValue.trim().length === 0) { return []; } try { const parsed = JSON.parse(envValue); if (!Array.isArray(parsed)) { console.warn("INSTANCE_SLAVES must be a JSON array"); return []; } return parsed.filter((item): item is EnvSlaveInstance => { if (typeof item !== "object" || item === null) return false; if (typeof item.name !== "string" || item.name.trim().length === 0) return false; if (typeof item.url !== "string" || item.url.trim().length === 0) return false; if (typeof item.token !== "string" || item.token.trim().length === 0) return false; return true; }); } catch (error) { console.warn("Failed to parse INSTANCE_SLAVES environment variable:", error); return []; } } /** * Gets the sync interval in milliseconds from environment variable. * Default is 0 (disabled). Set INSTANCE_SYNC_INTERVAL to enable periodic sync. * Value is in seconds. */ export function getSyncIntervalMs(): number { const envValue = process.env[ENV_SYNC_INTERVAL]; if (!envValue) return 0; const seconds = parseInt(envValue, 10); if (isNaN(seconds) || seconds <= 0) return 0; // Minimum 30 seconds to prevent abuse return Math.max(seconds, 30) * 1000; } /** * Checks if HTTP sync is explicitly allowed via environment variable. * HTTP sync transmits tokens in plaintext and should only be used in trusted networks. */ export function isHttpSyncAllowed(): boolean { const envValue = process.env[ENV_SYNC_ALLOW_HTTP]; return envValue === "true" || envValue === "1"; } /** * Checks if a URL uses HTTP (not HTTPS). */ function isHttpUrl(url: string): boolean { try { const parsed = new URL(url); return parsed.protocol === "http:"; } catch { return false; } } /** * Checks if instance mode is configured via environment variable. * Environment variables take precedence over database settings. */ export function isInstanceModeFromEnv(): boolean { const envMode = process.env[ENV_INSTANCE_MODE]; return envMode === "master" || envMode === "slave" || envMode === "standalone"; } /** * Checks if sync token is configured via environment variable. */ export function isSyncTokenFromEnv(): boolean { const envToken = process.env[ENV_INSTANCE_SYNC_TOKEN]; return typeof envToken === "string" && envToken.length > 0; } export async function getInstanceMode(): Promise { // Environment variable takes precedence const envMode = process.env[ENV_INSTANCE_MODE]; if (envMode === "master" || envMode === "slave" || envMode === "standalone") { return envMode; } // Fall back to database setting const stored = await getSetting(INSTANCE_MODE_KEY); if (stored === "master" || stored === "slave" || stored === "standalone") { return stored; } return "standalone"; } export async function setInstanceMode(mode: InstanceMode): Promise { // If mode is set via environment, don't allow changing it if (isInstanceModeFromEnv()) { console.warn("Instance mode is configured via INSTANCE_MODE environment variable and cannot be changed at runtime"); return; } await setSetting(INSTANCE_MODE_KEY, mode); } export async function getSlaveMasterToken(): Promise { // Environment variable takes precedence const envToken = process.env[ENV_INSTANCE_SYNC_TOKEN]; if (typeof envToken === "string" && envToken.length > 0) { return envToken; } // Fall back to database setting const stored = await getSetting(MASTER_TOKEN_KEY); if (!stored) { return null; } if (!isEncryptedSecret(stored)) { try { await setSetting(MASTER_TOKEN_KEY, encryptSecret(stored)); } catch (error) { console.warn("Failed to encrypt stored master token:", error); } return stored; } try { return decryptSecret(stored); } catch (error) { console.error("Failed to decrypt stored master token:", error); return null; } } export async function setSlaveMasterToken(token: string | null): Promise { // If token is set via environment, don't allow changing it if (isSyncTokenFromEnv()) { console.warn("Sync token is configured via INSTANCE_SYNC_TOKEN environment variable and cannot be changed at runtime"); return; } const next = token ? encryptSecret(token) : ""; await setSetting(MASTER_TOKEN_KEY, next); } export async function getSlaveLastSync(): Promise<{ at: string | null; error: string | null }> { const [at, error] = await Promise.all([ getSetting(SLAVE_LAST_SYNC_AT_KEY), getSetting(SLAVE_LAST_SYNC_ERROR_KEY) ]); return { at: at ?? null, error: error && error.length > 0 ? error : null }; } export async function setSlaveLastSync(result: { ok: boolean; error?: string | null }) { await setSetting(SLAVE_LAST_SYNC_AT_KEY, nowIso()); await setSetting(SLAVE_LAST_SYNC_ERROR_KEY, result.ok ? "" : result.error ?? "Unknown sync error"); } export async function getSyncedSetting(key: string): Promise { return await getSetting(`${SYNCED_PREFIX}${key}`); } export async function setSyncedSetting(key: string, value: T | null): Promise { await setSetting(`${SYNCED_PREFIX}${key}`, value ?? null); } export async function clearSyncedSetting(key: string): Promise { await setSetting(`${SYNCED_PREFIX}${key}`, null); } export async function buildSyncPayload(): Promise { const [certRows, caCertRows, issuedClientCertRows, accessListRows, accessEntryRows, proxyRows, l4Rows] = await Promise.all([ db.select().from(certificates), db.select().from(caCertificates), db.select().from(issuedClientCertificates), db.select().from(accessLists), db.select().from(accessListEntries), db.select().from(proxyHosts), db.select().from(l4ProxyHosts), ]); const settings = { general: await getSetting("general"), cloudflare: await getSetting("cloudflare"), authentik: await getSetting("authentik"), metrics: await getSetting("metrics"), logging: await getSetting("logging"), dns: await getSetting("dns"), upstream_dns_resolution: await getSetting("upstream_dns_resolution"), waf: await getSetting("waf"), geoblock: await getSetting("geoblock"), }; const sanitizedAccessLists = accessListRows.map((row) => ({ ...row, createdBy: null })); const sanitizedCertificates = certRows.map((row) => ({ ...row, createdBy: null })); const sanitizedCaCertificates = caCertRows.map((row) => ({ ...row, createdBy: null })); const sanitizedIssuedClientCertificates = issuedClientCertRows.map((row) => ({ ...row, createdBy: null })); const sanitizedProxyHosts = proxyRows.map((row) => ({ ...row, ownerUserId: null })); const sanitizedL4ProxyHosts = l4Rows.map((row) => ({ ...row, ownerUserId: null })); return { generated_at: nowIso(), settings, data: { certificates: sanitizedCertificates, caCertificates: sanitizedCaCertificates, issuedClientCertificates: sanitizedIssuedClientCertificates, accessLists: sanitizedAccessLists, accessListEntries: accessEntryRows, proxyHosts: sanitizedProxyHosts, l4ProxyHosts: sanitizedL4ProxyHosts, } }; } export async function syncInstances(): Promise<{ total: number; success: number; failed: number; skippedHttp: number }> { const mode = await getInstanceMode(); if (mode !== "master") { return { total: 0, success: 0, failed: 0, skippedHttp: 0 }; } // Get database-configured instances const dbTargets = await db.query.instances.findMany({ where: (table, operators) => operators.eq(table.enabled, true) }); // Get environment-configured instances const envTargets = getEnvSlaveInstances(); if (dbTargets.length === 0 && envTargets.length === 0) { return { total: 0, success: 0, failed: 0, skippedHttp: 0 }; } const httpAllowed = isHttpSyncAllowed(); const payload = await buildSyncPayload(); // Sync database-configured instances const dbResults = await Promise.all( dbTargets.map(async (instance) => { if (!isEncryptedSecret(instance.apiToken)) { try { await updateInstance(instance.id, { apiToken: instance.apiToken }); } catch (error) { console.warn(`Failed to encrypt stored token for instance "${instance.name}":`, error); } } let token: string; try { token = decryptSecret(instance.apiToken); } catch (error) { const message = error instanceof Error ? error.message : String(error); await recordInstanceSyncResult(instance.id, { ok: false, error: `Token decrypt failed: ${message}` }); return { ok: false, skippedHttp: false }; } // Check for HTTP URL if (isHttpUrl(instance.baseUrl) && !httpAllowed) { const message = "HTTP sync blocked. Set INSTANCE_SYNC_ALLOW_HTTP=true to allow insecure sync."; console.warn(`Skipping sync to "${instance.name}": ${message}`); await recordInstanceSyncResult(instance.id, { ok: false, error: message }); return { ok: false, skippedHttp: true }; } try { const response = await fetch(`${instance.baseUrl.replace(/\/$/, "")}/api/instances/sync`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, body: JSON.stringify(payload) }); if (!response.ok) { const text = await response.text(); throw new Error(`Sync failed: ${response.status} ${text}`); } await recordInstanceSyncResult(instance.id, { ok: true }); return { ok: true, skippedHttp: false }; } catch (error) { const message = error instanceof Error ? error.message : String(error); await recordInstanceSyncResult(instance.id, { ok: false, error: message }); return { ok: false, skippedHttp: false }; } }) ); // Sync environment-configured instances const envResults = await Promise.all( envTargets.map(async (instance) => { // Check for HTTP URL if (isHttpUrl(instance.url) && !httpAllowed) { console.warn(`Skipping sync to env-configured instance "${instance.name}": HTTP sync blocked. Set INSTANCE_SYNC_ALLOW_HTTP=true to allow insecure sync.`); return { ok: false, skippedHttp: true }; } try { const response = await fetch(`${instance.url.replace(/\/$/, "")}/api/instances/sync`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${instance.token}` }, body: JSON.stringify(payload) }); if (!response.ok) { const text = await response.text(); throw new Error(`Sync failed: ${response.status} ${text}`); } console.log(`Sync to env-configured instance "${instance.name}" succeeded`); return { ok: true, skippedHttp: false }; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error(`Sync to env-configured instance "${instance.name}" failed:`, message); return { ok: false, skippedHttp: false }; } }) ); const allResults = [...dbResults, ...envResults]; const success = allResults.filter((r) => r.ok).length; const skippedHttp = allResults.filter((r) => r.skippedHttp).length; const failed = allResults.length - success - skippedHttp; return { total: allResults.length, success, failed, skippedHttp }; } export async function applySyncPayload(payload: SyncPayload) { await setSyncedSetting("general", payload.settings.general); await setSyncedSetting("cloudflare", payload.settings.cloudflare); await setSyncedSetting("authentik", payload.settings.authentik); await setSyncedSetting("metrics", payload.settings.metrics); await setSyncedSetting("logging", payload.settings.logging); await setSyncedSetting("dns", payload.settings.dns); await setSyncedSetting("upstream_dns_resolution", payload.settings.upstream_dns_resolution ?? null); await setSyncedSetting("waf", payload.settings.waf ?? null); await setSyncedSetting("geoblock", payload.settings.geoblock ?? null); // better-sqlite3 is synchronous, so transaction callback must be synchronous db.transaction((tx) => { tx.delete(l4ProxyHosts).run(); tx.delete(proxyHosts).run(); tx.delete(accessListEntries).run(); tx.delete(accessLists).run(); tx.delete(issuedClientCertificates).run(); tx.delete(certificates).run(); tx.delete(caCertificates).run(); if (payload.data.certificates.length > 0) { tx.insert(certificates).values(payload.data.certificates).run(); } if (payload.data.caCertificates && payload.data.caCertificates.length > 0) { tx.insert(caCertificates).values(payload.data.caCertificates).run(); } if (payload.data.issuedClientCertificates && payload.data.issuedClientCertificates.length > 0) { tx.insert(issuedClientCertificates).values(payload.data.issuedClientCertificates).run(); } if (payload.data.accessLists.length > 0) { tx.insert(accessLists).values(payload.data.accessLists).run(); } if (payload.data.accessListEntries.length > 0) { tx.insert(accessListEntries).values(payload.data.accessListEntries).run(); } if (payload.data.proxyHosts.length > 0) { tx.insert(proxyHosts).values(payload.data.proxyHosts).run(); } if (payload.data.l4ProxyHosts && payload.data.l4ProxyHosts.length > 0) { tx.insert(l4ProxyHosts).values(payload.data.l4ProxyHosts).run(); } }); // If the synced L4 proxy hosts require different ports than currently applied, // write the override file and trigger the sidecar to recreate the caddy container. const diff = await getL4PortsDiff(); if (diff.needsApply) { await applyL4Ports(); } }