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,
|
fontSize: 15,
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
color: "var(--text-primary)",
|
color: "var(--text-primary)",
|
||||||
marginBottom: 20,
|
marginBottom: 4,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
General Settings
|
General Settings
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
|
<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
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: "grid",
|
display: "grid",
|
||||||
@@ -64,26 +87,6 @@ export default function GeneralSettingsPanel({
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<FieldLabel>Timezone</FieldLabel>
|
<FieldLabel>Timezone</FieldLabel>
|
||||||
<select
|
<select
|
||||||
@@ -101,6 +104,15 @@ export default function GeneralSettingsPanel({
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: "grid",
|
||||||
|
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
|
||||||
|
gap: 16,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Session Timeout</FieldLabel>
|
<FieldLabel>Session Timeout</FieldLabel>
|
||||||
<select
|
<select
|
||||||
@@ -122,6 +134,7 @@ export default function GeneralSettingsPanel({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<FieldLabel>Theme</FieldLabel>
|
<FieldLabel>Theme</FieldLabel>
|
||||||
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
|
||||||
@@ -153,6 +166,7 @@ export default function GeneralSettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SettingsFooterActions
|
<SettingsFooterActions
|
||||||
saving={saving}
|
saving={saving}
|
||||||
resetting={resetting}
|
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";
|
import type { SettingsTab } from "@/app/settings/components/settings-types";
|
||||||
|
|
||||||
export const TIMEZONE_OPTIONS = [
|
export const TIMEZONE_OPTIONS = [
|
||||||
@@ -25,10 +25,12 @@ export const SETTINGS_TABS: Array<{
|
|||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
}> = [
|
}> = [
|
||||||
{ id: "general", label: "General", icon: Settings },
|
{ id: "general", label: "General", icon: Settings },
|
||||||
|
{ id: "panel-access", label: "Panel Access", icon: Globe2 },
|
||||||
{ id: "security", label: "Security", icon: Lock },
|
{ id: "security", label: "Security", icon: Lock },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const ACTIVE_TAB_LABELS: Record<SettingsTab, string> = {
|
export const ACTIVE_TAB_LABELS: Record<SettingsTab, string> = {
|
||||||
general: "General",
|
general: "General",
|
||||||
|
"panel-access": "Panel Access",
|
||||||
security: "Security",
|
security: "Security",
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Dispatch, SetStateAction } from "react";
|
import type { Dispatch, SetStateAction } from "react";
|
||||||
import type { SettingsRecord } from "@/lib/api";
|
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";
|
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 { auth, settingsApi, type SettingsRecord } from "@/lib/api";
|
||||||
import { getStoredTheme, storeUiPreferences } from "@/lib/preferences";
|
import { getStoredTheme, storeUiPreferences } from "@/lib/preferences";
|
||||||
import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel";
|
import GeneralSettingsPanel from "@/app/settings/components/GeneralSettingsPanel";
|
||||||
|
import PanelAccessSettingsPanel from "@/app/settings/components/PanelAccessSettingsPanel";
|
||||||
import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel";
|
import SecuritySettingsPanel from "@/app/settings/components/SecuritySettingsPanel";
|
||||||
import {
|
import {
|
||||||
Banner,
|
Banner,
|
||||||
@@ -329,6 +330,16 @@ export default function SettingsPage() {
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{activeTab === "panel-access" ? (
|
||||||
|
<PanelAccessSettingsPanel
|
||||||
|
settings={settings}
|
||||||
|
onProvisioned={(nextSettings, message) => {
|
||||||
|
syncSettingsFromServer(nextSettings);
|
||||||
|
setSuccess(message || "Panel domain provisioned");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{activeTab === "security" ? (
|
{activeTab === "security" ? (
|
||||||
<SecuritySettingsPanel
|
<SecuritySettingsPanel
|
||||||
settings={settings}
|
settings={settings}
|
||||||
|
|||||||
@@ -406,6 +406,12 @@ export interface PanelAccessCapabilities {
|
|||||||
};
|
};
|
||||||
defaultProxy: PanelAccessProxy | null;
|
defaultProxy: PanelAccessProxy | null;
|
||||||
upstream: string;
|
upstream: string;
|
||||||
|
target: {
|
||||||
|
type: "local";
|
||||||
|
label: string;
|
||||||
|
serverId: null;
|
||||||
|
diagnostic: string | null;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PanelAccessProvisionResult {
|
export interface PanelAccessProvisionResult {
|
||||||
@@ -1076,6 +1082,26 @@ export const apiKeys = {
|
|||||||
export const settingsApi = {
|
export const settingsApi = {
|
||||||
get: () => get<{ success: boolean; data: SettingsRecord }>("/settings"),
|
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) =>
|
update: (body: UpdateSettingsBody) =>
|
||||||
patch<{ success: boolean; data: SettingsRecord; message?: string }>(
|
patch<{ success: boolean; data: SettingsRecord; message?: string }>(
|
||||||
"/settings",
|
"/settings",
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import prisma from "../lib/prisma";
|
|||||||
import { decrypt, encrypt } from "../lib/crypto";
|
import { decrypt, encrypt } from "../lib/crypto";
|
||||||
import { authenticate } from "../middleware/auth";
|
import { authenticate } from "../middleware/auth";
|
||||||
import { auditLog } from "../services/audit.service";
|
import { auditLog } from "../services/audit.service";
|
||||||
|
import {
|
||||||
|
getPanelAccessCapabilities,
|
||||||
|
provisionPanelDomain,
|
||||||
|
} from "../services/panel-access.service";
|
||||||
import {
|
import {
|
||||||
verifyAwsS3Connection,
|
verifyAwsS3Connection,
|
||||||
verifyCloudflareConnection,
|
verifyCloudflareConnection,
|
||||||
@@ -125,6 +129,12 @@ const NotificationProviderSchema = z.enum([
|
|||||||
"discord",
|
"discord",
|
||||||
"custom",
|
"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 = {
|
const INTEGRATION_PROVIDER_TO_DB = {
|
||||||
cloudflare: "CLOUDFLARE",
|
cloudflare: "CLOUDFLARE",
|
||||||
awsS3: "AWS_S3",
|
awsS3: "AWS_S3",
|
||||||
@@ -633,6 +643,83 @@ export async function settingsRoutes(app: FastifyInstance) {
|
|||||||
return reply.send({ success: true, data: serializeSettings(bundle) });
|
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(
|
app.patch(
|
||||||
"/",
|
"/",
|
||||||
{ preHandler: [authenticate], bodyLimit: 512 * 1024 },
|
{ 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