Implement slave-master architecture
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
92
app/api/instances/sync/route.ts
Normal file
92
app/api/instances/sync/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
13
drizzle/0003_instances.sql
Normal file
13
drizzle/0003_instances.sql
Normal 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`);
|
||||
1203
drizzle/meta/0003_snapshot.json
Normal file
1203
drizzle/meta/0003_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,13 @@
|
||||
"when": 1766880443160,
|
||||
"tag": "0002_perfect_hedge_knight",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1769262874211,
|
||||
"tag": "0003_instances",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
7
proxy.ts
7
proxy.ts
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
407
src/lib/instance-sync.ts
Normal 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
117
src/lib/models/instances.ts
Normal 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));
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user