diff --git a/src/server/services/panel-access.service.ts b/src/server/services/panel-access.service.ts index 9e64161..f4ea3bc 100644 --- a/src/server/services/panel-access.service.ts +++ b/src/server/services/panel-access.service.ts @@ -9,7 +9,9 @@ const execFileAsync = promisify(execFile); const PANEL_PROBE_TIMEOUT_MS = 12000; const PANEL_HOST_ROOT = (process.env.PANEL_HOST_ROOT || "").replace(/\/+$/, ""); const PANEL_HOST_EXECUTION = process.env.PANEL_ACCESS_HOST_EXECUTION === "1"; -const PANEL_PROXY_NETWORK = (process.env.PANEL_PROXY_NETWORK || "doktainer-proxy").trim(); +const PANEL_PROXY_NETWORK = ( + process.env.PANEL_PROXY_NETWORK || "doktainer-proxy" +).trim(); const DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock"; export type PanelProxyType = "NGINX" | "CADDY" | "TRAEFIK"; @@ -91,7 +93,10 @@ type DockerProxyProbe = { error: string | null; }; -type DockerProxyStackProbe = Record, DockerProxyProbe>; +type DockerProxyStackProbe = Record< + Lowercase, + DockerProxyProbe +>; function isUnixLikeHost() { return process.platform !== "win32"; @@ -162,7 +167,19 @@ async function runLocalShell( try { const executable = PANEL_HOST_EXECUTION ? "nsenter" : "/bin/sh"; const args = PANEL_HOST_EXECUTION - ? ["--target", "1", "--mount", "--uts", "--ipc", "--net", "--pid", "--", "/bin/sh", "-lc", command] + ? [ + "--target", + "1", + "--mount", + "--uts", + "--ipc", + "--net", + "--pid", + "--", + "/bin/sh", + "-lc", + command, + ] : ["-lc", command]; const result = await execFileAsync(executable, args, { timeout: timeoutMs, @@ -223,9 +240,9 @@ function parsePanelHostStackProbe(output: string): PanelHostStackProbe { 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; }", + '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)"', @@ -310,10 +327,7 @@ function inspectProxy( const installed = probe.traefikInstalled || probe.traefikActive; const active = probe.traefikActive; - const available = - installed && - active && - probe.traefikConfigWritable; + const available = installed && active && probe.traefikConfigWritable; return { type, label: "Traefik", @@ -335,7 +349,8 @@ function inspectDockerBridgeProxy( type: PanelProxyType, probe: DockerProxyProbe, ): PanelProxyCapability { - const label = type === "NGINX" ? "Nginx" : type === "CADDY" ? "Caddy" : "Traefik"; + const label = + type === "NGINX" ? "Nginx" : type === "CADDY" ? "Caddy" : "Traefik"; const installed = probe.installed; const active = probe.active; const supportsProvisioning = installed && active && probe.configMounted; @@ -377,7 +392,8 @@ export async function getPanelAccessCapabilities(): Promise proxy.installed) || Boolean(dockerBridgeError); + dockerProxies.some((proxy) => proxy.installed) || + Boolean(dockerBridgeError); const proxies = useDockerBridge ? dockerProxies : localProxies; const defaultProxy = proxies.find((proxy) => proxy.available && proxy.supportsProvisioning) @@ -387,9 +403,12 @@ export async function getPanelAccessCapabilities(): Promise proxy.type === "CADDY" && proxy.available), + available: + probe.certbotInstalled || + proxies.some((proxy) => proxy.type === "CADDY" && proxy.available), reason: - probe.certbotInstalled || proxies.some((proxy) => proxy.type === "CADDY" && proxy.available) + 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.", }, @@ -397,10 +416,13 @@ export async function getPanelAccessCapabilities(): Promise pattern.test(value.toLowerCase())); } -function proxyConfigMounts(type: PanelProxyType, mounts: Array<{ Destination?: string }>) { - const paths = type === "NGINX" - ? ["/etc/nginx", "/etc/nginx/conf.d"] - : type === "CADDY" - ? ["/etc/caddy"] - : ["/etc/traefik", "/etc/traefik/dynamic"]; - return mounts.some((mount) => paths.some((path) => mount.Destination === path)); +function proxyConfigMounts( + type: PanelProxyType, + mounts: Array<{ Destination?: string }>, +) { + const paths = + type === "NGINX" + ? ["/etc/nginx", "/etc/nginx/conf.d"] + : type === "CADDY" + ? ["/etc/caddy"] + : ["/etc/traefik", "/etc/traefik/dynamic"]; + return mounts.some((mount) => + paths.some((path) => mount.Destination === path), + ); } async function probeDockerBridgeProxyStack(): Promise { @@ -536,7 +567,8 @@ async function probeDockerBridgeProxyStack(): Promise { try { const socketPath = resolveDockerSocketPath(); if (!socketPath || !existsSync(socketPath)) { - const requested = process.env.DOCKER_SOCKET_PATH || DEFAULT_DOCKER_SOCKET_PATH; + const requested = + process.env.DOCKER_SOCKET_PATH || DEFAULT_DOCKER_SOCKET_PATH; throw new Error(`Docker socket is not available at ${requested}.`); } @@ -566,7 +598,8 @@ async function probeDockerBridgeProxyStack(): Promise { installed: true, active: container.State === "running", configMounted: proxyConfigMounts(type, details.Mounts || []), - containerName: container.Names?.[0]?.replace(/^\//, "") || container.Id.slice(0, 12), + containerName: + container.Names?.[0]?.replace(/^\//, "") || container.Id.slice(0, 12), containerId: container.Id, error: null, }; @@ -574,7 +607,10 @@ async function probeDockerBridgeProxyStack(): Promise { return result; } catch (error) { - const message = error instanceof Error ? error.message : "Unable to inspect Docker proxy containers."; + const message = + error instanceof Error + ? error.message + : "Unable to inspect Docker proxy containers."; return { nginx: emptyDockerProxyProbe(message), caddy: emptyDockerProxyProbe(message), @@ -603,24 +639,41 @@ async function runDockerContainerShell( const stdout = new PassThrough(); const stderr = new PassThrough(); const output = { stdout: "", stderr: "" }; - stdout.on("data", (chunk: Buffer) => { output.stdout += chunk.toString(); }); - stderr.on("data", (chunk: Buffer) => { output.stderr += chunk.toString(); }); + stdout.on("data", (chunk: Buffer) => { + output.stdout += chunk.toString(); + }); + stderr.on("data", (chunk: Buffer) => { + output.stderr += chunk.toString(); + }); docker.modem.demuxStream(stream, stdout, stderr); await new Promise((resolve, reject) => { const timeout = setTimeout(() => { stream.destroy(new Error("Container command timed out.")); }, timeoutMs); - stream.once("end", () => { clearTimeout(timeout); resolve(); }); - stream.once("error", (error) => { clearTimeout(timeout); reject(error); }); + stream.once("end", () => { + clearTimeout(timeout); + resolve(); + }); + stream.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); }); const details = await execution.inspect(); - return { stdout: output.stdout, stderr: output.stderr, code: details.ExitCode ?? 1 }; + return { + stdout: output.stdout, + stderr: output.stderr, + code: details.ExitCode ?? 1, + }; } catch (error) { return { stdout: "", - stderr: error instanceof Error ? error.message : "Unable to execute command in proxy container.", + stderr: + error instanceof Error + ? error.message + : "Unable to execute command in proxy container.", code: 1, }; } @@ -636,26 +689,34 @@ async function provisionDockerProxy( domain: string, upstream: string, ) { - if (!probe.containerId) throw new PanelAccessError(`${type} container is unavailable.`); + if (!probe.containerId) + throw new PanelAccessError(`${type} container is unavailable.`); const safeName = `doktainer-panel-${sanitizeFileName(domain)}`; - const config = type === "NGINX" - ? buildNginxPanelConfig(domain, upstream) - : type === "CADDY" - ? buildCaddyPanelConfig(domain, upstream) - : buildTraefikPanelConfig(domain, upstream); - const configPath = type === "NGINX" - ? `/etc/nginx/conf.d/${safeName}.conf` - : type === "CADDY" - ? `/etc/caddy/${safeName}.caddy` - : `/etc/traefik/dynamic/${safeName}.yml`; + const config = + type === "NGINX" + ? buildNginxPanelConfig(domain, upstream) + : type === "CADDY" + ? buildCaddyPanelConfig(domain, upstream) + : buildTraefikPanelConfig(domain, upstream); + const configPath = + type === "NGINX" + ? `/etc/nginx/conf.d/${safeName}.conf` + : type === "CADDY" + ? `/etc/caddy/${safeName}.caddy` + : `/etc/traefik/dynamic/${safeName}.yml`; const write = `printf %s '${base64Content(config)}' | base64 -d > '${configPath}'`; - const command = type === "NGINX" - ? `mkdir -p /etc/nginx/conf.d && ${write} && nginx -t && (nginx -s reload || kill -HUP 1)` - : type === "CADDY" - ? `mkdir -p /etc/caddy && ${write} && touch /etc/caddy/Caddyfile && { grep -Fqx 'import /etc/caddy/${safeName}.caddy' /etc/caddy/Caddyfile || printf '\nimport /etc/caddy/${safeName}.caddy\n' >> /etc/caddy/Caddyfile; } && caddy validate --config /etc/caddy/Caddyfile && caddy reload --config /etc/caddy/Caddyfile` - : `test -d /etc/traefik/dynamic && grep -Eq '^[[:space:]]*file:' /etc/traefik/traefik.yml /etc/traefik/traefik.yaml 2>/dev/null && ${write}`; - const result = await runDockerContainerShell(probe.containerId, command, 30000); + const command = + type === "NGINX" + ? `mkdir -p /etc/nginx/conf.d && ${write} && nginx -t && (nginx -s reload || kill -HUP 1)` + : type === "CADDY" + ? `mkdir -p /etc/caddy && ${write} && touch /etc/caddy/Caddyfile && { if grep -Fq '# doktainer-bootstrap: true' /etc/caddy/Caddyfile || grep -Fq 'respond "Doktainer Caddy proxy is ready." 200' /etc/caddy/Caddyfile; then printf 'import /etc/caddy/*.caddy\n' > /etc/caddy/Caddyfile; elif ! grep -Fq 'import /etc/caddy/*.caddy' /etc/caddy/Caddyfile; then printf '\nimport /etc/caddy/*.caddy\n' >> /etc/caddy/Caddyfile; fi; } && caddy validate --config /etc/caddy/Caddyfile && caddy reload --config /etc/caddy/Caddyfile` + : `test -d /etc/traefik/dynamic && grep -Eq '^[[:space:]]*file:' /etc/traefik/traefik.yml /etc/traefik/traefik.yaml 2>/dev/null && ${write}`; + const result = await runDockerContainerShell( + probe.containerId, + command, + 30000, + ); if (result.code !== 0) { throw new PanelAccessError( `${type} container provisioning failed: ${(result.stderr || result.stdout).trim()}`, @@ -674,14 +735,23 @@ async function provisionNginx(domain: string, upstream: string) { 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(panelHostPath(enabledPath), { force: true }).catch(() => undefined); + await writeFileEnsuringDir( + configPath, + buildNginxPanelConfig(domain, upstream), + ); + await fs + .rm(panelHostPath(enabledPath), { force: true }) + .catch(() => undefined); await fs.symlink(configPath, panelHostPath(enabledPath)); const test = await runLocalShell("nginx -t", 20000); if (test.code !== 0) { - await fs.rm(panelHostPath(enabledPath), { force: true }).catch(() => undefined); - await fs.rm(panelHostPath(configPath), { force: true }).catch(() => undefined); + await fs + .rm(panelHostPath(enabledPath), { force: true }) + .catch(() => undefined); + await fs + .rm(panelHostPath(configPath), { force: true }) + .catch(() => undefined); throw new PanelAccessError( `Nginx validation failed: ${(test.stderr || test.stdout).trim()}`, ); @@ -703,9 +773,14 @@ async function provisionNginx(domain: string, upstream: string) { 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(panelHostPath(caddyFilePath), "utf8").catch(() => ""); + const caddyFile = await fs + .readFile(panelHostPath(caddyFilePath), "utf8") + .catch(() => ""); - if (caddyFile && !/import\s+\/etc\/caddy\/conf\.d\/\*\.caddy/.test(caddyFile)) { + if ( + caddyFile && + !/import\s+\/etc\/caddy\/conf\.d\/\*\.caddy/.test(caddyFile) + ) { await fs.writeFile( panelHostPath(caddyFilePath), `${caddyFile.trimEnd()}\n\nimport /etc/caddy/conf.d/*.caddy\n`, @@ -713,7 +788,10 @@ async function provisionCaddy(domain: string, upstream: string) { ); } - await writeFileEnsuringDir(configPath, buildCaddyPanelConfig(domain, upstream)); + await writeFileEnsuringDir( + configPath, + buildCaddyPanelConfig(domain, upstream), + ); const reload = await runLocalShell( "caddy reload --config /etc/caddy/Caddyfile || systemctl reload caddy || service caddy reload", @@ -731,7 +809,10 @@ async function provisionCaddy(domain: string, upstream: string) { 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)); + await writeFileEnsuringDir( + configPath, + buildTraefikPanelConfig(domain, upstream), + ); const reload = await runLocalShell( [ @@ -762,7 +843,9 @@ async function ensureTraefikFileProvider() { await Promise.all( staticCandidates.map(async (candidate) => ({ path: candidate, - content: await fs.readFile(panelHostPath(candidate), "utf8").catch(() => null), + content: await fs + .readFile(panelHostPath(candidate), "utf8") + .catch(() => null), })), ) ).find((candidate) => candidate.content !== null); @@ -814,7 +897,8 @@ export async function provisionPanelDomain(options: { const domain = normalizePanelDomain(options.domain); const capabilities = await getPanelAccessCapabilities(); const dockerProbe = await probeDockerBridgeProxyStack(); - const dockerProxy = dockerProbe[options.proxy.toLowerCase() as Lowercase]; + const dockerProxy = + dockerProbe[options.proxy.toLowerCase() as Lowercase]; const usingDockerBridge = Boolean(dockerProxy?.installed); const selectedProxy = capabilities.proxies.find( (proxy) => proxy.type === options.proxy, @@ -854,7 +938,8 @@ export async function provisionPanelDomain(options: { sslEnabled = true; } else if (options.autoSsl && options.proxy === "NGINX") { if (!capabilities.autoSsl.installed) { - sslNote = " Auto SSL was requested, but Certbot was not detected; the HTTP proxy config was provisioned without issuing a certificate."; + sslNote = + " Auto SSL was requested, but Certbot was not detected; the HTTP proxy config was provisioned without issuing a certificate."; } else { const certificate = await runLocalShell( `certbot --nginx --non-interactive --agree-tos --register-unsafely-without-email -d ${domain}`,