support caddy-docker-proxy labels
This commit is contained in:
6 files changed
+177
-4
No files matched your search
@@ -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];
|
||||
}
|
||||
@@ -760,7 +760,7 @@ async function seedDatabase(): Promise<void> {
|
||||
// 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'],
|
||||
|
||||
@@ -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_<N>` 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_<N>` 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<string, string> | undefined | null
|
||||
): CaddyUrl[] {
|
||||
if (!labels) return [];
|
||||
|
||||
const out: CaddyUrl[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
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}`;
|
||||
}
|
||||
@@ -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 @@
|
||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||
</a>
|
||||
{/each}
|
||||
<!-- caddy-docker-proxy fallback URLs (#1390). dockhand.url suppresses these. -->
|
||||
{#each caddyUrls as c}
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => 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}"
|
||||
>
|
||||
<Globe class="w-2.5 h-2.5" />
|
||||
<span class="max-w-[120px] truncate">{c.url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||
</a>
|
||||
{/each}
|
||||
{#each displayPorts as port}
|
||||
{@const portParsed = parseCustomUrl(container.labels?.[`dockhand.port.${port.publicPort}.url`])}
|
||||
{@const portUrl = portParsed?.url || null}
|
||||
|
||||
@@ -437,13 +437,13 @@ services:
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<Label>Honor Traefik/Pangolin labels</Label>
|
||||
<Label>Honor Traefik/Pangolin/Caddy labels</Label>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger>
|
||||
<HelpCircle class="w-3.5 h-3.5 text-muted-foreground" />
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Content side="top" class="w-96 max-w-[90vw]">
|
||||
<p>Parse <code>traefik.http.routers.<name>.rule</code>, <code>pangolin.public-resources.<name>.full-domain</code>, and <code>pangolin.private-resources.<name>.full-domain</code> labels and surface the resulting URLs as clickable pills next to ports. When off, only explicit <code>dockhand.url</code> labels are shown.</p>
|
||||
<p>Parse <code>traefik.http.routers.<name>.rule</code>, <code>pangolin.public-resources.<name>.full-domain</code>, <code>pangolin.private-resources.<name>.full-domain</code>, and caddy-docker-proxy <code>caddy</code>/<code>caddy_<n></code> site-address labels, and surface the resulting URLs as clickable pills next to ports. When off, only explicit <code>dockhand.url</code> labels are shown.</p>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Root>
|
||||
<TogglePill
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { extractTraefikUrls } from '$lib/utils/traefik-urls';
|
||||
import { resolveChangelogUrl } from '$lib/utils/changelog-url';
|
||||
import { extractPangolinUrls } from '$lib/utils/pangolin-urls';
|
||||
import { extractCaddyUrls } from '$lib/utils/caddy-urls';
|
||||
import { appSettings } from '$lib/stores/settings';
|
||||
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
|
||||
import BatchOperationModal from '$lib/components/BatchOperationModal.svelte';
|
||||
@@ -2349,6 +2350,21 @@
|
||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||
</a>
|
||||
{/each}
|
||||
<!-- caddy-docker-proxy fallback URLs (#1390). Same suppression rules. -->
|
||||
{#each ($appSettings.honorProxyLabels ? extractCaddyUrls(container.labels) : []) as c}
|
||||
<a
|
||||
href={c.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onclick={(e) => 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}"
|
||||
>
|
||||
<Globe class="w-2.5 h-2.5" />
|
||||
<span class="max-w-[120px] truncate">{c.url.replace(/^https?:\/\//, '')}</span>
|
||||
<ExternalLink class="w-2.5 h-2.5 opacity-60" />
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
<!-- Clickable ports with range collapsing -->
|
||||
{#if container.ports.length > 0}
|
||||
|
||||
Reference in new issue
Block a user