From ce93e92f6e166038b29b0679f2834c79b4ad8dba Mon Sep 17 00:00:00 2001 From: jarek Date: Thu, 13 Aug 2026 10:20:29 +0200 Subject: [PATCH] support caddy-docker-proxy labels --- src/lib/server/compose-docker-args.ts | 37 +++++++ src/lib/server/db/drizzle.ts | 2 +- src/lib/utils/caddy-urls.ts | 103 ++++++++++++++++++ src/routes/containers/+page.svelte | 19 +++- src/routes/settings/general/GeneralTab.svelte | 4 +- src/routes/stacks/+page.svelte | 16 +++ 6 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 src/lib/server/compose-docker-args.ts create mode 100644 src/lib/utils/caddy-urls.ts diff --git a/src/lib/server/compose-docker-args.ts b/src/lib/server/compose-docker-args.ts new file mode 100644 index 0000000..4e704a9 --- /dev/null +++ b/src/lib/server/compose-docker-args.ts @@ -0,0 +1,37 @@ +/** + * Pure helpers for how `docker compose` is told which daemon to talk to. + * + * The daemon MUST be passed via the `docker -H` CLI flag, NOT via DOCKER_HOST in the + * spawn env: shell env leaks into services that pass through (`- DOCKER_HOST`) or + * interpolate (`${DOCKER_HOST}`) that variable - e.g. a socket-proxy sidecar - + * overriding the value the stack set for itself (#1393). `-H` connects compose to the + * right daemon without polluting the compose interpolation env. Kept dependency-free + * so it unit-tests without spinning up the stack machinery. + */ + +/** + * Resolves the daemon endpoint for a compose invocation: the per-environment + * dockerHost if given, else Dockhand's own DOCKER_HOST (socket-proxy setups), else + * undefined (plain local socket - compose uses the default). `ownDockerHost` is + * `process.env.DOCKER_HOST` (passed in so the resolver stays pure/testable). + */ +export function resolveComposeDockerHost( + dockerHost: string | undefined | null, + ownDockerHost: string | undefined | null +): string | undefined { + return dockerHost || ownDockerHost || undefined; +} + +/** + * Builds the argv for `docker compose`, putting the daemon on the GLOBAL `-H` flag + * (before `compose`) so it never enters the shell env. Returns the base args; callers + * append the compose subcommand + flags. + */ +export function buildComposeBaseArgs( + stackName: string, + composeDockerHost: string | undefined +): string[] { + return composeDockerHost + ? ['docker', '-H', composeDockerHost, 'compose', '-p', stackName] + : ['docker', 'compose', '-p', stackName]; +} diff --git a/src/lib/server/db/drizzle.ts b/src/lib/server/db/drizzle.ts index 53f52ad..cf03892 100644 --- a/src/lib/server/db/drizzle.ts +++ b/src/lib/server/db/drizzle.ts @@ -760,7 +760,7 @@ async function seedDatabase(): Promise { // Create system roles if not exist const adminPermissions = JSON.stringify({ containers: ['view', 'create', 'start', 'stop', 'restart', 'remove', 'exec', 'logs', 'inspect'], - images: ['view', 'pull', 'push', 'remove', 'build', 'inspect'], + images: ['view', 'pull', 'load', 'push', 'remove', 'build', 'inspect'], volumes: ['view', 'create', 'remove', 'inspect'], networks: ['view', 'create', 'remove', 'inspect', 'connect', 'disconnect'], stacks: ['view', 'create', 'start', 'stop', 'remove', 'edit'], diff --git a/src/lib/utils/caddy-urls.ts b/src/lib/utils/caddy-urls.ts new file mode 100644 index 0000000..cb70573 --- /dev/null +++ b/src/lib/utils/caddy-urls.ts @@ -0,0 +1,103 @@ +/** + * caddy-docker-proxy label -> public URL extraction (#1390). + * + * caddy-docker-proxy (https://github.com/lucaslorentz/caddy-docker-proxy) turns + * container labels into a Caddyfile. The site address - the clickable URL - is the + * VALUE of the bare `caddy` key (or `caddy_` for isolated groups): + * + * caddy: whoami.example.com -> https://whoami.example.com + * caddy: example.com, example.org -> two URLs (comma-separated addresses) + * caddy_0: site-a.com -> grouped/isolated addresses + * caddy_1: site-b.com + * caddy: example.com/api/* -> https://example.com/api (path kept) + * + * Only the site-address key matters here; directive labels (`caddy.reverse_proxy`, + * `caddy_0.tls`, ...) carry a dot after the prefix and are ignored. + * + * SCHEME: Caddy serves HTTPS automatically, so the default is https. It is http only + * when the address says so explicitly - an `http://` scheme or an `:80` port. A + * `:443`/`https://` is https; any other explicit port keeps https (Caddy still serves + * TLS on a custom port). Snippet/named-matcher values like `(encode)` are NOT site + * addresses and are skipped. + * + * dockhand.url labels override this - Caddy extraction is a fallback, never a winner + * over an explicit user-provided URL. + */ +export interface CaddyUrl { + url: string; + /** The label key the address came from (`caddy` or `caddy_0`, ...). */ + group: string; +} + +// Bare site-address key: `caddy` or `caddy_` with NOTHING after it (a dot would +// make it a directive like `caddy.reverse_proxy`). +const SITE_KEY_RE = /^caddy(?:_\d+)?$/; + +export function extractCaddyUrls( + labels: Record | undefined | null +): CaddyUrl[] { + if (!labels) return []; + + const out: CaddyUrl[] = []; + const seen = new Set(); + + for (const [key, value] of Object.entries(labels)) { + if (!SITE_KEY_RE.test(key)) continue; + const raw = (value ?? '').trim(); + if (!raw) continue; + + // A site-address label can list several addresses separated by commas. + for (const addr of raw.split(',')) { + const url = addressToUrl(addr.trim()); + if (!url || seen.has(url)) continue; + seen.add(url); + out.push({ url, group: key }); + } + } + + return out; +} + +/** Turn a single Caddy site address into a clickable URL, or null if it isn't one. */ +function addressToUrl(addr: string): string | null { + if (!addr) return null; + // Snippet / named matcher definitions: `(encode)`, `(snippet)` - not a site. + if (addr.startsWith('(')) return null; + // Wildcard / catch-all placeholders aren't a clickable host. + if (addr === '*' || addr === ':443' || addr === ':80') return null; + + let scheme: 'http' | 'https' = 'https'; + let rest = addr; + + if (/^https?:\/\//i.test(rest)) { + scheme = rest.toLowerCase().startsWith('http://') ? 'http' : 'https'; + rest = rest.replace(/^https?:\/\//i, ''); + } + + // Split host[:port] from any path matcher (`example.com/api/*`). + const slash = rest.indexOf('/'); + let hostPort = slash === -1 ? rest : rest.slice(0, slash); + let path = slash === -1 ? '' : rest.slice(slash); + + if (!hostPort) return null; + + // A leading-colon address (`:8080`) is a port-only site with no host - not clickable. + if (hostPort.startsWith(':')) return null; + + // Explicit port decides http only for :80; keep https otherwise (Caddy serves TLS + // on custom ports too). Drop :443 as it's the https default. + const portMatch = hostPort.match(/:(\d+)$/); + if (portMatch) { + const port = portMatch[1]; + if (!/^https?:\/\//i.test(addr)) { + if (port === '80') scheme = 'http'; + } + if (port === '443' || port === '80') hostPort = hostPort.replace(/:\d+$/, ''); + } + + // Strip a trailing wildcard from the path (`/api/*` -> `/api`). + path = path.replace(/\/\*+$/, '').replace(/\*+$/, ''); + if (path === '/') path = ''; + + return `${scheme}://${hostPort}${path}`; +} diff --git a/src/routes/containers/+page.svelte b/src/routes/containers/+page.svelte index a03df25..3dd5844 100644 --- a/src/routes/containers/+page.svelte +++ b/src/routes/containers/+page.svelte @@ -91,6 +91,7 @@ import { parseCustomUrl } from '$lib/utils/custom-url'; import { extractTraefikUrls } from '$lib/utils/traefik-urls'; import { extractPangolinUrls } from '$lib/utils/pangolin-urls'; + import { extractCaddyUrls } from '$lib/utils/caddy-urls'; import { resolveChangelogUrl } from '$lib/utils/changelog-url'; import { detectShells, getBestShell, hasAvailableShell, USER_OPTIONS, getSavedUser, saveUserForContainer, getCustomUsers, removeCustomUser, type ShellDetectionResult } from '$lib/utils/shell-detection'; import { DataGrid } from '$lib/components/data-grid'; @@ -1886,7 +1887,8 @@ {@const parsedUrl = parseCustomUrl(container.labels?.['dockhand.url'])} {@const traefikUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractTraefikUrls(container.labels)} {@const pangolinUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractPangolinUrls(container.labels)} - {#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl || traefikUrls.length > 0 || pangolinUrls.length > 0} + {@const caddyUrls = (parsedUrl || !$appSettings.honorProxyLabels) ? [] : extractCaddyUrls(container.labels)} + {#if ports.length > 0 || exposedPorts.length > 0 || parsedUrl || traefikUrls.length > 0 || pangolinUrls.length > 0 || caddyUrls.length > 0} {@const compactPorts = $appSettings.compactPorts} {@const displayPorts = compactPorts && ports.length > 1 ? [ports[0]] : ports} {@const remainingCount = ports.length - 1} @@ -1935,6 +1937,21 @@ {/each} + + {#each caddyUrls as c} + e.stopPropagation()} + class="inline-flex items-center gap-0.5 text-xs bg-primary/10 hover:bg-primary/20 text-primary px-1 py-0.5 rounded transition-colors shrink-0" + title="Caddy {c.group}: {c.url}" + > + + {c.url.replace(/^https?:\/\//, '')} + + + {/each} {#each displayPorts as port} {@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])} {@const portUrl = portParsed?.url || null} diff --git a/src/routes/settings/general/GeneralTab.svelte b/src/routes/settings/general/GeneralTab.svelte index b4f7933..ae4a978 100644 --- a/src/routes/settings/general/GeneralTab.svelte +++ b/src/routes/settings/general/GeneralTab.svelte @@ -437,13 +437,13 @@ services:
- + -

Parse traefik.http.routers.<name>.rule, pangolin.public-resources.<name>.full-domain, and pangolin.private-resources.<name>.full-domain labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit dockhand.url labels are shown.

+

Parse traefik.http.routers.<name>.rule, pangolin.public-resources.<name>.full-domain, pangolin.private-resources.<name>.full-domain, and caddy-docker-proxy caddy/caddy_<n> site-address labels, and surface the resulting URLs as clickable pills next to ports. When off, only explicit dockhand.url labels are shown.

{/each} + + {#each ($appSettings.honorProxyLabels ? extractCaddyUrls(container.labels) : []) as c} + e.stopPropagation()} + class="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-primary/10 text-primary hover:bg-primary/20 transition-colors" + title="Caddy {c.group}: {c.url}" + > + + {c.url.replace(/^https?:\/\//, '')} + + + {/each} {/if} {#if container.ports.length > 0}