feat(settings): add domain provisioning for Custom Panel URL

This commit is contained in:
KodekaTeam committed 2026-06-11 00:13:55 +07:00
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";
+11
View File
@@ -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}
+26
View File
@@ -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",
+87
View File
@@ -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 },
+592
View File
@@ -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}`,
};
}