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>
473 lines
16 KiB
TypeScript
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();
|
|
}
|
|
}
|