Implement slave-master architecture

This commit is contained in:
fuomag9
2026-01-25 01:39:36 +01:00
parent 648d12bf16
commit 6fb39dc809
14 changed files with 2598 additions and 20 deletions

View File

@@ -1,5 +1,6 @@
"use client";
import { useState } from "react";
import { useFormState } from "react-dom";
import { Alert, Box, Button, Card, CardContent, Checkbox, FormControlLabel, MenuItem, Stack, TextField, Typography } from "@mui/material";
import type { GeneralSettings, AuthentikSettings, MetricsSettings, LoggingSettings, DnsSettings } from "@/src/lib/settings";
@@ -9,7 +10,13 @@ import {
updateAuthentikSettingsAction,
updateMetricsSettingsAction,
updateLoggingSettingsAction,
updateDnsSettingsAction
updateDnsSettingsAction,
updateInstanceModeAction,
updateSlaveMasterTokenAction,
createSlaveInstanceAction,
deleteSlaveInstanceAction,
toggleSlaveInstanceAction,
syncSlaveInstancesAction
} from "./actions";
type Props = {
@@ -23,15 +30,60 @@ type Props = {
metrics: MetricsSettings | null;
logging: LoggingSettings | null;
dns: DnsSettings | null;
instanceSync: {
mode: "standalone" | "master" | "slave";
modeFromEnv: boolean;
tokenFromEnv: boolean;
overrides: {
general: boolean;
cloudflare: boolean;
authentik: boolean;
metrics: boolean;
logging: boolean;
dns: boolean;
};
slave: {
hasToken: boolean;
lastSyncAt: string | null;
lastSyncError: string | null;
} | null;
master: {
instances: Array<{
id: number;
name: string;
base_url: string;
enabled: boolean;
last_sync_at: string | null;
last_sync_error: string | null;
}>;
envInstances: Array<{
name: string;
url: string;
}>;
} | null;
};
};
export default function SettingsClient({ general, cloudflare, authentik, metrics, logging, dns }: Props) {
export default function SettingsClient({ general, cloudflare, authentik, metrics, logging, dns, instanceSync }: Props) {
const [generalState, generalFormAction] = useFormState(updateGeneralSettingsAction, null);
const [cloudflareState, cloudflareFormAction] = useFormState(updateCloudflareSettingsAction, null);
const [authentikState, authentikFormAction] = useFormState(updateAuthentikSettingsAction, null);
const [metricsState, metricsFormAction] = useFormState(updateMetricsSettingsAction, null);
const [loggingState, loggingFormAction] = useFormState(updateLoggingSettingsAction, null);
const [dnsState, dnsFormAction] = useFormState(updateDnsSettingsAction, null);
const [instanceModeState, instanceModeFormAction] = useFormState(updateInstanceModeAction, null);
const [slaveTokenState, slaveTokenFormAction] = useFormState(updateSlaveMasterTokenAction, null);
const [slaveInstanceState, slaveInstanceFormAction] = useFormState(createSlaveInstanceAction, null);
const [syncState, syncFormAction] = useFormState(syncSlaveInstancesAction, null);
const isSlave = instanceSync.mode === "slave";
const isMaster = instanceSync.mode === "master";
const [generalOverride, setGeneralOverride] = useState(instanceSync.overrides.general);
const [cloudflareOverride, setCloudflareOverride] = useState(instanceSync.overrides.cloudflare);
const [authentikOverride, setAuthentikOverride] = useState(instanceSync.overrides.authentik);
const [metricsOverride, setMetricsOverride] = useState(instanceSync.overrides.metrics);
const [loggingOverride, setLoggingOverride] = useState(instanceSync.overrides.logging);
const [dnsOverride, setDnsOverride] = useState(instanceSync.overrides.dns);
return (
<Stack spacing={4} sx={{ width: "100%" }}>
@@ -42,6 +94,222 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
<Typography color="text.secondary">Configure organization-wide defaults and DNS automation.</Typography>
</Stack>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
Instance Sync
</Typography>
<Typography color="text.secondary" variant="body2" sx={{ mb: 2 }}>
Choose whether this instance acts independently, pushes configuration to slave nodes, or pulls configuration from a master.
</Typography>
<Stack component="form" action={instanceModeFormAction} spacing={2}>
{instanceSync.modeFromEnv && (
<Alert severity="info">
Instance mode is configured via INSTANCE_MODE environment variable and cannot be changed at runtime.
</Alert>
)}
{instanceModeState?.message && (
<Alert severity={instanceModeState.success ? "success" : "error"}>
{instanceModeState.message}
</Alert>
)}
<TextField
name="mode"
label="Instance mode"
select
defaultValue={instanceSync.mode}
disabled={instanceSync.modeFromEnv}
fullWidth
>
<MenuItem value="standalone">Standalone</MenuItem>
<MenuItem value="master">Master</MenuItem>
<MenuItem value="slave">Slave</MenuItem>
</TextField>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" disabled={instanceSync.modeFromEnv}>
Save instance mode
</Button>
</Box>
</Stack>
{isSlave && (
<Stack spacing={2} sx={{ mt: 3 }}>
<Typography variant="subtitle1" fontWeight={600}>
Master Connection
</Typography>
<Stack component="form" action={slaveTokenFormAction} spacing={2}>
{instanceSync.tokenFromEnv && (
<Alert severity="info">
Sync token is configured via INSTANCE_SYNC_TOKEN environment variable and cannot be changed at runtime.
</Alert>
)}
{slaveTokenState?.message && (
<Alert severity={slaveTokenState.success ? "success" : "error"}>
{slaveTokenState.message}
</Alert>
)}
{instanceSync.slave?.hasToken && !instanceSync.tokenFromEnv && (
<Alert severity="info">
A master sync token is configured. Leave the token field blank to keep it, or select "Remove existing token" to delete it.
</Alert>
)}
<TextField
name="masterToken"
label="Master sync token"
type="password"
autoComplete="new-password"
placeholder="Enter new token"
disabled={instanceSync.tokenFromEnv}
fullWidth
/>
<FormControlLabel
control={<Checkbox name="clearToken" />}
label="Remove existing token"
disabled={!instanceSync.slave?.hasToken || instanceSync.tokenFromEnv}
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained" disabled={instanceSync.tokenFromEnv}>
Save master token
</Button>
</Box>
</Stack>
<Alert severity={instanceSync.slave?.lastSyncError ? "warning" : "info"}>
{instanceSync.slave?.lastSyncAt
? `Last sync: ${instanceSync.slave.lastSyncAt}${instanceSync.slave.lastSyncError ? ` (${instanceSync.slave.lastSyncError})` : ""}`
: "No sync payload has been received yet."}
</Alert>
</Stack>
)}
{isMaster && (
<Stack spacing={2} sx={{ mt: 3 }}>
<Typography variant="subtitle1" fontWeight={600}>
Slave Instances
</Typography>
<Stack component="form" action={slaveInstanceFormAction} spacing={2}>
{slaveInstanceState?.message && (
<Alert severity={slaveInstanceState.success ? "success" : "error"}>
{slaveInstanceState.message}
</Alert>
)}
<TextField name="name" label="Instance name" placeholder="Edge node EU-1" fullWidth />
<TextField name="baseUrl" label="Base URL" placeholder="https://slave-1.example.com" fullWidth />
<TextField name="apiToken" label="Slave API token" type="password" autoComplete="new-password" fullWidth />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Add slave instance
</Button>
</Box>
</Stack>
<Stack component="form" action={syncFormAction} spacing={2}>
{syncState?.message && (
<Alert severity={syncState.success ? "success" : "warning"}>
{syncState.message}
</Alert>
)}
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="outlined">
Sync now
</Button>
</Box>
</Stack>
{instanceSync.master?.instances.length === 0 && instanceSync.master?.envInstances.length === 0 && (
<Alert severity="info">No slave instances configured yet.</Alert>
)}
{instanceSync.master?.envInstances && instanceSync.master.envInstances.length > 0 && (
<>
<Typography variant="subtitle2" color="text.secondary" sx={{ mt: 1 }}>
Environment-configured instances (via INSTANCE_SLAVES)
</Typography>
{instanceSync.master.envInstances.map((instance, index) => (
<Box
key={`env-${index}`}
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
p: 2,
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: 2,
bgcolor: "action.hover"
}}
>
<Box>
<Typography fontWeight={600}>{instance.name}</Typography>
<Typography variant="body2" color="text.secondary">
{instance.url}
</Typography>
<Typography variant="caption" color="text.secondary">
Configured via environment variable
</Typography>
</Box>
</Box>
))}
</>
)}
{instanceSync.master?.instances && instanceSync.master.instances.length > 0 && (
<Typography variant="subtitle2" color="text.secondary" sx={{ mt: 1 }}>
UI-configured instances
</Typography>
)}
{instanceSync.master?.instances.map((instance) => (
<Box
key={instance.id}
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: 2,
p: 2,
display: "flex",
flexWrap: "wrap",
alignItems: "center",
justifyContent: "space-between",
gap: 2
}}
>
<Box>
<Typography fontWeight={600}>{instance.name}</Typography>
<Typography variant="body2" color="text.secondary">
{instance.base_url}
</Typography>
<Typography variant="caption" color="text.secondary">
{instance.last_sync_at ? `Last sync: ${instance.last_sync_at}` : "No sync yet"}
</Typography>
{instance.last_sync_error && (
<Typography variant="caption" color="error" display="block">
{instance.last_sync_error}
</Typography>
)}
</Box>
<Stack direction="row" spacing={1}>
<Box component="form" action={toggleSlaveInstanceAction}>
<input type="hidden" name="instanceId" value={instance.id} />
<input type="hidden" name="enabled" value={instance.enabled ? "" : "on"} />
<Button type="submit" variant="outlined" color={instance.enabled ? "warning" : "success"}>
{instance.enabled ? "Disable" : "Enable"}
</Button>
</Box>
<Box component="form" action={deleteSlaveInstanceAction}>
<input type="hidden" name="instanceId" value={instance.id} />
<Button type="submit" variant="outlined" color="error">
Remove
</Button>
</Box>
</Stack>
</Box>
))}
</Stack>
)}
</CardContent>
</Card>
<Card>
<CardContent>
<Typography variant="h6" fontWeight={600} gutterBottom>
@@ -53,11 +321,24 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
{generalState.message}
</Alert>
)}
{isSlave && (
<FormControlLabel
control={
<Checkbox
name="overrideEnabled"
checked={generalOverride}
onChange={(event) => setGeneralOverride(event.target.checked)}
/>
}
label="Override master settings"
/>
)}
<TextField
name="primaryDomain"
label="Primary domain"
defaultValue={general?.primaryDomain ?? "caddyproxymanager.com"}
required
disabled={isSlave && !generalOverride}
fullWidth
/>
<TextField
@@ -65,6 +346,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
label="ACME contact email"
type="email"
defaultValue={general?.acmeEmail ?? ""}
disabled={isSlave && !generalOverride}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
@@ -95,21 +377,34 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
{cloudflareState.message}
</Alert>
)}
{isSlave && (
<FormControlLabel
control={
<Checkbox
name="overrideEnabled"
checked={cloudflareOverride}
onChange={(event) => setCloudflareOverride(event.target.checked)}
/>
}
label="Override master settings"
/>
)}
<TextField
name="apiToken"
label="API token"
type="password"
autoComplete="new-password"
placeholder="Enter new token"
disabled={isSlave && !cloudflareOverride}
fullWidth
/>
<FormControlLabel
control={<Checkbox name="clearToken" />}
label="Remove existing token"
disabled={!cloudflare.hasToken}
disabled={!cloudflare.hasToken || (isSlave && !cloudflareOverride)}
/>
<TextField name="zoneId" label="Zone ID" defaultValue={cloudflare.zoneId ?? ""} fullWidth />
<TextField name="accountId" label="Account ID" defaultValue={cloudflare.accountId ?? ""} fullWidth />
<TextField name="zoneId" label="Zone ID" defaultValue={cloudflare.zoneId ?? ""} disabled={isSlave && !cloudflareOverride} fullWidth />
<TextField name="accountId" label="Account ID" defaultValue={cloudflare.accountId ?? ""} disabled={isSlave && !cloudflareOverride} fullWidth />
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
<Button type="submit" variant="contained">
Save Cloudflare settings
@@ -133,8 +428,20 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
{dnsState.message}
</Alert>
)}
{isSlave && (
<FormControlLabel
control={
<Checkbox
name="overrideEnabled"
checked={dnsOverride}
onChange={(event) => setDnsOverride(event.target.checked)}
/>
}
label="Override master settings"
/>
)}
<FormControlLabel
control={<Checkbox name="enabled" defaultChecked={dns?.enabled ?? false} />}
control={<Checkbox name="enabled" defaultChecked={dns?.enabled ?? false} disabled={isSlave && !dnsOverride} />}
label="Enable custom DNS resolvers"
/>
<TextField
@@ -145,6 +452,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
helperText="One resolver per line (e.g., 1.1.1.1, 8.8.8.8). Used for ACME DNS verification."
multiline
minRows={2}
disabled={isSlave && !dnsOverride}
fullWidth
/>
<TextField
@@ -155,6 +463,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
helperText="Fallback resolvers if primary fails. One per line."
multiline
minRows={2}
disabled={isSlave && !dnsOverride}
fullWidth
/>
<TextField
@@ -163,6 +472,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
placeholder="5s"
defaultValue={dns?.timeout ?? ""}
helperText="Timeout for DNS queries (e.g., 5s, 10s)"
disabled={isSlave && !dnsOverride}
fullWidth
/>
<Alert severity="info">
@@ -192,6 +502,18 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
{authentikState.message}
</Alert>
)}
{isSlave && (
<FormControlLabel
control={
<Checkbox
name="overrideEnabled"
checked={authentikOverride}
onChange={(event) => setAuthentikOverride(event.target.checked)}
/>
}
label="Override master settings"
/>
)}
<TextField
name="outpostDomain"
label="Outpost Domain"
@@ -199,6 +521,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
defaultValue={authentik?.outpostDomain ?? ""}
helperText="Authentik outpost domain"
required
disabled={isSlave && !authentikOverride}
fullWidth
/>
<TextField
@@ -208,6 +531,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
defaultValue={authentik?.outpostUpstream ?? ""}
helperText="Internal URL of Authentik outpost"
required
disabled={isSlave && !authentikOverride}
fullWidth
/>
<TextField
@@ -216,6 +540,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
placeholder="/outpost.goauthentik.io/auth/caddy"
defaultValue={authentik?.authEndpoint ?? ""}
helperText="Authpost endpoint path"
disabled={isSlave && !authentikOverride}
fullWidth
/>
<Box sx={{ display: "flex", justifyContent: "flex-end" }}>
@@ -242,8 +567,20 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
{metricsState.message}
</Alert>
)}
{isSlave && (
<FormControlLabel
control={
<Checkbox
name="overrideEnabled"
checked={metricsOverride}
onChange={(event) => setMetricsOverride(event.target.checked)}
/>
}
label="Override master settings"
/>
)}
<FormControlLabel
control={<Checkbox name="enabled" defaultChecked={metrics?.enabled ?? false} />}
control={<Checkbox name="enabled" defaultChecked={metrics?.enabled ?? false} disabled={isSlave && !metricsOverride} />}
label="Enable metrics endpoint"
/>
<TextField
@@ -252,6 +589,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
type="number"
defaultValue={metrics?.port ?? 9090}
helperText="Port to expose metrics on (default: 9090, separate from admin API on 2019)"
disabled={isSlave && !metricsOverride}
fullWidth
/>
<Alert severity="info">
@@ -282,8 +620,20 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
{loggingState.message}
</Alert>
)}
{isSlave && (
<FormControlLabel
control={
<Checkbox
name="overrideEnabled"
checked={loggingOverride}
onChange={(event) => setLoggingOverride(event.target.checked)}
/>
}
label="Override master settings"
/>
)}
<FormControlLabel
control={<Checkbox name="enabled" defaultChecked={logging?.enabled ?? false} />}
control={<Checkbox name="enabled" defaultChecked={logging?.enabled ?? false} disabled={isSlave && !loggingOverride} />}
label="Enable access logging"
/>
<TextField
@@ -292,6 +642,7 @@ export default function SettingsClient({ general, cloudflare, authentik, metrics
select
defaultValue={logging?.format ?? "json"}
helperText="Format for access logs"
disabled={isSlave && !loggingOverride}
fullWidth
>
<MenuItem value="json">JSON</MenuItem>

View File

@@ -3,20 +3,48 @@
import { revalidatePath } from "next/cache";
import { requireAdmin } from "@/src/lib/auth";
import { applyCaddyConfig } from "@/src/lib/caddy";
import { getCloudflareSettings, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings } from "@/src/lib/settings";
import { getInstanceMode, getSlaveMasterToken, setInstanceMode, setSlaveMasterToken, syncInstances } from "@/src/lib/instance-sync";
import { createInstance, deleteInstance, updateInstance } from "@/src/lib/models/instances";
import { clearSetting, getSetting, saveCloudflareSettings, saveGeneralSettings, saveAuthentikSettings, saveMetricsSettings, saveLoggingSettings, saveDnsSettings } from "@/src/lib/settings";
import type { CloudflareSettings } from "@/src/lib/settings";
type ActionResult = {
success: boolean;
message?: string;
};
const MIN_TOKEN_LENGTH = 32;
/**
* Validates that a sync token meets minimum security requirements.
* Tokens must be at least 32 characters to provide adequate entropy.
*/
function validateSyncToken(token: string): { valid: boolean; error?: string } {
if (token.length < MIN_TOKEN_LENGTH) {
return {
valid: false,
error: `Token must be at least ${MIN_TOKEN_LENGTH} characters for security. Consider using a randomly generated token.`
};
}
return { valid: true };
}
export async function updateGeneralSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
const overrideEnabled = formData.get("overrideEnabled") === "on";
if (mode === "slave" && !overrideEnabled) {
await clearSetting("general");
await syncInstances();
revalidatePath("/settings");
return { success: true, message: "General settings reset to master defaults" };
}
await saveGeneralSettings({
primaryDomain: String(formData.get("primaryDomain") ?? ""),
acmeEmail: formData.get("acmeEmail") ? String(formData.get("acmeEmail")) : undefined
});
await syncInstances();
revalidatePath("/settings");
return { success: true, message: "General settings saved successfully" };
} catch (error) {
@@ -28,9 +56,28 @@ export async function updateGeneralSettingsAction(_prevState: ActionResult | nul
export async function updateCloudflareSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
const overrideEnabled = formData.get("overrideEnabled") === "on";
if (mode === "slave" && !overrideEnabled) {
await clearSetting("cloudflare");
try {
await applyCaddyConfig();
revalidatePath("/settings");
return { success: true, message: "Cloudflare settings reset to master defaults" };
} catch (error) {
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true,
message: `Settings reset, but could not apply to Caddy: ${errorMsg}`
};
}
}
const rawToken = formData.get("apiToken") ? String(formData.get("apiToken")).trim() : "";
const clearToken = formData.get("clearToken") === "on";
const current = await getCloudflareSettings();
const current = await getSetting<CloudflareSettings>("cloudflare");
const apiToken = clearToken ? "" : rawToken || current?.apiToken || "";
const zoneId = formData.get("zoneId") ? String(formData.get("zoneId")) : undefined;
@@ -51,6 +98,7 @@ export async function updateCloudflareSettingsAction(_prevState: ActionResult |
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true, // Settings were saved successfully
message: `Settings saved, but could not apply to Caddy: ${errorMsg}. You may need to start Caddy or check your configuration.`
@@ -65,6 +113,14 @@ export async function updateCloudflareSettingsAction(_prevState: ActionResult |
export async function updateAuthentikSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
const overrideEnabled = formData.get("overrideEnabled") === "on";
if (mode === "slave" && !overrideEnabled) {
await clearSetting("authentik");
await syncInstances();
revalidatePath("/settings");
return { success: true, message: "Authentik defaults reset to master values" };
}
const outpostDomain = String(formData.get("outpostDomain") ?? "").trim();
const outpostUpstream = String(formData.get("outpostUpstream") ?? "").trim();
const authEndpoint = formData.get("authEndpoint") ? String(formData.get("authEndpoint")).trim() : undefined;
@@ -79,6 +135,7 @@ export async function updateAuthentikSettingsAction(_prevState: ActionResult | n
authEndpoint: authEndpoint && authEndpoint.length > 0 ? authEndpoint : undefined
});
await syncInstances();
revalidatePath("/settings");
return { success: true, message: "Authentik defaults saved successfully" };
} catch (error) {
@@ -90,6 +147,25 @@ export async function updateAuthentikSettingsAction(_prevState: ActionResult | n
export async function updateMetricsSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
const overrideEnabled = formData.get("overrideEnabled") === "on";
if (mode === "slave" && !overrideEnabled) {
await clearSetting("metrics");
try {
await applyCaddyConfig();
revalidatePath("/settings");
return { success: true, message: "Metrics settings reset to master defaults" };
} catch (error) {
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true,
message: `Settings reset, but could not apply to Caddy: ${errorMsg}`
};
}
}
const enabled = formData.get("enabled") === "on";
const portStr = formData.get("port") ? String(formData.get("port")).trim() : "";
const port = portStr && !isNaN(Number(portStr)) ? Number(portStr) : 9090;
@@ -108,6 +184,7 @@ export async function updateMetricsSettingsAction(_prevState: ActionResult | nul
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true,
message: `Settings saved, but could not apply to Caddy: ${errorMsg}`
@@ -122,6 +199,25 @@ export async function updateMetricsSettingsAction(_prevState: ActionResult | nul
export async function updateLoggingSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
const overrideEnabled = formData.get("overrideEnabled") === "on";
if (mode === "slave" && !overrideEnabled) {
await clearSetting("logging");
try {
await applyCaddyConfig();
revalidatePath("/settings");
return { success: true, message: "Logging settings reset to master defaults" };
} catch (error) {
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true,
message: `Settings reset, but could not apply to Caddy: ${errorMsg}`
};
}
}
const enabled = formData.get("enabled") === "on";
const format = formData.get("format") ? String(formData.get("format")).trim() : "json";
@@ -144,6 +240,7 @@ export async function updateLoggingSettingsAction(_prevState: ActionResult | nul
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true,
message: `Settings saved, but could not apply to Caddy: ${errorMsg}`
@@ -166,6 +263,25 @@ function parseResolverList(value: string | null): string[] {
export async function updateDnsSettingsAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
const overrideEnabled = formData.get("overrideEnabled") === "on";
if (mode === "slave" && !overrideEnabled) {
await clearSetting("dns");
try {
await applyCaddyConfig();
revalidatePath("/settings");
return { success: true, message: "DNS settings reset to master defaults" };
} catch (error) {
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true,
message: `Settings reset, but could not apply to Caddy: ${errorMsg}`
};
}
}
const enabled = formData.get("enabled") === "on";
const resolversRaw = formData.get("resolvers") ? String(formData.get("resolvers")) : "";
const fallbacksRaw = formData.get("fallbacks") ? String(formData.get("fallbacks")) : "";
@@ -194,6 +310,7 @@ export async function updateDnsSettingsAction(_prevState: ActionResult | null, f
console.error("Failed to apply Caddy config:", error);
revalidatePath("/settings");
const errorMsg = error instanceof Error ? error.message : "Unknown error";
await syncInstances();
return {
success: true,
message: `Settings saved, but could not apply to Caddy: ${errorMsg}`
@@ -204,3 +321,144 @@ export async function updateDnsSettingsAction(_prevState: ActionResult | null, f
return { success: false, message: error instanceof Error ? error.message : "Failed to save DNS settings" };
}
}
export async function updateInstanceModeAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = String(formData.get("mode") ?? "").trim() as "standalone" | "master" | "slave";
if (mode !== "standalone" && mode !== "master" && mode !== "slave") {
return { success: false, message: "Invalid instance mode" };
}
await setInstanceMode(mode);
revalidatePath("/settings");
return { success: true, message: `Instance mode set to ${mode}` };
} catch (error) {
console.error("Failed to update instance mode:", error);
return { success: false, message: error instanceof Error ? error.message : "Failed to update instance mode" };
}
}
export async function updateSlaveMasterTokenAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const clearToken = formData.get("clearToken") === "on";
const rawToken = formData.get("masterToken") ? String(formData.get("masterToken")).trim() : "";
const current = await getSlaveMasterToken();
// If clearing, allow empty token
if (clearToken) {
await setSlaveMasterToken("");
revalidatePath("/settings");
return { success: true, message: "Master sync token removed" };
}
// If a new token is provided, validate it
if (rawToken) {
const validation = validateSyncToken(rawToken);
if (!validation.valid) {
return { success: false, message: validation.error };
}
await setSlaveMasterToken(rawToken);
revalidatePath("/settings");
return { success: true, message: "Master sync token updated" };
}
// No change - keep existing token
if (!current) {
return { success: false, message: "No token provided. Please enter a sync token." };
}
return { success: true, message: "Master sync token unchanged" };
} catch (error) {
console.error("Failed to update master token:", error);
return { success: false, message: error instanceof Error ? error.message : "Failed to update master token" };
}
}
export async function createSlaveInstanceAction(_prevState: ActionResult | null, formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
if (mode !== "master") {
return { success: false, message: "Instance mode must be set to master to add slaves" };
}
const name = String(formData.get("name") ?? "").trim();
const baseUrl = String(formData.get("baseUrl") ?? "").trim().replace(/\/$/, "");
const apiToken = String(formData.get("apiToken") ?? "").trim();
if (!name || !baseUrl || !apiToken) {
return { success: false, message: "Name, base URL, and API token are required" };
}
// Validate token complexity
const validation = validateSyncToken(apiToken);
if (!validation.valid) {
return { success: false, message: validation.error };
}
await createInstance({ name, baseUrl, apiToken, enabled: true });
revalidatePath("/settings");
return { success: true, message: "Slave instance added" };
} catch (error) {
console.error("Failed to create slave instance:", error);
return { success: false, message: error instanceof Error ? error.message : "Failed to create slave instance" };
}
}
export async function deleteSlaveInstanceAction(formData: FormData): Promise<void> {
await requireAdmin();
const mode = await getInstanceMode();
if (mode !== "master") {
return;
}
const id = Number(formData.get("instanceId"));
if (Number.isNaN(id)) {
return;
}
await deleteInstance(id);
revalidatePath("/settings");
}
export async function toggleSlaveInstanceAction(formData: FormData): Promise<void> {
await requireAdmin();
const mode = await getInstanceMode();
if (mode !== "master") {
return;
}
const id = Number(formData.get("instanceId"));
const enabled = formData.get("enabled") === "on";
if (Number.isNaN(id)) {
return;
}
await updateInstance(id, { enabled });
revalidatePath("/settings");
}
export async function syncSlaveInstancesAction(_prevState: ActionResult | null, _formData: FormData): Promise<ActionResult> {
try {
await requireAdmin();
const mode = await getInstanceMode();
if (mode !== "master") {
return { success: false, message: "Instance mode must be set to master to sync slaves" };
}
const result = await syncInstances();
revalidatePath("/settings");
const parts: string[] = [];
if (result.success > 0) parts.push(`${result.success} succeeded`);
if (result.failed > 0) parts.push(`${result.failed} failed`);
if (result.skippedHttp > 0) parts.push(`${result.skippedHttp} skipped (HTTP blocked)`);
if (result.skippedHttp > 0) {
return {
success: result.success > 0,
message: `Sync: ${parts.join(", ")}. Set INSTANCE_SYNC_ALLOW_HTTP=true to allow insecure HTTP sync.`
};
}
if (result.failed > 0) {
return { success: true, message: `Sync completed with ${result.failed} failures (${result.success}/${result.total} succeeded)` };
}
return { success: true, message: `Sync completed (${result.success}/${result.total} succeeded)` };
} catch (error) {
console.error("Failed to sync slave instances:", error);
return { success: false, message: error instanceof Error ? error.message : "Failed to sync slave instances" };
}
}

View File

@@ -1,19 +1,45 @@
import SettingsClient from "./SettingsClient";
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings } from "@/src/lib/settings";
import { getCloudflareSettings, getGeneralSettings, getAuthentikSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, getSetting } from "@/src/lib/settings";
import { getInstanceMode, getSlaveLastSync, getSlaveMasterToken, isInstanceModeFromEnv, isSyncTokenFromEnv, getEnvSlaveInstances } from "@/src/lib/instance-sync";
import { listInstances } from "@/src/lib/models/instances";
import { requireAdmin } from "@/src/lib/auth";
export default async function SettingsPage() {
await requireAdmin();
const [general, cloudflare, authentik, metrics, logging, dns] = await Promise.all([
// Check if configuration is from environment variables
const modeFromEnv = isInstanceModeFromEnv();
const tokenFromEnv = isSyncTokenFromEnv();
const [general, cloudflare, authentik, metrics, logging, dns, instanceMode] = await Promise.all([
getGeneralSettings(),
getCloudflareSettings(),
getAuthentikSettings(),
getMetricsSettings(),
getLoggingSettings(),
getDnsSettings()
getDnsSettings(),
getInstanceMode()
]);
const [overrideGeneral, overrideCloudflare, overrideAuthentik, overrideMetrics, overrideLogging, overrideDns] =
instanceMode === "slave"
? await Promise.all([
getSetting("general"),
getSetting("cloudflare"),
getSetting("authentik"),
getSetting("metrics"),
getSetting("logging"),
getSetting("dns")
])
: [null, null, null, null, null, null];
const [slaveToken, slaveLastSync] = instanceMode === "slave"
? await Promise.all([getSlaveMasterToken(), getSlaveLastSync()])
: [null, null];
const instances = instanceMode === "master" ? await listInstances() : [];
const envInstances = instanceMode === "master" ? getEnvSlaveInstances() : [];
return (
<SettingsClient
general={general}
@@ -26,6 +52,25 @@ export default async function SettingsPage() {
metrics={metrics}
logging={logging}
dns={dns}
instanceSync={{
mode: instanceMode,
modeFromEnv,
tokenFromEnv,
overrides: {
general: overrideGeneral !== null,
cloudflare: overrideCloudflare !== null,
authentik: overrideAuthentik !== null,
metrics: overrideMetrics !== null,
logging: overrideLogging !== null,
dns: overrideDns !== null
},
slave: instanceMode === "slave" ? {
hasToken: Boolean(slaveToken),
lastSyncAt: slaveLastSync?.at ?? null,
lastSyncError: slaveLastSync?.error ?? null
} : null,
master: instanceMode === "master" ? { instances, envInstances } : null
}}
/>
);
}

View File

@@ -0,0 +1,92 @@
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "crypto";
import { applyCaddyConfig } from "@/src/lib/caddy";
import { applySyncPayload, getInstanceMode, getSlaveMasterToken, setSlaveLastSync, SyncPayload } from "@/src/lib/instance-sync";
/**
* Timing-safe token comparison to prevent timing attacks
*/
function secureTokenCompare(a: string, b: string): boolean {
if (a.length !== b.length) {
// Compare against dummy to maintain constant time
const dummy = Buffer.alloc(a.length, 0);
timingSafeEqual(Buffer.from(a), dummy);
return false;
}
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
}
/**
* Validates that the payload has the expected structure for syncing
*/
function isValidSyncPayload(payload: unknown): payload is SyncPayload {
if (payload === null || typeof payload !== "object") {
return false;
}
const p = payload as Record<string, unknown>;
// Check required top-level properties
if (!("settings" in p) || !("data" in p)) {
return false;
}
// Validate settings is an object
if (p.settings !== null && typeof p.settings !== "object") {
return false;
}
// Validate data has required array properties
const data = p.data;
if (data === null || typeof data !== "object") {
return false;
}
const d = data as Record<string, unknown>;
const requiredArrays = ["certificates", "accessLists", "accessListEntries", "proxyHosts", "redirectHosts", "deadHosts"];
for (const key of requiredArrays) {
if (!(key in d) || !Array.isArray(d[key])) {
return false;
}
}
return true;
}
export async function POST(request: NextRequest) {
const mode = await getInstanceMode();
if (mode !== "slave") {
return NextResponse.json({ error: "Instance is not configured as a slave" }, { status: 403 });
}
const authHeader = request.headers.get("authorization") ?? "";
const token = authHeader.startsWith("Bearer ") ? authHeader.slice(7) : "";
const expected = await getSlaveMasterToken();
if (!expected || !secureTokenCompare(token, expected)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let payload: unknown;
try {
payload = await request.json();
} catch (error) {
return NextResponse.json({ error: "Invalid JSON payload" }, { status: 400 });
}
if (!isValidSyncPayload(payload)) {
return NextResponse.json({ error: "Invalid sync payload structure" }, { status: 400 });
}
try {
await applySyncPayload(payload);
await applyCaddyConfig();
await setSlaveLastSync({ ok: true });
return NextResponse.json({ ok: true });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to apply sync payload";
await setSlaveLastSync({ ok: false, error: message });
return NextResponse.json({ error: message }, { status: 500 });
}
}

View File

@@ -0,0 +1,13 @@
CREATE TABLE `instances` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`name` text NOT NULL,
`base_url` text NOT NULL,
`api_token` text NOT NULL,
`enabled` integer DEFAULT true NOT NULL,
`last_sync_at` text,
`last_sync_error` text,
`created_at` text NOT NULL,
`updated_at` text NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `instances_base_url_unique` ON `instances` (`base_url`);

File diff suppressed because it is too large Load Diff

View File

@@ -22,6 +22,13 @@
"when": 1766880443160,
"tag": "0002_perfect_hedge_knight",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1769262874211,
"tag": "0003_instances",
"breakpoints": true
}
]
}

View File

@@ -14,7 +14,12 @@ export default auth((req) => {
const pathname = req.nextUrl.pathname;
// Allow public routes
if (pathname === "/login" || pathname.startsWith("/api/auth") || pathname === "/api/health") {
if (
pathname === "/login" ||
pathname.startsWith("/api/auth") ||
pathname === "/api/health" ||
pathname === "/api/instances/sync"
) {
return NextResponse.next();
}

View File

@@ -47,5 +47,29 @@ export async function register() {
console.error("Failed to start Caddy health monitoring:", error);
// Don't throw - monitoring is a nice-to-have feature
}
// Start periodic instance sync if configured (master mode only)
const { getInstanceMode, getSyncIntervalMs, syncInstances } = await import("./lib/instance-sync");
try {
const mode = await getInstanceMode();
const intervalMs = getSyncIntervalMs();
if (mode === "master" && intervalMs > 0) {
console.log(`Starting periodic instance sync (every ${intervalMs / 1000}s)`);
setInterval(async () => {
try {
const result = await syncInstances();
if (result.total > 0) {
console.log(`Periodic sync completed: ${result.success}/${result.total} succeeded`);
}
} catch (error) {
console.error("Periodic sync failed:", error);
}
}, intervalMs);
}
} catch (error) {
console.error("Failed to start periodic instance sync:", error);
// Don't throw - periodic sync is optional
}
}
}

View File

@@ -4,6 +4,7 @@ import crypto from "node:crypto";
import db, { nowIso } from "./db";
import { config } from "./config";
import { getCloudflareSettings, getGeneralSettings, getMetricsSettings, getLoggingSettings, getDnsSettings, setSetting } from "./settings";
import { syncInstances } from "./instance-sync";
import {
accessListEntries,
certificates,
@@ -1174,6 +1175,8 @@ export async function applyCaddyConfig() {
const text = await response.text();
throw new Error(`Caddy config load failed: ${response.status} ${text}`);
}
await syncInstances();
} catch (error) {
console.error("Failed to apply Caddy config", error);

View File

@@ -70,6 +70,24 @@ export const settings = sqliteTable("settings", {
updatedAt: text("updated_at").notNull()
});
export const instances = sqliteTable(
"instances",
{
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),
baseUrl: text("base_url").notNull(),
apiToken: text("api_token").notNull(),
enabled: integer("enabled", { mode: "boolean" }).notNull().default(true),
lastSyncAt: text("last_sync_at"),
lastSyncError: text("last_sync_error"),
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull()
},
(table) => ({
baseUrlUnique: uniqueIndex("instances_base_url_unique").on(table.baseUrl)
})
);
export const accessLists = sqliteTable("access_lists", {
id: integer("id").primaryKey({ autoIncrement: true }),
name: text("name").notNull(),

407
src/lib/instance-sync.ts Normal file
View File

@@ -0,0 +1,407 @@
import db, { nowIso } from "./db";
import { accessListEntries, accessLists, certificates, deadHosts, proxyHosts, redirectHosts } from "./db/schema";
import { getSetting, setSetting } from "./settings";
import { recordInstanceSyncResult } from "./models/instances";
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;
};
export type SyncPayload = {
generated_at: string;
settings: SyncSettings;
data: {
certificates: Array<typeof certificates.$inferSelect>;
accessLists: Array<typeof accessLists.$inferSelect>;
accessListEntries: Array<typeof accessListEntries.$inferSelect>;
proxyHosts: Array<typeof proxyHosts.$inferSelect>;
redirectHosts: Array<typeof redirectHosts.$inferSelect>;
deadHosts: Array<typeof deadHosts.$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
return await getSetting<string>(MASTER_TOKEN_KEY);
}
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;
}
await setSetting(MASTER_TOKEN_KEY, token ?? "");
}
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, accessListRows, accessEntryRows, proxyRows, redirectRows, deadRows] = await Promise.all([
db.select().from(certificates),
db.select().from(accessLists),
db.select().from(accessListEntries),
db.select().from(proxyHosts),
db.select().from(redirectHosts),
db.select().from(deadHosts)
]);
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")
};
const sanitizedAccessLists = accessListRows.map((row) => ({
...row,
createdBy: null
}));
const sanitizedCertificates = certRows.map((row) => ({
...row,
createdBy: null
}));
const sanitizedRedirects = redirectRows.map((row) => ({
...row,
createdBy: null
}));
const sanitizedDeadHosts = deadRows.map((row) => ({
...row,
createdBy: null
}));
const sanitizedProxyHosts = proxyRows.map((row) => ({
...row,
ownerUserId: null
}));
return {
generated_at: nowIso(),
settings,
data: {
certificates: sanitizedCertificates,
accessLists: sanitizedAccessLists,
accessListEntries: accessEntryRows,
proxyHosts: sanitizedProxyHosts,
redirectHosts: sanitizedRedirects,
deadHosts: sanitizedDeadHosts
}
};
}
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();
let skippedHttp = 0;
// Sync database-configured instances
const dbResults = await Promise.all(
dbTargets.map(async (instance) => {
// 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 ${instance.apiToken}`
},
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;
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);
// better-sqlite3 is synchronous, so transaction callback must be synchronous
db.transaction((tx) => {
tx.delete(proxyHosts).run();
tx.delete(redirectHosts).run();
tx.delete(deadHosts).run();
tx.delete(accessListEntries).run();
tx.delete(accessLists).run();
tx.delete(certificates).run();
if (payload.data.certificates.length > 0) {
tx.insert(certificates).values(payload.data.certificates).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.redirectHosts.length > 0) {
tx.insert(redirectHosts).values(payload.data.redirectHosts).run();
}
if (payload.data.deadHosts.length > 0) {
tx.insert(deadHosts).values(payload.data.deadHosts).run();
}
});
}

