feat(settings): add domain provisioning for Custom Panel URL
This commit is contained in:
1 parent
b70c93af85
commit
3ca1aa1450
8 files changed
+1186
-23
No files matched your search
@@ -39,12 +39,35 @@ export default function GeneralSettingsPanel({
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
marginBottom: 20,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
General Settings
|
||||
</h2>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
Panel Identity
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 3,
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Name and workspace preferences
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
@@ -64,26 +87,6 @@ export default function GeneralSettingsPanel({
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>Panel URL</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
readOnly
|
||||
disabled
|
||||
value={settings.general.panelUrl}
|
||||
onChange={(event) =>
|
||||
updateGeneral("panelUrl", event.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<FieldLabel>Timezone</FieldLabel>
|
||||
<select
|
||||
@@ -101,6 +104,15 @@ export default function GeneralSettingsPanel({
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<FieldLabel>Session Timeout</FieldLabel>
|
||||
<select
|
||||
@@ -122,6 +134,7 @@ export default function GeneralSettingsPanel({
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Theme</FieldLabel>
|
||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||
@@ -153,6 +166,7 @@ export default function GeneralSettingsPanel({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SettingsFooterActions
|
||||
saving={saving}
|
||||
resetting={resetting}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Globe2,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
settingsApi,
|
||||
type PanelAccessCapabilities,
|
||||
type PanelAccessProxy,
|
||||
type SettingsRecord,
|
||||
} from "@/lib/api";
|
||||
import {
|
||||
FieldLabel,
|
||||
Toggle,
|
||||
} from "@/app/settings/components/SettingsPrimitives";
|
||||
import type { EditableSettings } from "@/app/settings/components/settings-types";
|
||||
|
||||
interface PanelAccessSettingsPanelProps {
|
||||
settings: EditableSettings;
|
||||
onProvisioned?: (settings: SettingsRecord, message?: string) => void;
|
||||
}
|
||||
|
||||
const proxyOptions = [
|
||||
{ value: "NGINX", label: "Nginx" },
|
||||
{ value: "CADDY", label: "Caddy" },
|
||||
{ value: "TRAEFIK", label: "Traefik" },
|
||||
] as const satisfies Array<{ value: PanelAccessProxy; label: string }>;
|
||||
|
||||
function resolveInitialDomain(panelUrl: string) {
|
||||
try {
|
||||
const parsed = new URL(panelUrl);
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
const isLocalOrIp =
|
||||
host === "localhost" ||
|
||||
host === "127.0.0.1" ||
|
||||
host === "0.0.0.0" ||
|
||||
host === "::1" ||
|
||||
/^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) ||
|
||||
host.endsWith(".local");
|
||||
|
||||
return isLocalOrIp ? "" : parsed.hostname;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export default function PanelAccessSettingsPanel({
|
||||
settings,
|
||||
onProvisioned,
|
||||
}: PanelAccessSettingsPanelProps) {
|
||||
const initialDomain = useMemo(
|
||||
() => resolveInitialDomain(settings.general.panelUrl),
|
||||
[settings.general.panelUrl],
|
||||
);
|
||||
const [panelDomain, setPanelDomain] = useState(initialDomain);
|
||||
const [panelProxy, setPanelProxy] = useState<PanelAccessProxy>("NGINX");
|
||||
const [panelAutoSsl, setPanelAutoSsl] = useState(
|
||||
settings.general.panelUrl.startsWith("https://") || !initialDomain,
|
||||
);
|
||||
const [capabilities, setCapabilities] =
|
||||
useState<PanelAccessCapabilities | null>(null);
|
||||
const [loadingCapabilities, setLoadingCapabilities] = useState(true);
|
||||
const [provisioning, setProvisioning] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedCapability = capabilities?.proxies.find(
|
||||
(proxy) => proxy.type === panelProxy,
|
||||
);
|
||||
const panelRoutePreview = panelDomain.trim()
|
||||
? `${panelDomain.trim()} -> ${panelProxy}`
|
||||
: "No custom panel domain configured";
|
||||
const canProvision = Boolean(
|
||||
panelDomain.trim() &&
|
||||
selectedCapability?.available &&
|
||||
selectedCapability.supportsProvisioning &&
|
||||
!provisioning,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadCapabilities = async () => {
|
||||
setLoadingCapabilities(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await settingsApi.getPanelAccessCapabilities();
|
||||
if (!mounted) return;
|
||||
|
||||
setCapabilities(response.data);
|
||||
if (response.data.defaultProxy) {
|
||||
setPanelProxy(response.data.defaultProxy);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mounted) return;
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to inspect panel host reverse proxy",
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setLoadingCapabilities(false);
|
||||
}
|
||||
};
|
||||
|
||||
void loadCapabilities();
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const provisionPanelDomain = async () => {
|
||||
setProvisioning(true);
|
||||
setError(null);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await settingsApi.provisionPanelDomain({
|
||||
domain: panelDomain.trim(),
|
||||
proxy: panelProxy,
|
||||
autoSsl: panelAutoSsl,
|
||||
});
|
||||
setMessage(response.message || response.data.provision.message);
|
||||
onProvisioned?.(response.data.settings, response.message);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Panel domain provisioning failed",
|
||||
);
|
||||
} finally {
|
||||
setProvisioning(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="card"
|
||||
style={{ padding: 24, display: "flex", flexDirection: "column", gap: 20 }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
fontSize: 15,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
<Globe2 size={16} color="var(--accent-green)" />
|
||||
Panel Access
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 5,
|
||||
fontSize: 12,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
Custom domain provisioning for the Doktainer panel
|
||||
</div>
|
||||
</div>
|
||||
<span className="ui-badge badge-info">
|
||||
{loadingCapabilities
|
||||
? "Inspecting"
|
||||
: (capabilities?.target.label ?? "Panel host")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "flex-start",
|
||||
gap: 8,
|
||||
padding: "10px 12px",
|
||||
border: "1px solid rgba(239,68,68,0.28)",
|
||||
borderRadius: 8,
|
||||
background: "rgba(239,68,68,0.1)",
|
||||
color: "#ef4444",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
<AlertTriangle size={14} style={{ flexShrink: 0, marginTop: 2 }} />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
border: "1px solid rgba(16,185,129,0.28)",
|
||||
borderRadius: 8,
|
||||
background: "rgba(16,185,129,0.1)",
|
||||
color: "#10b981",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{capabilities?.target.diagnostic ? (
|
||||
<div
|
||||
style={{
|
||||
padding: "10px 12px",
|
||||
border: "1px solid rgba(59,130,246,0.24)",
|
||||
borderRadius: 8,
|
||||
background: "rgba(59,130,246,0.07)",
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{capabilities.target.diagnostic}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<FieldLabel>Domain</FieldLabel>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="panel.example.com"
|
||||
value={panelDomain}
|
||||
onChange={(event) => setPanelDomain(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||
gap: 16,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<FieldLabel>Reverse Proxy</FieldLabel>
|
||||
<div
|
||||
className="ui-pill-switch"
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
background: "var(--bg-input)",
|
||||
}}
|
||||
>
|
||||
{proxyOptions.map((option) => {
|
||||
const active = panelProxy === option.value;
|
||||
const capability = capabilities?.proxies.find(
|
||||
(proxy) => proxy.type === option.value,
|
||||
);
|
||||
const disabled =
|
||||
loadingCapabilities ||
|
||||
!capability?.available ||
|
||||
!capability.supportsProvisioning;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!disabled) setPanelProxy(option.value);
|
||||
}}
|
||||
disabled={disabled}
|
||||
title={capability?.reason ?? undefined}
|
||||
style={{
|
||||
minHeight: 34,
|
||||
flex: "1 1 120px",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
background: active ? "#1d4ed8" : "transparent",
|
||||
color: disabled
|
||||
? "var(--text-muted)"
|
||||
: active
|
||||
? "white"
|
||||
: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
cursor: disabled ? "not-allowed" : "pointer",
|
||||
opacity: disabled ? 0.55 : 1,
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{selectedCapability?.reason ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 7,
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{selectedCapability.reason}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minHeight: 62,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "11px 12px",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
background: "var(--bg-input)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 7,
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
color: "var(--text-primary)",
|
||||
}}
|
||||
>
|
||||
<ShieldCheck size={14} color="var(--accent-green)" />
|
||||
Auto SSL
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 2,
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
}}
|
||||
>
|
||||
Certbot or proxy-managed TLS
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={panelAutoSsl}
|
||||
onChange={() => setPanelAutoSsl((current) => !current)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{capabilities?.autoSsl.reason ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: -8,
|
||||
fontSize: 11,
|
||||
color: "var(--text-muted)",
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{capabilities.autoSsl.reason}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: 12,
|
||||
flexWrap: "wrap",
|
||||
padding: "12px 14px",
|
||||
border: "1px solid rgba(59,130,246,0.24)",
|
||||
borderRadius: 8,
|
||||
background: "rgba(59,130,246,0.07)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
minWidth: 0,
|
||||
color: "var(--text-secondary)",
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
<RefreshCw size={14} color="var(--accent-blue)" />
|
||||
<span>{panelRoutePreview}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={!canProvision}
|
||||
onClick={() => void provisionPanelDomain()}
|
||||
style={{
|
||||
minWidth: 168,
|
||||
opacity: canProvision ? 1 : 0.55,
|
||||
cursor: canProvision ? "pointer" : "not-allowed",
|
||||
}}
|
||||
>
|
||||
{provisioning ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
Provisioning
|
||||
</>
|
||||
) : (
|
||||
"Provision Panel Domain"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Lock, Settings, type LucideIcon } from "lucide-react";
|
||||
import { Globe2, Lock, Settings, type LucideIcon } from "lucide-react";
|
||||
import type { SettingsTab } from "@/app/settings/components/settings-types";
|
||||
|
||||
export const TIMEZONE_OPTIONS = [
|
||||
@@ -25,10 +25,12 @@ export const SETTINGS_TABS: Array<{
|
||||
icon: LucideIcon;
|
||||
}> = [
|
||||
{ id: "general", label: "General", icon: Settings },
|
||||
{ id: "panel-access", label: "Panel Access", icon: Globe2 },
|
||||
{ id: "security", label: "Security", icon: Lock },
|
||||
];
|
||||
|
||||
export const ACTIVE_TAB_LABELS: Record<SettingsTab, string> = {
|
||||
general: "General",
|
||||
"panel-access": "Panel Access",
|
||||
security: "Security",
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { SettingsRecord } from "@/lib/api";
|
||||
|
||||
export type SettingsTab = "general" | "security";
|
||||
export type SettingsTab = "general" | "panel-access" | "security";
|
||||
|
||||
export type IntegrationProvider = "cloudflare" | "awsS3" | "github" | "gitlab";
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import GuardedPage from "@/components/GuardedPage";
|
||||
import { auth, settingsApi, type SettingsRecord } from "@/lib/api";
|
||||
import { getStoredTheme, storeUiPreferences } from "@/lib/preferences";
|
||||
import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel";
|
||||
import PanelAccessSettingsPanel from "@/app/settings/components/PanelAccessSettingsPanel";
|
||||
import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel";
|
||||
import {
|
||||
Banner,
|
||||
@@ -329,6 +330,16 @@ export default function SettingsPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === "panel-access" ? (
|
||||
<PanelAccessSettingsPanel
|
||||
settings={settings}
|
||||
onProvisioned={(nextSettings, message) => {
|
||||
syncSettingsFromServer(nextSettings);
|
||||
setSuccess(message || "Panel domain provisioned");
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{activeTab === "security" ? (
|
||||
<SecuritySettingsPanel
|
||||
settings={settings}
|
||||
|
||||
@@ -406,6 +406,12 @@ export interface PanelAccessCapabilities {
|
||||
};
|
||||
defaultProxy: PanelAccessProxy | null;
|
||||
upstream: string;
|
||||
target: {
|
||||
type: "local";
|
||||
label: string;
|
||||
serverId: null;
|
||||
diagnostic: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PanelAccessProvisionResult {
|
||||
@@ -1076,6 +1082,26 @@ export const apiKeys = {
|
||||
export const settingsApi = {
|
||||
get: () => get<{ success: boolean; data: SettingsRecord }>("/settings"),
|
||||
|
||||
getPanelAccessCapabilities: () =>
|
||||
get<{ success: boolean; data: PanelAccessCapabilities }>(
|
||||
"/settings/panel-access/capabilities",
|
||||
{ timeoutMs: 45000 },
|
||||
),
|
||||
|
||||
provisionPanelDomain: (body: {
|
||||
domain: string;
|
||||
proxy: PanelAccessProxy;
|
||||
autoSsl: boolean;
|
||||
}) =>
|
||||
post<{
|
||||
success: boolean;
|
||||
data: {
|
||||
settings: SettingsRecord;
|
||||
provision: PanelAccessProvisionResult;
|
||||
};
|
||||
message?: string;
|
||||
}>("/settings/panel-access/provision", body, { timeoutMs: 30000 }),
|
||||
|
||||
update: (body: UpdateSettingsBody) =>
|
||||
patch<{ success: boolean; data: SettingsRecord; message?: string }>(
|
||||
"/settings",
|
||||
|
||||
@@ -5,6 +5,10 @@ import prisma from "../lib/prisma";
|
||||
import { decrypt, encrypt } from "../lib/crypto";
|
||||
import { authenticate } from "../middleware/auth";
|
||||
import { auditLog } from "../services/audit.service";
|
||||
import {
|
||||
getPanelAccessCapabilities,
|
||||
provisionPanelDomain,
|
||||
} from "../services/panel-access.service";
|
||||
import {
|
||||
verifyAwsS3Connection,
|
||||
verifyCloudflareConnection,
|
||||
@@ -125,6 +129,12 @@ const NotificationProviderSchema = z.enum([
|
||||
"discord",
|
||||
"custom",
|
||||
]);
|
||||
const PanelAccessProxySchema = z.enum(["NGINX", "CADDY", "TRAEFIK"]);
|
||||
const PanelAccessProvisionSchema = z.object({
|
||||
domain: z.string().trim().min(3).max(253),
|
||||
proxy: PanelAccessProxySchema,
|
||||
autoSsl: z.boolean().default(true),
|
||||
});
|
||||
const INTEGRATION_PROVIDER_TO_DB = {
|
||||
cloudflare: "CLOUDFLARE",
|
||||
awsS3: "AWS_S3",
|
||||
@@ -633,6 +643,83 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
return reply.send({ success: true, data: serializeSettings(bundle) });
|
||||
});
|
||||
|
||||
app.get(
|
||||
"/panel-access/capabilities",
|
||||
{ preHandler: [authenticate] },
|
||||
async (_req, reply) => {
|
||||
const capabilities = await getPanelAccessCapabilities();
|
||||
return reply.send({ success: true, data: capabilities });
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/panel-access/provision",
|
||||
{ preHandler: [authenticate] },
|
||||
async (req, reply) => {
|
||||
const body = PanelAccessProvisionSchema.safeParse(req.body);
|
||||
if (!body.success) {
|
||||
return reply
|
||||
.status(400)
|
||||
.send({ success: false, error: body.error.flatten() });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await provisionPanelDomain(body.data);
|
||||
await prisma.userSettings.upsert({
|
||||
where: { userId: req.userId! },
|
||||
update: { panelUrl: result.panelUrl },
|
||||
create: {
|
||||
userId: req.userId!,
|
||||
panelUrl: result.panelUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const saved = await ensureModularSettings(
|
||||
req.userId!,
|
||||
req.organizationId,
|
||||
);
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "PANEL_DOMAIN_PROVISION",
|
||||
category: "SYSTEM",
|
||||
level: "SUCCESS",
|
||||
message: `Panel domain "${result.domain}" provisioned`,
|
||||
meta: result,
|
||||
});
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
data: {
|
||||
settings: serializeSettings(saved),
|
||||
provision: result,
|
||||
},
|
||||
message: result.message,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Panel domain provisioning failed";
|
||||
const statusCode =
|
||||
typeof (error as { statusCode?: unknown })?.statusCode === "number"
|
||||
? (error as { statusCode: number }).statusCode
|
||||
: 400;
|
||||
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
action: "PANEL_DOMAIN_PROVISION_FAILED",
|
||||
category: "SYSTEM",
|
||||
level: "ERROR",
|
||||
message,
|
||||
meta: body.data,
|
||||
}).catch(() => undefined);
|
||||
|
||||
return reply.status(statusCode).send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/",
|
||||
{ preHandler: [authenticate], bodyLimit: 512 * 1024 },
|
||||
|
||||
@@ -0,0 +1,592 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { promisify } from "node:util";
|
||||
import yaml from "js-yaml";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PANEL_PROBE_TIMEOUT_MS = 12000;
|
||||
|
||||
export type PanelProxyType = "NGINX" | "CADDY" | "TRAEFIK";
|
||||
|
||||
export type PanelProxyCapability = {
|
||||
type: PanelProxyType;
|
||||
label: string;
|
||||
installed: boolean;
|
||||
active: boolean;
|
||||
available: boolean;
|
||||
supportsProvisioning: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
|
||||
export type PanelAccessCapabilities = {
|
||||
proxies: PanelProxyCapability[];
|
||||
autoSsl: {
|
||||
installed: boolean;
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
};
|
||||
defaultProxy: PanelProxyType | null;
|
||||
upstream: string;
|
||||
target: {
|
||||
type: "local";
|
||||
label: string;
|
||||
serverId: null;
|
||||
diagnostic: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type PanelProvisionResult = {
|
||||
domain: string;
|
||||
panelUrl: string;
|
||||
proxy: PanelProxyType;
|
||||
configPath: string;
|
||||
enabledPath: string;
|
||||
reloadTarget: string;
|
||||
sslEnabled: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
class PanelAccessError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode = 400,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type ShellResult = {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
};
|
||||
|
||||
type PanelHostStackProbe = {
|
||||
nginxInstalled: boolean;
|
||||
nginxActive: boolean;
|
||||
nginxConfigWritable: boolean;
|
||||
caddyInstalled: boolean;
|
||||
caddyActive: boolean;
|
||||
caddyConfigWritable: boolean;
|
||||
traefikInstalled: boolean;
|
||||
traefikActive: boolean;
|
||||
traefikConfigWritable: boolean;
|
||||
traefikStaticConfigWritable: boolean;
|
||||
certbotInstalled: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
function isUnixLikeHost() {
|
||||
return process.platform !== "win32";
|
||||
}
|
||||
|
||||
function isLikelyContainerRuntime() {
|
||||
return Boolean(
|
||||
process.env.DOCKER_CONTAINER ||
|
||||
process.env.KUBERNETES_SERVICE_HOST ||
|
||||
process.env.HOSTNAME,
|
||||
);
|
||||
}
|
||||
|
||||
function resolvePanelUpstream() {
|
||||
return (
|
||||
process.env.PANEL_UPSTREAM_URL ||
|
||||
process.env.NEXT_PUBLIC_PANEL_INTERNAL_URL ||
|
||||
"http://127.0.0.1:3000"
|
||||
).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function emptyPanelHostStackProbe(error: string | null): PanelHostStackProbe {
|
||||
return {
|
||||
nginxInstalled: false,
|
||||
nginxActive: false,
|
||||
nginxConfigWritable: false,
|
||||
caddyInstalled: false,
|
||||
caddyActive: false,
|
||||
caddyConfigWritable: false,
|
||||
traefikInstalled: false,
|
||||
traefikActive: false,
|
||||
traefikConfigWritable: false,
|
||||
traefikStaticConfigWritable: false,
|
||||
certbotInstalled: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
async function runLocalShell(
|
||||
command: string,
|
||||
timeoutMs = PANEL_PROBE_TIMEOUT_MS,
|
||||
): Promise<ShellResult> {
|
||||
if (!isUnixLikeHost()) {
|
||||
return {
|
||||
code: 127,
|
||||
stdout: "",
|
||||
stderr: "Panel proxy provisioning requires a Unix-like panel host.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await execFileAsync("/bin/sh", ["-lc", command], {
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 1024 * 1024,
|
||||
});
|
||||
|
||||
return {
|
||||
code: 0,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
};
|
||||
} catch (error) {
|
||||
const typed = error as {
|
||||
code?: number | null;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
return {
|
||||
code: typeof typed.code === "number" ? typed.code : 1,
|
||||
stdout: typed.stdout ?? "",
|
||||
stderr: typed.stderr ?? typed.message ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseProbeBoolean(value: string | undefined) {
|
||||
return value === "1" || value === "true" || value === "yes";
|
||||
}
|
||||
|
||||
function parsePanelHostStackProbe(output: string): PanelHostStackProbe {
|
||||
const values = new Map<string, string>();
|
||||
for (const line of output.split(/\r?\n/)) {
|
||||
const [key, ...rest] = line.trim().split("=");
|
||||
if (key) values.set(key, rest.join("="));
|
||||
}
|
||||
|
||||
return {
|
||||
nginxInstalled: parseProbeBoolean(values.get("NGINX_INSTALLED")),
|
||||
nginxActive: parseProbeBoolean(values.get("NGINX_ACTIVE")),
|
||||
nginxConfigWritable: parseProbeBoolean(values.get("NGINX_CONFIG_WRITABLE")),
|
||||
caddyInstalled: parseProbeBoolean(values.get("CADDY_INSTALLED")),
|
||||
caddyActive: parseProbeBoolean(values.get("CADDY_ACTIVE")),
|
||||
caddyConfigWritable: parseProbeBoolean(values.get("CADDY_CONFIG_WRITABLE")),
|
||||
traefikInstalled: parseProbeBoolean(values.get("TRAEFIK_INSTALLED")),
|
||||
traefikActive: parseProbeBoolean(values.get("TRAEFIK_ACTIVE")),
|
||||
traefikConfigWritable: parseProbeBoolean(
|
||||
values.get("TRAEFIK_CONFIG_WRITABLE"),
|
||||
),
|
||||
traefikStaticConfigWritable: parseProbeBoolean(
|
||||
values.get("TRAEFIK_STATIC_CONFIG_WRITABLE"),
|
||||
),
|
||||
certbotInstalled: parseProbeBoolean(values.get("CERTBOT_INSTALLED")),
|
||||
error: values.get("ERROR") || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function probePanelHostStack(): Promise<PanelHostStackProbe> {
|
||||
const script = [
|
||||
"probe_cmd() { command -v \"$1\" >/dev/null 2>&1 && printf 1 || printf 0; }",
|
||||
"probe_service() { systemctl is-active --quiet \"$1\" 2>/dev/null || service \"$1\" status >/dev/null 2>&1 || pgrep -x \"$1\" >/dev/null 2>&1; }",
|
||||
"probe_writable_dir() { [ -d \"$1\" ] && [ -w \"$1\" ] && printf 1 || printf 0; }",
|
||||
'printf "NGINX_INSTALLED=%s\\n" "$(probe_cmd nginx)"',
|
||||
'if probe_service nginx; then printf "NGINX_ACTIVE=1\\n"; else printf "NGINX_ACTIVE=0\\n"; fi',
|
||||
'printf "NGINX_CONFIG_WRITABLE=%s\\n" "$(probe_writable_dir /etc/nginx/sites-available)"',
|
||||
'printf "CADDY_INSTALLED=%s\\n" "$(probe_cmd caddy)"',
|
||||
'if probe_service caddy; then printf "CADDY_ACTIVE=1\\n"; else printf "CADDY_ACTIVE=0\\n"; fi',
|
||||
'printf "CADDY_CONFIG_WRITABLE=%s\\n" "$(probe_writable_dir /etc/caddy)"',
|
||||
'printf "TRAEFIK_INSTALLED=%s\\n" "$(probe_cmd traefik)"',
|
||||
'if pgrep -x traefik >/dev/null 2>&1 || docker ps --format "{{.Names}} {{.Image}}" 2>/dev/null | grep -Eiq "traefik"; then printf "TRAEFIK_ACTIVE=1\\n"; else printf "TRAEFIK_ACTIVE=0\\n"; fi',
|
||||
'if [ -d /etc/traefik/dynamic ] && [ -w /etc/traefik/dynamic ]; then printf "TRAEFIK_CONFIG_WRITABLE=1\\n"; elif [ -w /etc/traefik/traefik.yml ] || [ -w /etc/traefik/traefik.yaml ]; then printf "TRAEFIK_CONFIG_WRITABLE=1\\n"; else printf "TRAEFIK_CONFIG_WRITABLE=0\\n"; fi',
|
||||
'if [ -w /etc/traefik/traefik.yml ] || [ -w /etc/traefik/traefik.yaml ] || [ -w /etc/traefik/traefik.toml ]; then printf "TRAEFIK_STATIC_CONFIG_WRITABLE=1\\n"; else printf "TRAEFIK_STATIC_CONFIG_WRITABLE=0\\n"; fi',
|
||||
'printf "CERTBOT_INSTALLED=%s\\n" "$(probe_cmd certbot)"',
|
||||
].join("\n");
|
||||
|
||||
const result = await runLocalShell(script, PANEL_PROBE_TIMEOUT_MS);
|
||||
if (result.code !== 0) {
|
||||
return emptyPanelHostStackProbe(
|
||||
(result.stderr || result.stdout).trim() ||
|
||||
"Unable to inspect the panel host proxy stack.",
|
||||
);
|
||||
}
|
||||
|
||||
return parsePanelHostStackProbe(result.stdout);
|
||||
}
|
||||
|
||||
function localRuntimeDiagnostic(probe: PanelHostStackProbe) {
|
||||
if (probe.error) return probe.error;
|
||||
if (!isLikelyContainerRuntime()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "Doktainer is running inside a container. Proxy detection only works for proxies and config paths visible from the Doktainer container. Mount the panel host proxy config paths into the app container, or run Doktainer with host-level access, before using Panel Access provisioning.";
|
||||
}
|
||||
|
||||
function inspectProxy(
|
||||
type: PanelProxyType,
|
||||
probe: PanelHostStackProbe,
|
||||
): PanelProxyCapability {
|
||||
if (type === "NGINX") {
|
||||
const installed = probe.nginxInstalled;
|
||||
const active = probe.nginxActive;
|
||||
const available = installed && active && probe.nginxConfigWritable;
|
||||
return {
|
||||
type,
|
||||
label: "Nginx",
|
||||
installed,
|
||||
active,
|
||||
available,
|
||||
supportsProvisioning: true,
|
||||
reason: !installed
|
||||
? "Nginx is not visible from the Doktainer runtime."
|
||||
: !active
|
||||
? "Nginx is visible but not active."
|
||||
: !probe.nginxConfigWritable
|
||||
? "Nginx config path is not writable from Doktainer."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (type === "CADDY") {
|
||||
const installed = probe.caddyInstalled;
|
||||
const active = probe.caddyActive;
|
||||
const available = installed && active && probe.caddyConfigWritable;
|
||||
return {
|
||||
type,
|
||||
label: "Caddy",
|
||||
installed,
|
||||
active,
|
||||
available,
|
||||
supportsProvisioning: true,
|
||||
reason: !installed
|
||||
? "Caddy is not visible from the Doktainer runtime."
|
||||
: !active
|
||||
? "Caddy is visible but not active."
|
||||
: !probe.caddyConfigWritable
|
||||
? "Caddy config path is not writable from Doktainer."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const installed = probe.traefikInstalled || probe.traefikActive;
|
||||
const active = probe.traefikActive;
|
||||
const available =
|
||||
installed &&
|
||||
active &&
|
||||
probe.traefikConfigWritable;
|
||||
return {
|
||||
type,
|
||||
label: "Traefik",
|
||||
installed,
|
||||
active,
|
||||
available,
|
||||
supportsProvisioning: true,
|
||||
reason: !installed
|
||||
? "Traefik is not visible from the Doktainer runtime."
|
||||
: !active
|
||||
? "Traefik is visible but not active."
|
||||
: !probe.traefikConfigWritable
|
||||
? "Traefik dynamic config path is not writable from Doktainer."
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPanelAccessCapabilities(): Promise<PanelAccessCapabilities> {
|
||||
const probe = await probePanelHostStack();
|
||||
const proxies = [
|
||||
inspectProxy("NGINX", probe),
|
||||
inspectProxy("CADDY", probe),
|
||||
inspectProxy("TRAEFIK", probe),
|
||||
];
|
||||
const defaultProxy =
|
||||
proxies.find((proxy) => proxy.available && proxy.supportsProvisioning)
|
||||
?.type ?? null;
|
||||
|
||||
return {
|
||||
proxies,
|
||||
autoSsl: {
|
||||
installed: probe.certbotInstalled,
|
||||
available: probe.certbotInstalled || proxies.some((proxy) => proxy.type === "CADDY" && proxy.available),
|
||||
reason:
|
||||
probe.certbotInstalled || proxies.some((proxy) => proxy.type === "CADDY" && proxy.available)
|
||||
? null
|
||||
: "Certbot was not detected. Caddy can still manage TLS automatically when Caddy is selected.",
|
||||
},
|
||||
defaultProxy,
|
||||
upstream: resolvePanelUpstream(),
|
||||
target: {
|
||||
type: "local",
|
||||
label: "Panel host",
|
||||
serverId: null,
|
||||
diagnostic: localRuntimeDiagnostic(probe),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePanelDomain(value: string) {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/.*$/, "")
|
||||
.replace(/:\d+$/, "")
|
||||
.replace(/[.;]+$/g, "");
|
||||
|
||||
if (
|
||||
!/^(?=.{1,253}$)(?!-)(?:[a-z0-9-]{1,63}\.)+[a-z]{2,63}$/.test(
|
||||
normalized,
|
||||
)
|
||||
) {
|
||||
throw new PanelAccessError("Enter a valid public domain name.");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sanitizeFileName(domain: string) {
|
||||
return domain.replace(/[^a-z0-9.-]/g, "-");
|
||||
}
|
||||
|
||||
function buildNginxPanelConfig(domain: string, upstream: string) {
|
||||
return [
|
||||
"# doktainer-managed: true",
|
||||
"# doktainer-panel-domain: true",
|
||||
"server {",
|
||||
" listen 80;",
|
||||
` server_name ${domain};`,
|
||||
"",
|
||||
" location / {",
|
||||
` proxy_pass ${upstream};`,
|
||||
" proxy_http_version 1.1;",
|
||||
" proxy_set_header Host $host;",
|
||||
" proxy_set_header X-Real-IP $remote_addr;",
|
||||
" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;",
|
||||
" proxy_set_header X-Forwarded-Proto $scheme;",
|
||||
" proxy_set_header Upgrade $http_upgrade;",
|
||||
' proxy_set_header Connection "upgrade";',
|
||||
" }",
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildCaddyPanelConfig(domain: string, upstream: string) {
|
||||
return [
|
||||
"# doktainer-managed: true",
|
||||
`${domain} {`,
|
||||
` reverse_proxy ${upstream.replace(/^https?:\/\//, "")}`,
|
||||
"}",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function buildTraefikPanelConfig(domain: string, upstream: string) {
|
||||
const key = sanitizeFileName(domain).replace(/\./g, "-");
|
||||
return yaml.dump(
|
||||
{
|
||||
http: {
|
||||
routers: {
|
||||
[`doktainer-panel-${key}`]: {
|
||||
rule: `Host(\`${domain}\`)`,
|
||||
service: `doktainer-panel-${key}`,
|
||||
entryPoints: ["web"],
|
||||
},
|
||||
},
|
||||
services: {
|
||||
[`doktainer-panel-${key}`]: {
|
||||
loadBalancer: {
|
||||
servers: [{ url: upstream }],
|
||||
passHostHeader: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ lineWidth: 120 },
|
||||
);
|
||||
}
|
||||
|
||||
async function writeFileEnsuringDir(filePath: string, content: string) {
|
||||
await fs.mkdir(filePath.replace(/\/[^/]+$/, "") || "/", {
|
||||
recursive: true,
|
||||
});
|
||||
await fs.writeFile(filePath, content, "utf8");
|
||||
}
|
||||
|
||||
async function provisionNginx(domain: string, upstream: string) {
|
||||
const safeName = `doktainer-panel-${sanitizeFileName(domain)}`;
|
||||
const configPath = `/etc/nginx/sites-available/${safeName}.conf`;
|
||||
const enabledPath = `/etc/nginx/sites-enabled/${safeName}.conf`;
|
||||
|
||||
await writeFileEnsuringDir(configPath, buildNginxPanelConfig(domain, upstream));
|
||||
await fs.rm(enabledPath, { force: true }).catch(() => undefined);
|
||||
await fs.symlink(configPath, enabledPath);
|
||||
|
||||
const test = await runLocalShell("nginx -t", 20000);
|
||||
if (test.code !== 0) {
|
||||
await fs.rm(enabledPath, { force: true }).catch(() => undefined);
|
||||
await fs.rm(configPath, { force: true }).catch(() => undefined);
|
||||
throw new PanelAccessError(
|
||||
`Nginx validation failed: ${(test.stderr || test.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const reload = await runLocalShell(
|
||||
"systemctl reload nginx || nginx -s reload || service nginx reload",
|
||||
20000,
|
||||
);
|
||||
if (reload.code !== 0) {
|
||||
throw new PanelAccessError(
|
||||
`Nginx config is valid but reload failed: ${(reload.stderr || reload.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { configPath, enabledPath, reloadTarget: "nginx" };
|
||||
}
|
||||
|
||||
async function provisionCaddy(domain: string, upstream: string) {
|
||||
const configPath = `/etc/caddy/conf.d/doktainer-panel-${sanitizeFileName(domain)}.caddy`;
|
||||
const caddyFilePath = "/etc/caddy/Caddyfile";
|
||||
const caddyFile = await fs.readFile(caddyFilePath, "utf8").catch(() => "");
|
||||
|
||||
if (caddyFile && !/import\s+\/etc\/caddy\/conf\.d\/\*\.caddy/.test(caddyFile)) {
|
||||
await fs.writeFile(
|
||||
caddyFilePath,
|
||||
`${caddyFile.trimEnd()}\n\nimport /etc/caddy/conf.d/*.caddy\n`,
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
await writeFileEnsuringDir(configPath, buildCaddyPanelConfig(domain, upstream));
|
||||
|
||||
const reload = await runLocalShell(
|
||||
"caddy reload --config /etc/caddy/Caddyfile || systemctl reload caddy || service caddy reload",
|
||||
20000,
|
||||
);
|
||||
if (reload.code !== 0) {
|
||||
throw new PanelAccessError(
|
||||
`Caddy config was written but reload failed: ${(reload.stderr || reload.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { configPath, enabledPath: configPath, reloadTarget: "caddy" };
|
||||
}
|
||||
|
||||
async function provisionTraefik(domain: string, upstream: string) {
|
||||
const dynamicDirectory = await ensureTraefikFileProvider();
|
||||
const configPath = `${dynamicDirectory}/doktainer-panel-${sanitizeFileName(domain)}.yml`;
|
||||
await writeFileEnsuringDir(configPath, buildTraefikPanelConfig(domain, upstream));
|
||||
|
||||
const reload = await runLocalShell(
|
||||
[
|
||||
"if command -v systemctl >/dev/null 2>&1 && systemctl status traefik >/dev/null 2>&1; then systemctl restart traefik; exit 0; fi",
|
||||
"if command -v docker >/dev/null 2>&1; then TRAEFIK_ID=$(docker ps --format '{{.ID}}|{{.Names}}|{{.Image}}' | awk -F'|' 'BEGIN{IGNORECASE=1} /traefik/ {print $1; exit}'); if [ -n \"$TRAEFIK_ID\" ]; then docker restart \"$TRAEFIK_ID\" >/dev/null; exit 0; fi; fi",
|
||||
"exit 0",
|
||||
].join("\n"),
|
||||
20000,
|
||||
);
|
||||
if (reload.code !== 0) {
|
||||
throw new PanelAccessError(
|
||||
`Traefik config was written but reload failed: ${(reload.stderr || reload.stdout).trim()}`,
|
||||
);
|
||||
}
|
||||
|
||||
return { configPath, enabledPath: configPath, reloadTarget: "traefik" };
|
||||
}
|
||||
|
||||
async function ensureTraefikFileProvider() {
|
||||
const dynamicDirectory = "/etc/traefik/dynamic";
|
||||
await fs.mkdir(dynamicDirectory, { recursive: true });
|
||||
|
||||
const staticCandidates = [
|
||||
"/etc/traefik/traefik.yml",
|
||||
"/etc/traefik/traefik.yaml",
|
||||
];
|
||||
const staticConfigPath = (
|
||||
await Promise.all(
|
||||
staticCandidates.map(async (candidate) => ({
|
||||
path: candidate,
|
||||
content: await fs.readFile(candidate, "utf8").catch(() => null),
|
||||
})),
|
||||
)
|
||||
).find((candidate) => candidate.content !== null);
|
||||
|
||||
if (!staticConfigPath?.content) {
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
const parsed =
|
||||
(yaml.load(staticConfigPath.content) as Record<string, unknown> | null) ??
|
||||
{};
|
||||
const providers =
|
||||
typeof parsed.providers === "object" && parsed.providers !== null
|
||||
? (parsed.providers as Record<string, unknown>)
|
||||
: {};
|
||||
const fileProvider =
|
||||
typeof providers.file === "object" && providers.file !== null
|
||||
? (providers.file as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
if (
|
||||
`${fileProvider.directory ?? ""}`.trim() !== dynamicDirectory ||
|
||||
fileProvider.watch !== true
|
||||
) {
|
||||
parsed.providers = {
|
||||
...providers,
|
||||
file: {
|
||||
...fileProvider,
|
||||
directory: dynamicDirectory,
|
||||
watch: true,
|
||||
},
|
||||
};
|
||||
|
||||
await fs.writeFile(
|
||||
staticConfigPath.path,
|
||||
yaml.dump(parsed, { lineWidth: 120 }),
|
||||
"utf8",
|
||||
);
|
||||
}
|
||||
|
||||
return dynamicDirectory;
|
||||
}
|
||||
|
||||
export async function provisionPanelDomain(options: {
|
||||
domain: string;
|
||||
proxy: PanelProxyType;
|
||||
autoSsl: boolean;
|
||||
}): Promise<PanelProvisionResult> {
|
||||
const domain = normalizePanelDomain(options.domain);
|
||||
const capabilities = await getPanelAccessCapabilities();
|
||||
const selectedProxy = capabilities.proxies.find(
|
||||
(proxy) => proxy.type === options.proxy,
|
||||
);
|
||||
|
||||
if (!selectedProxy?.available) {
|
||||
throw new PanelAccessError(
|
||||
selectedProxy?.reason || `${options.proxy} is not available.`,
|
||||
);
|
||||
}
|
||||
|
||||
const upstream = resolvePanelUpstream();
|
||||
const result =
|
||||
options.proxy === "NGINX"
|
||||
? await provisionNginx(domain, upstream)
|
||||
: options.proxy === "CADDY"
|
||||
? await provisionCaddy(domain, upstream)
|
||||
: await provisionTraefik(domain, upstream);
|
||||
|
||||
const sslNote =
|
||||
options.autoSsl && !capabilities.autoSsl.available
|
||||
? " Auto SSL was requested, but Certbot was not detected; proxy config was provisioned without issuing a certificate."
|
||||
: "";
|
||||
|
||||
return {
|
||||
domain,
|
||||
panelUrl: `${options.autoSsl ? "https" : "http"}://${domain}`,
|
||||
proxy: options.proxy,
|
||||
configPath: result.configPath,
|
||||
enabledPath: result.enabledPath,
|
||||
reloadTarget: result.reloadTarget,
|
||||
sslEnabled: options.autoSsl && capabilities.autoSsl.available,
|
||||
message: `Panel domain ${domain} was provisioned through ${selectedProxy.label}.${sslNote}`,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user