Files
caddy-proxy-manager/src/lib/instance-sync.ts
fuomag9 00c9bff8b4 feat: instant banner refresh on L4 mutations + master-slave L4 sync
Banner (L4PortsApplyBanner):
- Accept refreshSignal prop; re-fetch /api/l4-ports when it changes
- Signal fires immediately after create/edit/delete/toggle in L4ProxyHostsClient
  without waiting for a page reload

Master-slave replication (instance-sync):
- Add l4ProxyHosts to SyncPayload.data (optional for backward compat
  with older master instances that don't include it)
- buildSyncPayload: query and include l4ProxyHosts, sanitize ownerUserId
- applySyncPayload: clear and re-insert l4ProxyHosts in transaction;
  call applyL4Ports() if port diff requires it so the slave's sidecar
  recreates caddy with the correct ports
- Sync route: add isL4ProxyHost validator; backfill missing field from
  old masters; validate array when present

Tests (25 new tests):
- instance-sync.test.ts: buildSyncPayload includes L4 data, sanitizes ownerUserId;
  applySyncPayload replaces L4 hosts, handles missing field, writes trigger
  when ports differ, skips trigger when ports already match
- l4-ports-apply-banner.test.ts: banner refreshSignal contract + client
  increments counter on all mutation paths

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-22 00:22:44 +01:00

473 lines
16 KiB
TypeScript

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<typeof certificates.$inferSelect>;
caCertificates: Array<typeof caCertificates.$inferSelect>;
issuedClientCertificates: Array<typeof issuedClientCertificates.$inferSelect>;
accessLists: Array<typeof accessLists.$inferSelect>;
accessListEntries: Array<typeof accessListEntries.$inferSelect>;
proxyHosts: Array<typeof proxyHosts.$inferSelect>;
/** Optional — not present in payloads from older master instances */
l4ProxyHosts?: Array<typeof l4ProxyHosts.$inferSelect>;
};
};
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<InstanceMode> {
// 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<string>(INSTANCE_MODE_KEY);
if (stored === "master" || stored === "slave" || stored === "standalone") {
return stored;
}
return "standalone";
}
export async function setInstanceMode(mode: InstanceMode): Promise<void> {
// 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<string | null> {
// 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<string>(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<void> {
// 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<string>(SLAVE_LAST_SYNC_AT_KEY),
getSetting<string>(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<T>(key: string): Promise<T | null> {
return await getSetting<T>(`${SYNCED_PREFIX}${key}`);
}
export async function setSyncedSetting<T>(key: string, value: T | null): Promise<void> {
await setSetting(`${SYNCED_PREFIX}${key}`, value ?? null);
}
export async function clearSyncedSetting(key: string): Promise<void> {
await setSetting(`${SYNCED_PREFIX}${key}`, null);
}
export async function buildSyncPayload(): Promise<SyncPayload> {
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();
}
}