117
src/lib/models/instances.ts Normal file
View File

@@ -0,0 +1,117 @@
import db, { nowIso, toIso } from "../db";
import { instances } from "../db/schema";
import { asc, eq } from "drizzle-orm";
export type Instance = {
id: number;
name: string;
base_url: string;
enabled: boolean;
has_token: boolean;
last_sync_at: string | null;
last_sync_error: string | null;
created_at: string;
updated_at: string;
};
export type InstanceInput = {
name: string;
baseUrl: string;
apiToken: string;
enabled?: boolean;
};
type InstanceRow = typeof instances.$inferSelect;
function toInstance(row: InstanceRow): Instance {
return {
id: row.id,
name: row.name,
base_url: row.baseUrl,
enabled: Boolean(row.enabled),
has_token: row.apiToken.length > 0,
last_sync_at: row.lastSyncAt ? toIso(row.lastSyncAt) : null,
last_sync_error: row.lastSyncError ?? null,
created_at: toIso(row.createdAt)!,
updated_at: toIso(row.updatedAt)!
};
}
export async function listInstances(): Promise<Instance[]> {
const rows = await db.query.instances.findMany({
orderBy: (table) => asc(table.name)
});
return rows.map(toInstance);
}
export async function getInstance(id: number): Promise<InstanceRow | null> {
return await db.query.instances.findFirst({
where: (table, operators) => operators.eq(table.id, id)
}) ?? null;
}
export async function createInstance(input: InstanceInput): Promise<Instance> {
const now = nowIso();
const [row] = await db
.insert(instances)
.values({
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
apiToken: input.apiToken.trim(),
enabled: input.enabled ?? true,
createdAt: now,
updatedAt: now
})
.returning();
if (!row) {
throw new Error("Failed to create instance");
}
return toInstance(row);
}
export async function updateInstance(
id: number,
input: { name?: string; baseUrl?: string; apiToken?: string; enabled?: boolean }
): Promise<Instance> {
const existing = await getInstance(id);
if (!existing) {
throw new Error("Instance not found");
}
const now = nowIso();
const [row] = await db
.update(instances)
.set({
name: input.name?.trim() ?? existing.name,
baseUrl: input.baseUrl?.trim() ?? existing.baseUrl,
apiToken: input.apiToken?.trim() ?? existing.apiToken,
enabled: input.enabled ?? existing.enabled,
updatedAt: now
})
.where(eq(instances.id, id))
.returning();
if (!row) {
throw new Error("Failed to update instance");
}
return toInstance(row);
}
export async function deleteInstance(id: number): Promise<void> {
await db.delete(instances).where(eq(instances.id, id));
}
export async function recordInstanceSyncResult(id: number, result: { ok: boolean; error?: string | null }) {
const now = nowIso();
await db
.update(instances)
.set({
lastSyncAt: now,
lastSyncError: result.ok ? null : result.error ?? "Unknown sync error",
updatedAt: now
})
.where(eq(instances.id, id));
}

