support caddy-docker-proxy labels

This commit is contained in:
jarek committed 2026-08-13 10:20:29 +02:00
1 parent c91f38eddf
commit ce93e92f6e
6 files changed
+177 -4

No files matched your search

+37
View File
@@ -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];
}
+1 -1
View File
@@ -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'],
+103
View File
@@ -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}`;
}