diff --git a/src/app/settings/components/GeneralSettingsPanel.tsx b/src/app/settings/components/GeneralSettingsPanel.tsx index 223e021..bd531c9 100644 --- a/src/app/settings/components/GeneralSettingsPanel.tsx +++ b/src/app/settings/components/GeneralSettingsPanel.tsx @@ -39,12 +39,35 @@ export default function GeneralSettingsPanel({ fontSize: 15, fontWeight: 700, color: "var(--text-primary)", - marginBottom: 20, + marginBottom: 4, }} > General Settings +
+
+
+ Panel Identity +
+
+ Name and workspace preferences +
+
+
-
- Panel URL - - updateGeneral("panelUrl", event.target.value) - } - /> -
-
-
Timezone
+
Theme
@@ -153,6 +166,7 @@ export default function GeneralSettingsPanel({
+ 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("NGINX"); + const [panelAutoSsl, setPanelAutoSsl] = useState( + settings.general.panelUrl.startsWith("https://") || !initialDomain, + ); + const [capabilities, setCapabilities] = + useState(null); + const [loadingCapabilities, setLoadingCapabilities] = useState(true); + const [provisioning, setProvisioning] = useState(false); + const [message, setMessage] = useState(null); + const [error, setError] = useState(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 ( +
+
+
+

+ + Panel Access +

+
+ Custom domain provisioning for the Doktainer panel +
+
+ + {loadingCapabilities + ? "Inspecting" + : (capabilities?.target.label ?? "Panel host")} + +
+ + {error ? ( +
+ + {error} +
+ ) : null} + + {message ? ( +
+ {message} +
+ ) : null} + + {capabilities?.target.diagnostic ? ( +
+ {capabilities.target.diagnostic} +
+ ) : null} + +
+
+ Domain + setPanelDomain(event.target.value)} + /> +
+
+ +
+
+ Reverse Proxy +
+ {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 ( + + ); + })} +
+ {selectedCapability?.reason ? ( +
+ {selectedCapability.reason} +
+ ) : null} +
+
+
+
+ + Auto SSL +
+
+ Certbot or proxy-managed TLS +
+
+ setPanelAutoSsl((current) => !current)} + /> +
+
+ {capabilities?.autoSsl.reason ? ( +
+ {capabilities.autoSsl.reason} +
+ ) : null} + +
+
+ + {panelRoutePreview} +
+ +
+
+ ); +} diff --git a/src/app/settings/components/settings-config.ts b/src/app/settings/components/settings-config.ts index a0960c2..f169294 100644 --- a/src/app/settings/components/settings-config.ts +++ b/src/app/settings/components/settings-config.ts @@ -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 = { general: "General", + "panel-access": "Panel Access", security: "Security", }; diff --git a/src/app/settings/components/settings-types.ts b/src/app/settings/components/settings-types.ts index 850f48e..b5e6ada 100644 --- a/src/app/settings/components/settings-types.ts +++ b/src/app/settings/components/settings-types.ts @@ -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"; diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index e8ad0c2..2f7cc11 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -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" ? ( + { + syncSettingsFromServer(nextSettings); + setSuccess(message || "Panel domain provisioned"); + }} + /> + ) : null} + {activeTab === "security" ? ( 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", diff --git a/src/server/routes/settings.ts b/src/server/routes/settings.ts index 9c3f4cc..5d18772 100644 --- a/src/server/routes/settings.ts +++ b/src/server/routes/settings.ts @@ -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 }, diff --git a/src/server/services/panel-access.service.ts b/src/server/services/panel-access.service.ts new file mode 100644 index 0000000..754e6a4 --- /dev/null +++ b/src/server/services/panel-access.service.ts @@ -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 { + 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(); + 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 { + 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 { + 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 | null) ?? + {}; + const providers = + typeof parsed.providers === "object" && parsed.providers !== null + ? (parsed.providers as Record) + : {}; + const fileProvider = + typeof providers.file === "object" && providers.file !== null + ? (providers.file as Record) + : {}; + + 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 { + 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}`, + }; +}