View File

@@ -38,6 +38,11 @@ export type DnsSettings = {
timeout?: string; // DNS query timeout (e.g., "5s")
};
type InstanceMode = "standalone" | "master" | "slave";
const INSTANCE_MODE_KEY = "instance_mode";
const SYNCED_PREFIX = "synced:";
export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
const setting = await db.query.settings.findFirst({
where: (table, { eq }) => eq(table.key, key)
@@ -55,6 +60,32 @@ export async function getSetting<T>(key: string): Promise<SettingValue<T>> {
}
}
async function getInstanceModeForSettings(): Promise<InstanceMode> {
const stored = await getSetting<string>(INSTANCE_MODE_KEY);
if (stored === "master" || stored === "slave" || stored === "standalone") {
return stored;
}
return "standalone";
}
async function getSyncedSetting<T>(key: string): Promise<SettingValue<T>> {
return await getSetting<T>(`${SYNCED_PREFIX}${key}`);
}
export async function getEffectiveSetting<T>(key: string): Promise<SettingValue<T>> {
const mode = await getInstanceModeForSettings();
if (mode !== "slave") {
return await getSetting<T>(key);
}
const override = await getSetting<T>(key);
if (override !== null) {
return override;
}
return await getSyncedSetting<T>(key);
}
export async function setSetting<T>(key: string, value: T): Promise<void> {
const payload = JSON.stringify(value);
const now = nowIso();
@@ -75,8 +106,12 @@ export async function setSetting<T>(key: string, value: T): Promise<void> {
});
}
export async function clearSetting(key: string): Promise<void> {
await db.delete(settings).where(eq(settings.key, key));
}
export async function getCloudflareSettings(): Promise<CloudflareSettings | null> {
return await getSetting<CloudflareSettings>("cloudflare");
return await getEffectiveSetting<CloudflareSettings>("cloudflare");
}
export async function saveCloudflareSettings(settings: CloudflareSettings): Promise<void> {
@@ -84,7 +119,7 @@ export async function saveCloudflareSettings(settings: CloudflareSettings): Prom
}
export async function getGeneralSettings(): Promise<GeneralSettings | null> {
return await getSetting<GeneralSettings>("general");
return await getEffectiveSetting<GeneralSettings>("general");
}
export async function saveGeneralSettings(settings: GeneralSettings): Promise<void> {
@@ -92,7 +127,7 @@ export async function saveGeneralSettings(settings: GeneralSettings): Promise<vo
}
export async function getAuthentikSettings(): Promise<AuthentikSettings | null> {
return await getSetting<AuthentikSettings>("authentik");
return await getEffectiveSetting<AuthentikSettings>("authentik");
}
export async function saveAuthentikSettings(settings: AuthentikSettings): Promise<void> {
@@ -100,7 +135,7 @@ export async function saveAuthentikSettings(settings: AuthentikSettings): Promis
}
export async function getMetricsSettings(): Promise<MetricsSettings | null> {
return await getSetting<MetricsSettings>("metrics");
return await getEffectiveSetting<MetricsSettings>("metrics");
}
export async function saveMetricsSettings(settings: MetricsSettings): Promise<void> {
@@ -108,7 +143,7 @@ export async function saveMetricsSettings(settings: MetricsSettings): Promise<vo
}
export async function getLoggingSettings(): Promise<LoggingSettings | null> {
return await getSetting<LoggingSettings>("logging");
return await getEffectiveSetting<LoggingSettings>("logging");
}
export async function saveLoggingSettings(settings: LoggingSettings): Promise<void> {
@@ -116,7 +151,7 @@ export async function saveLoggingSettings(settings: LoggingSettings): Promise<vo
}
export async function getDnsSettings(): Promise<DnsSettings | null> {
return await getSetting<DnsSettings>("dns");
return await getEffectiveSetting<DnsSettings>("dns");
}
export async function saveDnsSettings(settings: DnsSettings): Promise<void> {