246 lines
9.9 KiB
Python
Executable File
246 lines
9.9 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# /// script
|
|
# dependencies = []
|
|
# ///
|
|
|
|
import argparse
|
|
import ipaddress
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
from typing import Any
|
|
|
|
|
|
class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter):
|
|
pass
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate Caddy reverse proxies from Docker container labels.",
|
|
formatter_class=HelpFormatter,
|
|
epilog="""Compose service labels:
|
|
labels:
|
|
caddy.domain: ascii.app.abdus.dev
|
|
caddy.port: "3000"
|
|
caddy.basic_auth: "true"
|
|
|
|
caddy.port selects the internal TCP port, not the published host port.
|
|
Omit it when exactly one TCP port is published. Only running labeled
|
|
containers generate routes. Potential Docker port clashes warn on stderr.
|
|
caddy.basic_auth accepts true or false (default). When true, the route
|
|
imports the (auth) snippet defined in your handwritten Caddy config.
|
|
|
|
Local usage:
|
|
python3 docker_to_caddy.py
|
|
python3 docker_to_caddy.py --output docker.generated.caddy
|
|
|
|
Remote Docker over SSH:
|
|
env -u DOCKER_CONTEXT DOCKER_HOST=ssh://abdus@your-server docker ps
|
|
env -u DOCKER_CONTEXT DOCKER_HOST=ssh://abdus@your-server \\
|
|
python3 docker_to_caddy.py --output docker.generated.caddy
|
|
|
|
The SSH user needs Docker socket access on the server. These commands do
|
|
not change your selected Docker context (for example, OrbStack). Output is
|
|
written locally; loopback upstreams refer to the server where Caddy runs.
|
|
|
|
Caddy setup:
|
|
Add a top-level import docker.generated.caddy to your handwritten config.
|
|
Remove migrated proxy blocks, then validate and reload Caddy manually.
|
|
The script does not configure DNS or reload Caddy.
|
|
""",
|
|
)
|
|
parser.add_argument("--output", type=Path, help="Output file; otherwise write to stdout")
|
|
return parser.parse_args()
|
|
|
|
|
|
def docker_json(arguments: list[str]) -> list[Any]:
|
|
result = subprocess.run(
|
|
args=["docker", *arguments], check=True, capture_output=True, text=True,
|
|
timeout=60,
|
|
)
|
|
value = json.loads(result.stdout)
|
|
if not isinstance(value, list):
|
|
raise ValueError("Docker returned an unexpected response")
|
|
return value
|
|
|
|
|
|
def inspect_containers() -> list[dict[str, Any]]:
|
|
result = subprocess.run(
|
|
args=["docker", "container", "ls", "--all", "--quiet"],
|
|
check=True, capture_output=True, text=True, timeout=60,
|
|
)
|
|
identifiers = result.stdout.split()
|
|
containers = []
|
|
for offset in range(0, len(identifiers), 100):
|
|
containers.extend(docker_json(arguments=[
|
|
"container", "inspect", *identifiers[offset:offset + 100],
|
|
]))
|
|
return containers
|
|
|
|
|
|
def container_name(container: dict[str, Any]) -> str:
|
|
return str(container.get("Name") or container["Id"]).lstrip("/")
|
|
|
|
|
|
def port_number(value: str) -> int:
|
|
if not re.fullmatch(r"[0-9]+", value) or not 1 <= int(value) <= 65535:
|
|
raise ValueError(f"Invalid port: {value!r}")
|
|
return int(value)
|
|
|
|
|
|
def normalize_domain(value: str) -> str:
|
|
domain = value.lower().rstrip(".")
|
|
labels = domain.split(".")
|
|
if len(domain) > 253 or len(labels) < 2 or any(
|
|
not re.fullmatch(r"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?", label)
|
|
for label in labels
|
|
):
|
|
raise ValueError(f"Invalid domain: {value!r}; use a single DNS hostname")
|
|
return domain
|
|
|
|
|
|
def upstream(binding: dict[str, str]) -> tuple[int, str, int]:
|
|
address = ipaddress.ip_address(binding.get("HostIp") or "0.0.0.0")
|
|
if address.is_unspecified:
|
|
address = ipaddress.ip_address("127.0.0.1" if address.version == 4 else "::1")
|
|
port = port_number(value=binding["HostPort"])
|
|
return address.version, str(address), port
|
|
|
|
|
|
def render_config(containers: list[dict[str, Any]]) -> str:
|
|
routes: dict[str, tuple[str, str, bool]] = {}
|
|
for container in containers:
|
|
labels = container.get("Config", {}).get("Labels") or {}
|
|
if "caddy.domain" not in labels or not container.get("State", {}).get("Running"):
|
|
continue
|
|
name = container_name(container=container)
|
|
try:
|
|
domain = normalize_domain(value=labels["caddy.domain"])
|
|
auth_label = labels.get("caddy.basic_auth", "false")
|
|
if not isinstance(auth_label, str) or auth_label.lower() not in ("true", "false"):
|
|
raise ValueError("caddy.basic_auth must be true or false")
|
|
basic_auth = auth_label.lower() == "true"
|
|
if container.get("HostConfig", {}).get("NetworkMode") == "host":
|
|
raise ValueError("Host networking is unsupported; publish a TCP port instead")
|
|
ports = {
|
|
key: [binding for binding in bindings or [] if binding.get("HostPort")]
|
|
for key, bindings in (container.get("NetworkSettings", {}).get("Ports") or {}).items()
|
|
if key.endswith("/tcp")
|
|
}
|
|
ports = {key: bindings for key, bindings in ports.items() if bindings}
|
|
if "caddy.port" in labels:
|
|
key = f"{port_number(value=labels['caddy.port'])}/tcp"
|
|
if key not in ports:
|
|
raise ValueError(f"Selected container port {key} has no published host port")
|
|
elif len(ports) == 1:
|
|
key = next(iter(ports))
|
|
else:
|
|
raise ValueError("Expected one published TCP port; set caddy.port to select one")
|
|
version, address, port = min(upstream(binding=binding) for binding in ports[key])
|
|
target = f"[{address}]:{port}" if version == 6 else f"{address}:{port}"
|
|
if domain in routes:
|
|
raise ValueError(f"Domain {domain} is also claimed by {routes[domain][0]}")
|
|
routes[domain] = (name, target, basic_auth)
|
|
except (ValueError, KeyError, TypeError) as error:
|
|
raise ValueError(f"Container {name}: {error}") from error
|
|
blocks = ["# Generated by docker_to_caddy.py; do not edit.\n"]
|
|
for domain, (name, target, basic_auth) in sorted(routes.items()):
|
|
safe_name = re.sub(r"[^a-zA-Z0-9_.-]", "_", name)
|
|
auth_import = " import auth\n" if basic_auth else ""
|
|
blocks.append(
|
|
f"# Container: {safe_name}\n{domain} {{\n{auth_import} reverse_proxy {target}\n}}\n"
|
|
)
|
|
return "\n".join(blocks)
|
|
|
|
|
|
def addresses_overlap(first: str, second: str) -> bool:
|
|
left = ipaddress.ip_address(first or "0.0.0.0")
|
|
right = ipaddress.ip_address(second or "0.0.0.0")
|
|
if left.version != right.version:
|
|
return (left.version == 6 and left.is_unspecified) or (
|
|
right.version == 6 and right.is_unspecified
|
|
)
|
|
return left == right or left.is_unspecified or right.is_unspecified
|
|
|
|
|
|
def warn_port_clashes(containers: list[dict[str, Any]]) -> None:
|
|
bindings: list[tuple[str, str, str, int, str]] = []
|
|
for container in containers:
|
|
name = container_name(container=container)
|
|
state = container.get("State", {}).get("Status", "unknown")
|
|
configured = container.get("HostConfig", {}).get("PortBindings") or {}
|
|
actual = container.get("NetworkSettings", {}).get("Ports") or {}
|
|
seen: set[tuple[str, int, str]] = set()
|
|
for source in (configured, actual):
|
|
for container_port, entries in source.items():
|
|
protocol = container_port.rsplit("/", 1)[-1]
|
|
for binding in entries or []:
|
|
value = binding.get("HostPort")
|
|
if not value or value == "0":
|
|
continue
|
|
try:
|
|
port = port_number(value=value)
|
|
address = str(ipaddress.ip_address(binding.get("HostIp") or "0.0.0.0"))
|
|
except ValueError as error:
|
|
logging.warning("Cannot check binding for %s: %s", name, error)
|
|
continue
|
|
identity = (address, port, protocol)
|
|
if identity not in seen:
|
|
seen.add(identity)
|
|
bindings.append((name, state, address, port, protocol))
|
|
warned: set[tuple[str, str, int, str]] = set()
|
|
for index, first in enumerate(bindings):
|
|
for second in bindings[index + 1:]:
|
|
if first[0] == second[0] or first[3:] != second[3:]:
|
|
continue
|
|
if not addresses_overlap(first=first[2], second=second[2]):
|
|
continue
|
|
identity = (*sorted((first[0], second[0])), first[3], first[4])
|
|
if identity in warned:
|
|
continue
|
|
warned.add(identity)
|
|
logging.warning(
|
|
"Potential port clash: %s (%s) [%s]:%s/%s and %s (%s) [%s]:%s/%s",
|
|
*first, *second,
|
|
)
|
|
|
|
|
|
def write_config(config: str, output: Path) -> None:
|
|
output = output.absolute()
|
|
with tempfile.TemporaryDirectory(dir=output.parent, prefix=".docker-to-caddy-") as directory:
|
|
temporary = Path(directory) / "config"
|
|
temporary.write_text(data=config, encoding="utf-8")
|
|
if output.exists():
|
|
temporary.chmod(mode=output.stat().st_mode & 0o777)
|
|
os.replace(src=temporary, dst=output)
|
|
|
|
|
|
def main() -> int:
|
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s: %(message)s")
|
|
arguments = parse_args()
|
|
try:
|
|
containers = inspect_containers()
|
|
warn_port_clashes(containers=containers)
|
|
config = render_config(containers=containers)
|
|
if arguments.output is None:
|
|
sys.stdout.write(config)
|
|
else:
|
|
write_config(config=config, output=arguments.output)
|
|
logging.info("Wrote %s", arguments.output)
|
|
return 0
|
|
except subprocess.CalledProcessError as error:
|
|
logging.error("Docker command failed: %s", (error.stderr or str(error)).strip())
|
|
except (OSError, ValueError, subprocess.TimeoutExpired) as error:
|
|
logging.error("%s", error)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|