Skip to content

Instantly share code, notes, and snippets.

@WuSiYu
Last active July 13, 2026 14:48
Show Gist options
  • Select an option

  • Save WuSiYu/9b577da02052072c8526ceb3da921ec3 to your computer and use it in GitHub Desktop.

Select an option

Save WuSiYu/9b577da02052072c8526ceb3da921ec3 to your computer and use it in GitHub Desktop.
OCI Streaming Mirror: Docker/OCI registry cache-less reverse proxy
#!/usr/bin/env python3
"""
OCI Streaming Mirror POC
A single-file proof-of-concept for running a stateless Docker/OCI registry
streaming reverse proxy. It lets Docker pull images through custom mirror
domains such as:
docker pull hub.mirrors.example.com/library/ubuntu:24.04
docker pull ghcr.mirrors.example.com/modeltc/lightllm:main
docker pull nvcr.mirrors.example.com/nvidia/cuda:12.4.1-base-ubuntu22.04
The proxy does not cache image layers on disk. It forwards OCI Registry API
requests to the selected upstream registry, follows upstream blob redirects
server-side, and streams the response back to the Docker client.
Default upstream host prefixes:
hub.<base-domain> -> https://registry-1.docker.io
ghcr.<base-domain> -> https://ghcr.io
nvcr.<base-domain> -> https://nvcr.io
quay.<base-domain> -> https://quay.io
k8s.<base-domain> -> https://registry.k8s.io
gcr.<base-domain> -> https://gcr.io
mcr.<base-domain> -> https://mcr.microsoft.com
Basic usage with automatic HTTPS certificates:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com \
--email admin@example.com \
--renew-loop
If --email is omitted, certbot is invoked in no-email mode:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com
Run behind an existing HTTPS reverse proxy, such as nginx or Caddy:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com \
--http-only \
--http-port 8080 \
--skip-certbot \
--trust-forwarded-for
Optional path-token authentication:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com \
--path-auth mysecret
Then pull images with the token inserted after the host:
docker pull ghcr.mirrors.example.com/mysecret/modeltc/lightllm:main
docker pull hub.mirrors.example.com/mysecret/library/ubuntu:24.04
Serve an informational page on the base domain itself:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com \
--serve-base-domain
This requires DNS records for both the base domain and the wildcard:
mirrors.example.com A/AAAA <server IP>
*.mirrors.example.com A/AAAA <server IP>
Serve a static directory on the base domain instead of the built-in page:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com \
--web-root /var/www/oci-mirror
Install as a systemd service:
sudo python3 oci_stream_mirror.py \
--install \
--base-domain mirrors.example.com \
--path-auth mysecret \
--renew-loop
The installer copies the current script to:
/opt/oci_stream_mirror.py
and writes a systemd unit to:
/etc/systemd/system/oci-stream-mirror.service
The generated service embeds the current command-line arguments, except
installer-only options such as --install, --uninstall, and --service-name.
The installer does not start the service automatically. After installation:
sudo systemctl enable --now oci-stream-mirror
sudo journalctl -u oci-stream-mirror -f
sudo systemctl restart oci-stream-mirror
Uninstall the systemd service and remove /opt/oci_stream_mirror.py:
sudo python3 oci_stream_mirror.py --uninstall
Using non-standard service ports:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com \
--http-port 8080 \
--https-port 8443
If port 80 is already used by another web server, certbot HTTP-01 validation
can use that server's webroot:
sudo python3 oci_stream_mirror.py \
--base-domain mirrors.example.com \
--no-http-server \
--https-port 8443 \
--acme-webroot /var/www/acme
The existing web server must serve:
/.well-known/acme-challenge/
from the same webroot.
Notes:
- Docker Hub official images need the explicit "library/" namespace when using
a custom registry host.
- This proxy only supports pull-style requests. Push/write methods are rejected.
- Mirror hosts only handle /v2/, /v2/*, /__oci_auth/*, and ACME challenge paths.
- With --serve-base-domain, the base domain serves an informational page.
- With --web-root, the base domain serves static files from that directory.
- This does not bypass upstream registry rate limits, because nothing is cached.
- Do not expose this service publicly without IP allowlisting, VPN access, or
path-token authentication.
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import html
import http.client
import errno
import ipaddress
import mimetypes
import os
import posixpath
import re
import secrets
import shlex
import shutil
import signal
import socket
import socketserver
import ssl
import subprocess
import sys
import textwrap
import threading
import time
from dataclasses import dataclass, field
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Dict, Iterable, List, Optional, Tuple
from urllib.parse import parse_qsl, unquote, urlencode, urljoin, urlsplit, urlunsplit
DEFAULT_UPSTREAMS = {
# Docker Hub official images need hub.<base>/library/ubuntu:24.04
"hub": "https://registry-1.docker.io",
"ghcr": "https://ghcr.io",
"nvcr": "https://nvcr.io",
"quay": "https://quay.io",
"k8s": "https://registry.k8s.io",
"gcr": "https://gcr.io",
"mcr": "https://mcr.microsoft.com",
}
HOP_BY_HOP_HEADERS = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
REALM_RE = re.compile(r'realm="([^"]+)"', re.IGNORECASE)
SAFE_TOKEN_RE = re.compile(r"^[A-Za-z0-9._~-]{1,64}$")
DNS_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$")
HOST_PORT_RE = re.compile(r"^([A-Za-z0-9.-]+)(?::([0-9]{1,5}))?$")
CERT_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,128}$")
AUTH_SIG_HEX_LEN = 32
MAX_REDIRECTS = 8
COPY_CHUNK_SIZE = 1024 * 1024
DEFAULT_PULL_LOG_IDLE_SECONDS = 5.0
# Some registries, notably GHCR, may return "manifest unknown: OCI index found,
# but Accept header does not support OCI indexes" if /manifests/<ref> does not
# explicitly advertise OCI index support. This proxy defensively unions the
# client's Accept value with common Docker/OCI manifest media types for manifest
# requests only. Blob requests are untouched.
MANIFEST_ACCEPT_TYPES = [
"application/vnd.docker.distribution.manifest.v2+json",
"application/vnd.docker.distribution.manifest.list.v2+json",
"application/vnd.oci.image.manifest.v1+json",
"application/vnd.oci.image.index.v1+json",
"application/vnd.oci.artifact.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v1+json",
"application/vnd.docker.distribution.manifest.v1+prettyjws",
]
@dataclass
class Upstream:
alias: str
public_host: str
registry_base: str
@dataclass
class AppConfig:
base_domain: str
bind: str
http_port: int
https_port: int
acme_webroot: str
cert_name: str
cert_file: str
key_file: str
serve_base_domain: bool
web_root: Optional[str]
upstream_by_host: Dict[str, Upstream]
allowed_cidrs: List[ipaddress._BaseNetwork]
upstream_timeout: int
client_timeout: int
tls_handshake_timeout: int
max_clients: int
pull_log_idle: float
verbose: bool
http_only: bool
external_scheme: str
trust_forwarded_for: bool
path_auth_enabled: bool
path_token: Optional[str]
auth_relay_secret: bytes
ban_threshold: int
ban_seconds: int
auth_failures: Dict[str, int] = field(default_factory=dict)
banned_until: Dict[str, float] = field(default_factory=dict)
auth_lock: threading.Lock = field(default_factory=threading.Lock)
CONFIG: Optional[AppConfig] = None
TLS_CONTEXT: Optional[ssl.SSLContext] = None
STOP_EVENT = threading.Event()
SERVERS: List[ThreadingHTTPServer] = []
SERVER_THREADS: List[threading.Thread] = []
def log(msg: str) -> None:
print(time.strftime("[%Y-%m-%d %H:%M:%S] ") + msg, flush=True)
def one_line_exception(exc: BaseException, max_len: int = 180) -> str:
msg = str(exc) or repr(exc)
msg = msg.replace("\n", " ").replace("\r", " ")
if len(msg) > max_len:
msg = msg[: max_len - 3] + "..."
return f"{exc.__class__.__name__}: {msg}"
def is_quiet_network_error(exc: BaseException) -> bool:
"""Return True for common client-side disconnect / TLS scanner noise.
These are not application errors and should not produce a traceback in
normal operation. In verbose mode they are logged as a single line.
"""
if isinstance(exc, (BrokenPipeError, ConnectionResetError, TimeoutError, socket.timeout, ssl.SSLError)):
return True
if isinstance(exc, OSError):
noisy_errno = {
errno.EPIPE,
errno.ECONNRESET,
errno.ECONNABORTED,
errno.ETIMEDOUT,
}
return getattr(exc, "errno", None) in noisy_errno
return False
def log_quiet_network_error(where: str, client: object, exc: BaseException) -> None:
cfg = CONFIG
if not (cfg and cfg.verbose):
return
if isinstance(client, tuple) and client:
client_s = str(client[0])
else:
client_s = str(client)
log(f"network-close where={where} client={safe_log_value(client_s, 128)} error={safe_log_value(one_line_exception(exc), 256)}")
def die(msg: str, code: int = 2) -> None:
print(f"fatal: {msg}", file=sys.stderr)
sys.exit(code)
def safe_log_value(value: str, max_len: int = 512) -> str:
value = value.replace("\n", "\\n").replace("\r", "\\r")
value = re.sub(r"\s+", " ", value).strip()
if len(value) > max_len:
value = value[: max_len - 1] + "…"
return value
def format_size(num_bytes: int) -> str:
value = float(max(0, num_bytes))
for unit in ("B", "KiB", "MiB", "GiB", "TiB", "PiB"):
if value < 1024.0 or unit == "PiB":
if unit == "B":
return f"{int(value)} {unit}"
return f"{value:.2f} {unit}"
value /= 1024.0
return f"{num_bytes} B"
def format_speed(num_bytes: int, seconds: float) -> str:
seconds = max(seconds, 0.001)
return format_size(int(num_bytes / seconds)) + "/s"
def parse_manifest_request_path(path: str) -> Optional[Tuple[str, str]]:
if not path.startswith("/v2/"):
return None
marker = "/manifests/"
idx = path.find(marker, len("/v2/"))
if idx < 0:
return None
repo = path[len("/v2/") : idx]
ref = path[idx + len(marker) :]
if not repo or not ref or "/" in ref:
return None
return unquote(repo), unquote(ref)
def parse_blob_request_path(path: str) -> Optional[Tuple[str, str]]:
if not path.startswith("/v2/"):
return None
marker = "/blobs/"
idx = path.find(marker, len("/v2/"))
if idx < 0:
return None
repo = path[len("/v2/") : idx]
digest = path[idx + len(marker) :]
if not repo or not digest or "/" in digest:
return None
return unquote(repo), unquote(digest)
def format_image_ref(public_host: str, repo: str, ref: str) -> str:
if ref.startswith("sha256:") or ref.startswith("sha512:"):
return f"{public_host}/{repo}@{ref}"
return f"{public_host}/{repo}:{ref}"
@dataclass
class StreamStats:
response_status: Optional[int] = None
body_bytes: int = 0
@dataclass
class PullSession:
key: Tuple[str, str, str]
client_ip: str
public_host: str
repo: str
image: str
started_at: float
last_activity: float
bytes_total: int = 0
active_blobs: int = 0
failed: bool = False
finalized: bool = False
timer: Optional[threading.Timer] = None
class PullLogManager:
"""
Best-effort Docker pull session logger.
Docker/OCI Registry does not expose a single "pull finished" request. A
client normally performs manifest requests followed by several concurrent
blob requests. This manager groups requests by client IP + mirror host +
repository, logs one START line at the first manifest request, accumulates
streamed blob bytes, and logs one END line after a short idle period once no
blob transfer is active.
"""
def __init__(self) -> None:
self.lock = threading.Lock()
self.sessions: Dict[Tuple[str, str, str], PullSession] = {}
def idle_seconds(self) -> float:
cfg = CONFIG
if cfg is None:
return DEFAULT_PULL_LOG_IDLE_SECONDS
return max(0.5, float(cfg.pull_log_idle))
def start_manifest(self, client_ip: str, public_host: str, repo: str, ref: str) -> Tuple[str, str, str]:
now = time.time()
key = (client_ip, public_host, repo)
image = format_image_ref(public_host, repo, ref)
with self.lock:
existing = self.sessions.get(key)
if existing and not existing.finalized:
# Keep the first user-visible image name for this pull. Docker
# often requests a tag manifest first and then a platform
# manifest by digest; logging both would create duplicate pull
# sessions for one docker pull. Concurrent pulls of different
# tags in the same repo from the same client may be merged,
# which is acceptable for this lightweight best-effort logger.
existing.last_activity = now
self._cancel_timer_locked(existing)
return key
session = PullSession(
key=key,
client_ip=client_ip,
public_host=public_host,
repo=repo,
image=image,
started_at=now,
last_activity=now,
)
self.sessions[key] = session
log(f"PULL START client={safe_log_value(client_ip)} image={safe_log_value(image)}")
return key
def start_blob(self, client_ip: str, public_host: str, repo: str, digest: str) -> Tuple[str, str, str]:
now = time.time()
key = (client_ip, public_host, repo)
with self.lock:
session = self.sessions.get(key)
if session is None or session.finalized:
short_digest = digest if len(digest) <= 32 else digest[:32] + "…"
image = f"{public_host}/{repo}@{short_digest}"
session = PullSession(
key=key,
client_ip=client_ip,
public_host=public_host,
repo=repo,
image=image,
started_at=now,
last_activity=now,
)
self.sessions[key] = session
log(f"PULL START client={safe_log_value(client_ip)} image={safe_log_value(image)}")
session.active_blobs += 1
session.last_activity = now
self._cancel_timer_locked(session)
return key
def finish_manifest(self, key: Tuple[str, str, str], status: Optional[int]) -> None:
now = time.time()
with self.lock:
session = self.sessions.get(key)
if not session or session.finalized:
return
session.last_activity = now
if status is not None and status >= 400:
session.failed = True
if session.active_blobs == 0:
self._schedule_finalize_locked(session)
def finish_blob(self, key: Tuple[str, str, str], stats: StreamStats, failed: bool = False) -> None:
now = time.time()
with self.lock:
session = self.sessions.get(key)
if not session or session.finalized:
return
session.bytes_total += max(0, stats.body_bytes)
session.last_activity = now
if failed or (stats.response_status is not None and stats.response_status >= 400):
session.failed = True
if session.active_blobs > 0:
session.active_blobs -= 1
if session.active_blobs == 0:
self._schedule_finalize_locked(session)
def fail_request(self, key: Tuple[str, str, str], stats: Optional[StreamStats] = None) -> None:
now = time.time()
with self.lock:
session = self.sessions.get(key)
if not session or session.finalized:
return
if stats is not None:
session.bytes_total += max(0, stats.body_bytes)
session.failed = True
session.last_activity = now
if session.active_blobs > 0:
session.active_blobs -= 1
if session.active_blobs == 0:
self._schedule_finalize_locked(session)
def finalize_if_idle(self, key: Tuple[str, str, str]) -> None:
now = time.time()
with self.lock:
session = self.sessions.get(key)
if not session or session.finalized:
return
if session.active_blobs > 0:
return
idle = now - session.last_activity
delay = self.idle_seconds()
if idle + 0.05 < delay and not STOP_EVENT.is_set():
self._schedule_finalize_locked(session)
return
self._finalize_locked(session, now)
def finalize_all(self) -> None:
now = time.time()
with self.lock:
for session in list(self.sessions.values()):
if not session.finalized:
self._finalize_locked(session, now)
def _cancel_timer_locked(self, session: PullSession) -> None:
if session.timer is not None:
try:
session.timer.cancel()
except Exception:
pass
session.timer = None
def _schedule_finalize_locked(self, session: PullSession) -> None:
self._cancel_timer_locked(session)
if STOP_EVENT.is_set():
self._finalize_locked(session, time.time())
return
timer = threading.Timer(self.idle_seconds(), self.finalize_if_idle, args=(session.key,))
timer.daemon = True
session.timer = timer
timer.start()
def _finalize_locked(self, session: PullSession, now: float) -> None:
session.finalized = True
self._cancel_timer_locked(session)
self.sessions.pop(session.key, None)
duration = max(0.001, now - session.started_at)
status = "failed" if session.failed else "ok"
log(
"PULL END "
f"client={safe_log_value(session.client_ip)} "
f"image={safe_log_value(session.image)} "
f"status={status} "
f"size={format_size(session.bytes_total)} "
f"duration={duration:.2f}s "
f"speed={format_speed(session.bytes_total, duration)}"
)
PULL_LOG = PullLogManager()
def validate_dns_name(name: str, what: str) -> str:
value = name.strip().lower().rstrip(".")
if len(value) > 253 or "." not in value:
die(f"{what} should be a valid DNS name, e.g. mirrors.example.com")
labels = value.split(".")
if any(not DNS_LABEL_RE.fullmatch(label) for label in labels):
die(f"{what} contains an invalid DNS label")
return value
def validate_upstream_alias(alias: str) -> str:
value = alias.strip().lower().rstrip(".")
if not DNS_LABEL_RE.fullmatch(value):
die(f"bad upstream alias {alias!r}; use a valid single DNS label, e.g. ghcr")
return value
def validate_cert_name(name: str) -> str:
value = name.strip()
if not CERT_NAME_RE.fullmatch(value):
die("--cert-name contains unsupported characters")
return value
def validate_port(port: int, what: str) -> int:
if port < 1 or port > 65535:
die(f"{what} must be in 1..65535")
return port
def parse_safe_host_header(host_header: str) -> Optional[Tuple[str, str]]:
# This POC is designed for normal DNS hostnames, not IPv6 literals.
m = HOST_PORT_RE.fullmatch(host_header.strip())
if not m:
return None
host = m.group(1).lower().rstrip(".")
labels = host.split(".")
if len(host) > 253 or any(not DNS_LABEL_RE.fullmatch(label) for label in labels):
return None
port = m.group(2)
if port is None:
return host, host
n = int(port)
if n < 1 or n > 65535:
return None
return host, f"{host}:{n}"
def b64url_encode(s: str) -> str:
return base64.urlsafe_b64encode(s.encode("utf-8")).decode("ascii").rstrip("=")
def b64url_decode(s: str) -> str:
s += "=" * (-len(s) % 4)
return base64.urlsafe_b64decode(s.encode("ascii")).decode("utf-8")
def sign_auth_realm(encoded_realm: str) -> str:
cfg = CONFIG
assert cfg is not None
return hmac.new(
cfg.auth_relay_secret,
encoded_realm.encode("ascii"),
hashlib.sha256,
).hexdigest()[:AUTH_SIG_HEX_LEN]
def make_auth_relay_token(upstream_realm: str) -> str:
encoded = b64url_encode(upstream_realm)
return f"{encoded}.{sign_auth_realm(encoded)}"
def verify_auth_relay_token(token: str) -> Optional[str]:
if "." not in token:
return None
encoded, sig = token.rsplit(".", 1)
if not re.fullmatch(r"[A-Za-z0-9_-]+", encoded):
return None
if not re.fullmatch(r"[a-f0-9]+", sig):
return None
expected = sign_auth_realm(encoded)
if not hmac.compare_digest(sig, expected):
return None
try:
realm = b64url_decode(encoded)
except Exception:
return None
p = urlsplit(realm)
# Registry token endpoints should be HTTPS URLs returned by known upstream registries.
# HMAC prevents external clients from choosing arbitrary URLs.
if p.scheme != "https" or not p.netloc:
return None
return realm
def merge_query(base_url: str, extra_query: str) -> str:
p = urlsplit(base_url)
q = []
if p.query:
q.extend(parse_qsl(p.query, keep_blank_values=True))
if extra_query:
q.extend(parse_qsl(extra_query, keep_blank_values=True))
return urlunsplit((p.scheme, p.netloc, p.path or "/", urlencode(q, doseq=True), ""))
def rewrite_www_authenticate(value: str, public_netloc: str) -> str:
cfg = CONFIG
assert cfg is not None
if "bearer" not in value.lower():
return value
def repl(match: re.Match[str]) -> str:
upstream_realm = match.group(1)
relay_token = make_auth_relay_token(upstream_realm)
return f'realm="{cfg.external_scheme}://{public_netloc}/__oci_auth/{relay_token}"'
return REALM_RE.sub(repl, value)
def get_client_ip(handler: BaseHTTPRequestHandler) -> str:
cfg = CONFIG
assert cfg is not None
if cfg.trust_forwarded_for:
xff = handler.headers.get("X-Forwarded-For", "")
if xff:
first = xff.split(",", 1)[0].strip()
if first:
return first
xri = handler.headers.get("X-Real-IP", "").strip()
if xri:
return xri
return handler.client_address[0]
def is_client_allowed(client_ip: str) -> bool:
cfg = CONFIG
assert cfg is not None
if not cfg.allowed_cidrs:
return True
try:
ip = ipaddress.ip_address(client_ip)
except ValueError:
return False
return any(ip in net for net in cfg.allowed_cidrs)
def is_ip_banned(client_ip: str) -> bool:
cfg = CONFIG
assert cfg is not None
if not cfg.path_auth_enabled:
return False
now = time.time()
with cfg.auth_lock:
until = cfg.banned_until.get(client_ip)
if until is None:
return False
if now >= until:
cfg.banned_until.pop(client_ip, None)
cfg.auth_failures.pop(client_ip, None)
return False
return True
def record_auth_failure(client_ip: str) -> None:
cfg = CONFIG
assert cfg is not None
if not cfg.path_auth_enabled:
return
with cfg.auth_lock:
n = cfg.auth_failures.get(client_ip, 0) + 1
if n >= cfg.ban_threshold:
cfg.banned_until[client_ip] = time.time() + cfg.ban_seconds
cfg.auth_failures[client_ip] = 0
log(f"temporarily banned {client_ip} for {cfg.ban_seconds}s after {cfg.ban_threshold} bad path-token attempts")
else:
cfg.auth_failures[client_ip] = n
def clear_auth_failure(client_ip: str) -> None:
cfg = CONFIG
assert cfg is not None
if not cfg.path_auth_enabled:
return
with cfg.auth_lock:
cfg.auth_failures.pop(client_ip, None)
def copy_request_headers(src_headers, target_netloc: str, drop_authorization: bool = False) -> Dict[str, str]:
connection_tokens = set()
for v in src_headers.get_all("Connection", []):
connection_tokens.update(x.strip().lower() for x in v.split(",") if x.strip())
headers: Dict[str, str] = {}
for k, v in src_headers.items():
lk = k.lower()
if lk in HOP_BY_HOP_HEADERS or lk in connection_tokens or lk == "host":
continue
if drop_authorization and lk == "authorization":
continue
headers[k] = v
headers["Host"] = target_netloc
return headers
def get_header_ci(headers: Dict[str, str], name: str) -> Tuple[Optional[str], Optional[str]]:
wanted = name.lower()
for k, v in headers.items():
if k.lower() == wanted:
return k, v
return None, None
def set_header_ci(headers: Dict[str, str], name: str, value: str) -> None:
existing_key, _ = get_header_ci(headers, name)
headers[existing_key or name] = value
def ensure_manifest_accept(headers: Dict[str, str], upstream_path: str) -> None:
if "/manifests/" not in upstream_path:
return
_, existing = get_header_ci(headers, "Accept")
existing = existing or ""
values = [x.strip() for x in existing.split(",") if x.strip()]
seen = {x.split(";", 1)[0].strip().lower() for x in values}
for media_type in MANIFEST_ACCEPT_TYPES:
if media_type.lower() not in seen:
values.append(media_type)
seen.add(media_type.lower())
set_header_ci(headers, "Accept", ", ".join(values))
def add_forwarded_for(headers: Dict[str, str], client_ip: str) -> None:
prior = headers.get("X-Forwarded-For")
headers["X-Forwarded-For"] = f"{prior}, {client_ip}" if prior else client_ip
def is_allowed_oci_path(path: str) -> bool:
return path == "/v2" or path.startswith("/v2/") or path.startswith("/__oci_auth/")
def is_allowed_pull_method(method: str, path: str) -> bool:
if path.startswith("/__oci_auth/"):
return method in {"GET", "HEAD", "OPTIONS"}
return method in {"GET", "HEAD", "OPTIONS"}
def strip_path_token_for_upstream(path: str, client_ip: str) -> Optional[str]:
cfg = CONFIG
assert cfg is not None
if not cfg.path_auth_enabled:
return path
if path in {"/v2", "/v2/"}:
return path
if not path.startswith("/v2/"):
return path
rest = path[len("/v2/") :]
token, sep, repo_rest = rest.partition("/")
if token != cfg.path_token:
record_auth_failure(client_ip)
return None
clear_auth_failure(client_ip)
if not sep or not repo_rest:
return "/v2/"
return "/v2/" + repo_rest
def strip_path_token_from_scope_query(query: str) -> str:
cfg = CONFIG
assert cfg is not None
if not cfg.path_auth_enabled or not cfg.path_token or not query:
return query
changed = False
out = []
prefix = f"repository:{cfg.path_token}/"
for k, v in parse_qsl(query, keep_blank_values=True):
if k == "scope" and v.startswith(prefix):
v = "repository:" + v[len(prefix) :]
changed = True
out.append((k, v))
return urlencode(out, doseq=True) if changed else query
def mirror_example_path() -> str:
cfg = CONFIG
assert cfg is not None
return "<TOKEN>/" if cfg.path_auth_enabled else ""
def build_default_base_page(client_ip: str) -> bytes:
cfg = CONFIG
assert cfg is not None
base = html.escape(cfg.base_domain)
prefix = html.escape(mirror_example_path())
shown_client_ip = html.escape(client_ip)
examples = {
"hub": f"docker pull hub.{base}/{prefix}library/ubuntu:24.04",
"ghcr": f"docker pull ghcr.{base}/{prefix}modeltc/lightllm:main",
"nvcr": f"docker pull nvcr.{base}/{prefix}nvidia/cuda:12.4.1-base-ubuntu22.04",
"quay": f"docker pull quay.{base}/{prefix}prometheus/prometheus:latest",
"k8s": f"docker pull k8s.{base}/{prefix}pause:3.10",
"gcr": f"docker pull gcr.{base}/{prefix}distroless/static-debian12:latest",
"mcr": f"docker pull mcr.{base}/{prefix}hello-world",
}
rows = []
for host, up in sorted(cfg.upstream_by_host.items()):
alias = html.escape(up.alias)
mirror_host = html.escape(host)
target = html.escape(up.registry_base)
cmd = html.escape(examples.get(up.alias, f"docker pull {host}/{prefix}namespace/image:tag"))
rows.append(
f"<tr><td><code>{alias}</code></td>"
f"<td><code>{mirror_host}</code></td>"
f"<td><code>{target}</code></td>"
f"<td><pre>{cmd}</pre></td></tr>"
)
auth_note = ""
if cfg.path_auth_enabled:
auth_note = (
"<p><strong>Path authentication is enabled.</strong> "
"Replace <code>&lt;TOKEN&gt;</code> with the configured token. "
"The token is intentionally not shown on this page.</p>"
)
doc = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OCI Streaming Mirror</title>
<style>
body{{font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,sans-serif;max-width:1100px;margin:40px auto;padding:0 20px;line-height:1.5}}
code,pre{{background:#f5f5f5;border-radius:6px}}
code{{padding:2px 5px}}
pre{{padding:8px;overflow:auto;margin:0}}
table{{border-collapse:collapse;width:100%;margin-top:16px}}
th,td{{border:1px solid #ddd;padding:8px;text-align:left;vertical-align:top}}
th{{background:#fafafa}}
.warn{{color:#9a6700}}
</style>
</head>
<body>
<h1>OCI Streaming Mirror</h1>
<p>This service is a stateless Docker/OCI registry streaming reverse proxy. It does not cache image layers.</p>
{auth_note}
<p>Docker Hub official images need the explicit <code>library/</code> namespace when using a custom registry host.</p>
<table>
<thead><tr><th>Prefix</th><th>Mirror host</th><th>Upstream</th><th>Example</th></tr></thead>
<tbody>
{''.join(rows)}
</tbody>
</table>
<p class="warn">Client IP: <code>{shown_client_ip}</code></p>
</body>
</html>
"""
return doc.encode("utf-8")
def safe_static_file_for_request(root: str, request_path: str) -> Optional[Path]:
root_path = Path(root).resolve()
raw_path = unquote(urlsplit(request_path).path)
if "\x00" in raw_path:
return None
rel = posixpath.normpath(raw_path.lstrip("/"))
if rel in (".", ""):
rel = "index.html"
candidate = (root_path / rel).resolve()
try:
candidate.relative_to(root_path)
except ValueError:
return None
if candidate.is_dir():
candidate = (candidate / "index.html").resolve()
try:
candidate.relative_to(root_path)
except ValueError:
return None
if not candidate.is_file():
return None
return candidate
class TimeoutAndTLSRequestMixin:
"""
Per-client timeouts and per-connection TLS handshake handling.
Important: do not wrap the listening socket with SSLContext.wrap_socket().
If TLS handshakes happen in the accept/serve_forever thread, a scanner can
open a TLS connection and never finish the handshake, blocking all new
accepts and making systemd stop wait forever in server.shutdown().
"""
def setup(self) -> None:
cfg = CONFIG
raw_sock = self.request
client_timeout = cfg.client_timeout if cfg is not None else 300
handshake_timeout = cfg.tls_handshake_timeout if cfg is not None else 10
ctx = getattr(self.server, "ssl_context", None)
if ctx is not None:
raw_sock.settimeout(handshake_timeout)
conn = ctx.wrap_socket(raw_sock, server_side=True, do_handshake_on_connect=False)
try:
conn.do_handshake()
except Exception:
conn.close()
raise
conn.settimeout(client_timeout)
else:
raw_sock.settimeout(client_timeout)
conn = raw_sock
self.connection = conn
self.rfile = self.connection.makefile("rb", self.rbufsize)
if self.wbufsize == 0:
self.wfile = socketserver._SocketWriter(self.connection)
else:
self.wfile = self.connection.makefile("wb", self.wbufsize)
class QuietBrokenPipeMixin:
def handle(self) -> None:
try:
super().handle()
except (BrokenPipeError, ConnectionResetError, TimeoutError, socket.timeout, ssl.SSLError, OSError) as exc:
log_quiet_network_error("handler", getattr(self, "client_address", "unknown"), exc)
return
class ReuseThreadingHTTPServer(ThreadingHTTPServer):
allow_reuse_address = True
daemon_threads = True
block_on_close = False
request_queue_size = 128
def __init__(self, server_address, RequestHandlerClass, *, ssl_context=None):
self.ssl_context = ssl_context
cfg = CONFIG
max_clients = cfg.max_clients if cfg is not None else 256
self._client_slots = threading.BoundedSemaphore(max_clients)
super().__init__(server_address, RequestHandlerClass)
def handle_error(self, request, client_address):
exc = sys.exc_info()[1]
if exc is not None and is_quiet_network_error(exc):
log_quiet_network_error("server", client_address, exc)
return
super().handle_error(request, client_address)
def process_request(self, request, client_address):
if not self._client_slots.acquire(blocking=False):
try:
request.close()
except Exception:
pass
return
try:
super().process_request(request, client_address)
except Exception:
self._client_slots.release()
raise
def process_request_thread(self, request, client_address):
try:
super().process_request_thread(request, client_address)
finally:
self._client_slots.release()
class ChallengeAndRedirectHandler(QuietBrokenPipeMixin, TimeoutAndTLSRequestMixin, BaseHTTPRequestHandler):
server_version = "oci-stream-mirror/0.8"
def log_message(self, fmt: str, *args) -> None:
cfg = CONFIG
if cfg and cfg.verbose:
super().log_message(fmt, *args)
def do_HEAD(self) -> None:
self._handle(send_body=False)
def do_GET(self) -> None:
self._handle(send_body=True)
def _handle(self, send_body: bool) -> None:
cfg = CONFIG
assert cfg is not None
client_ip = get_client_ip(self)
path = urlsplit(self.path).path
# ACME challenge must remain reachable even if allow-cidr/path-auth would reject normal pull traffic.
if path.startswith("/.well-known/acme-challenge/"):
rel = path.lstrip("/")
root = os.path.abspath(cfg.acme_webroot)
fp = os.path.abspath(os.path.join(root, rel))
if not fp.startswith(root + os.sep) or not os.path.isfile(fp):
self.send_error(404, "challenge not found")
return
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(os.path.getsize(fp)))
self.end_headers()
if send_body:
with open(fp, "rb") as f:
shutil.copyfileobj(f, self.wfile, COPY_CHUNK_SIZE)
return
if not is_client_allowed(client_ip):
self.send_error(403, "forbidden")
return
host_header = self.headers.get("Host", cfg.base_domain)
parsed_host = parse_safe_host_header(host_header)
if parsed_host is None:
self.send_error(400, "bad host header")
return
_, public_netloc = parsed_host
location = f"https://{public_netloc}{self.path}"
self.send_response(301)
self.send_header("Location", location)
self.send_header("Content-Length", "0")
self.end_headers()
class OCIProxyHandler(QuietBrokenPipeMixin, TimeoutAndTLSRequestMixin, BaseHTTPRequestHandler):
server_version = "oci-stream-mirror/0.8"
protocol_version = "HTTP/1.1"
def log_message(self, fmt: str, *args) -> None:
cfg = CONFIG
if cfg and cfg.verbose:
super().log_message(fmt, *args)
def do_GET(self) -> None:
self._handle_proxy()
def do_HEAD(self) -> None:
self._handle_proxy()
def do_OPTIONS(self) -> None:
self._handle_proxy()
def do_POST(self) -> None:
self._send_text(405, "method not allowed: this POC is pull-only\n")
def do_PUT(self) -> None:
self._send_text(405, "method not allowed: this POC is pull-only\n")
def do_PATCH(self) -> None:
self._send_text(405, "method not allowed: this POC is pull-only\n")
def do_DELETE(self) -> None:
self._send_text(405, "method not allowed: this POC is pull-only\n")
def _handle_proxy(self) -> None:
cfg = CONFIG
assert cfg is not None
client_ip = get_client_ip(self)
if not is_client_allowed(client_ip):
self.send_error(403, "forbidden")
return
if is_ip_banned(client_ip):
self.send_error(403, "temporarily banned")
return
host_header = self.headers.get("Host", "")
parsed_host = parse_safe_host_header(host_header)
if parsed_host is None:
self._send_text(400, "bad host header\n")
return
public_host, public_netloc = parsed_host
parsed = urlsplit(self.path)
pull_log_key: Optional[Tuple[str, str, str]] = None
pull_log_is_blob = False
if public_host == cfg.base_domain:
if cfg.serve_base_domain or cfg.web_root:
self._handle_base_domain(send_body=(self.command != "HEAD"))
else:
self._send_text(404, "base domain service is disabled; use --serve-base-domain or --web-root\n")
return
if not is_allowed_oci_path(parsed.path):
self._send_text(404, "not found: this endpoint only proxies OCI Registry /v2/* pulls\n")
return
if not is_allowed_pull_method(self.command, parsed.path):
self._send_text(405, "method not allowed: this POC is pull-only\n")
return
try:
if parsed.path.startswith("/__oci_auth/"):
if public_host not in cfg.upstream_by_host:
self._send_text(404, "unknown mirror host\n")
return
relay_token = parsed.path[len("/__oci_auth/") :].split("/", 1)[0]
upstream_realm = verify_auth_relay_token(relay_token)
if upstream_realm is None:
self._send_text(404, "not found\n")
return
auth_query = strip_path_token_from_scope_query(parsed.query)
target_url = merge_query(upstream_realm, auth_query)
else:
upstream = cfg.upstream_by_host.get(public_host)
if not upstream:
known = ", ".join(sorted(cfg.upstream_by_host.keys()))
self._send_text(404, f"unknown mirror host: {public_host}\nknown hosts: {known}\n")
return
upstream_path = strip_path_token_for_upstream(parsed.path, client_ip)
if upstream_path is None:
self._send_text(404, "not found\n")
return
manifest_info = parse_manifest_request_path(upstream_path)
if manifest_info is not None:
repo, ref = manifest_info
pull_log_key = PULL_LOG.start_manifest(client_ip, public_host, repo, ref)
pull_log_is_blob = False
else:
blob_info = parse_blob_request_path(upstream_path)
if blob_info is not None:
repo, digest = blob_info
pull_log_key = PULL_LOG.start_blob(client_ip, public_host, repo, digest)
pull_log_is_blob = True
upstream_uri = urlunsplit(("", "", upstream_path, parsed.query, ""))
target_url = upstream.registry_base.rstrip("/") + upstream_uri
if cfg.verbose:
log(f"{client_ip} {self.command} {public_host}{self.path} -> {target_url}")
stats = StreamStats()
self._forward_streaming(target_url, public_netloc, client_ip, stats=stats)
if pull_log_key is not None:
if pull_log_is_blob:
PULL_LOG.finish_blob(pull_log_key, stats)
else:
PULL_LOG.finish_manifest(pull_log_key, stats.response_status)
except BrokenPipeError:
if pull_log_key is not None:
if pull_log_is_blob:
PULL_LOG.fail_request(pull_log_key, stats if 'stats' in locals() else None)
else:
PULL_LOG.finish_manifest(pull_log_key, 499)
return
except Exception as e:
if pull_log_key is not None:
if pull_log_is_blob:
PULL_LOG.fail_request(pull_log_key, stats if 'stats' in locals() else None)
else:
PULL_LOG.finish_manifest(pull_log_key, 502)
self._send_text(502, f"proxy error: {type(e).__name__}: {e}\n")
def _handle_base_domain(self, send_body: bool) -> None:
cfg = CONFIG
assert cfg is not None
if self.command not in {"GET", "HEAD", "OPTIONS"}:
self._send_text(405, "method not allowed\n")
return
if self.command == "OPTIONS":
self.send_response(204)
self.send_header("Content-Length", "0")
self.end_headers()
return
if cfg.web_root:
fp = safe_static_file_for_request(cfg.web_root, self.path)
if fp is None:
self._send_text(404, "not found\n")
return
ctype = mimetypes.guess_type(str(fp))[0] or "application/octet-stream"
try:
size = fp.stat().st_size
self.send_response(200)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(size))
self.end_headers()
if send_body:
with fp.open("rb") as f:
shutil.copyfileobj(f, self.wfile, COPY_CHUNK_SIZE)
return
except OSError:
self._send_text(404, "not found\n")
return
data = build_default_base_page(get_client_ip(self))
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if send_body:
self.wfile.write(data)
def _send_text(self, status: int, text: str) -> None:
data = text.encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
if self.command != "HEAD":
self.wfile.write(data)
def _read_small_body(self) -> Optional[bytes]:
length = self.headers.get("Content-Length")
if not length:
return None
n = int(length)
if n > 16 * 1024 * 1024:
raise ValueError("request body too large for this pull-only POC")
return self.rfile.read(n)
def _forward_streaming(self, target_url: str, public_netloc: str, client_ip: str, stats: Optional[StreamStats] = None) -> None:
cfg = CONFIG
assert cfg is not None
method = self.command
body = None if method in ("GET", "HEAD", "OPTIONS") else self._read_small_body()
redirects = 0
current_url = target_url
drop_auth = False
while True:
p = urlsplit(current_url)
if p.scheme not in ("http", "https") or not p.netloc:
raise ValueError(f"bad upstream URL: {current_url}")
headers = copy_request_headers(self.headers, p.netloc, drop_authorization=drop_auth)
ensure_manifest_accept(headers, p.path or "/")
add_forwarded_for(headers, client_ip)
path = urlunsplit(("", "", p.path or "/", p.query, ""))
conn_cls = http.client.HTTPSConnection if p.scheme == "https" else http.client.HTTPConnection
conn = conn_cls(p.hostname, port=p.port, timeout=cfg.upstream_timeout)
try:
conn.request(method, path, body=body, headers=headers)
resp = conn.getresponse()
if stats is not None:
stats.response_status = resp.status
if resp.status in (301, 302, 303, 307, 308) and resp.getheader("Location"):
if redirects >= MAX_REDIRECTS:
raise RuntimeError("too many upstream redirects")
location = resp.getheader("Location")
resp.close()
conn.close()
new_url = urljoin(current_url, location)
old_host = urlsplit(current_url).netloc.lower()
new_host = urlsplit(new_url).netloc.lower()
if new_host != old_host:
drop_auth = True
current_url = new_url
redirects += 1
continue
self.send_response(resp.status, resp.reason)
has_content_length = False
for k, v in resp.getheaders():
lk = k.lower()
if lk in HOP_BY_HOP_HEADERS:
continue
if lk == "www-authenticate":
v = rewrite_www_authenticate(v, public_netloc)
if lk == "content-length":
has_content_length = True
self.send_header(k, v)
if not has_content_length:
self.close_connection = True
self.end_headers()
if method != "HEAD":
while True:
chunk = resp.read(COPY_CHUNK_SIZE)
if not chunk:
break
if stats is not None:
stats.body_bytes += len(chunk)
self.wfile.write(chunk)
resp.close()
conn.close()
return
except Exception:
conn.close()
raise
def parse_upstreams(items: Iterable[str], base_domain: str) -> Dict[str, Upstream]:
mapping = dict(DEFAULT_UPSTREAMS)
for item in items:
if "=" not in item:
die(f"bad --upstream {item!r}; expected alias=https://registry.example.com")
alias_raw, url_raw = item.split("=", 1)
alias = validate_upstream_alias(alias_raw)
url = url_raw.strip().rstrip("/")
p = urlsplit(url)
if p.scheme not in ("http", "https") or not p.netloc or p.query or p.fragment:
die(f"bad upstream URL {url!r}; expected http(s)://registry.example.com")
mapping[alias] = url
result: Dict[str, Upstream] = {}
for alias, url in mapping.items():
public_host = f"{alias}.{base_domain}".lower()
result[public_host] = Upstream(alias=alias, public_host=public_host, registry_base=url)
return result
def run_certbot(cmd: List[str]) -> None:
log("running: " + " ".join(shlex.quote(x) for x in cmd))
subprocess.run(cmd, check=True)
def is_staging_cert(cert_file: str, cert_name: str) -> bool:
markers = ("STAGING", "Fake LE", "Fake ISRG", "Pebble")
try:
decoded = ssl._ssl._test_decode_cert(cert_file) # type: ignore[attr-defined]
issuer_parts = []
for rdn in decoded.get("issuer", ()): # tuple(tuple(key, value))
for k, v in rdn:
issuer_parts.append(f"{k}={v}")
issuer = ", ".join(issuer_parts)
if any(m.lower() in issuer.lower() for m in markers):
return True
except Exception:
pass
renewal_conf = f"/etc/letsencrypt/renewal/{cert_name}.conf"
try:
with open(renewal_conf, "r", encoding="utf-8", errors="ignore") as f:
data = f.read().lower()
if "acme-staging" in data or "--staging" in data or "test-cert" in data:
return True
except FileNotFoundError:
pass
except Exception:
pass
return False
def delete_certbot_cert(cert_name: str) -> None:
run_certbot(["certbot", "delete", "--cert-name", cert_name, "--non-interactive"])
def ensure_certbot_cert(args: argparse.Namespace, domains: List[str], cert_name: str, cert_file: str) -> None:
if shutil.which("certbot") is None:
die("certbot not found; install it first, e.g. apt install certbot")
os.makedirs(args.acme_webroot, mode=0o755, exist_ok=True)
if os.path.exists(cert_file) and not args.staging and is_staging_cert(cert_file, cert_name):
log(f"existing certificate {cert_name!r} looks like a Let's Encrypt staging/test certificate; deleting before requesting production cert")
delete_certbot_cert(cert_name)
cmd = [
"certbot",
"certonly",
"--webroot",
"-w",
args.acme_webroot,
"--non-interactive",
"--agree-tos",
"--cert-name",
cert_name,
"--keep-until-expiring",
"--expand",
]
if args.email:
cmd.extend(["--email", args.email])
else:
cmd.append("--register-unsafely-without-email")
if args.staging:
cmd.append("--staging")
for d in domains:
cmd.extend(["-d", d])
run_certbot(cmd)
def renewal_loop(interval_seconds: int, cert_file: str, key_file: str) -> None:
global TLS_CONTEXT
while not STOP_EVENT.wait(interval_seconds):
try:
run_certbot(["certbot", "renew", "--quiet"])
if TLS_CONTEXT:
TLS_CONTEXT.load_cert_chain(certfile=cert_file, keyfile=key_file)
log("TLS certificate reloaded")
except Exception as e:
log(f"renewal failed: {type(e).__name__}: {e}")
def register_server(srv: ThreadingHTTPServer, label: str) -> ThreadingHTTPServer:
SERVERS.append(srv)
t = threading.Thread(target=srv.serve_forever, kwargs={"poll_interval": 0.5}, daemon=True, name=label)
SERVER_THREADS.append(t)
t.start()
return srv
def start_http_challenge_server(bind: str, port: int) -> ThreadingHTTPServer:
srv = ReuseThreadingHTTPServer((bind, port), ChallengeAndRedirectHandler)
register_server(srv, f"http-challenge-{port}")
log(f"HTTP challenge/redirect server listening on {bind}:{port}")
return srv
def start_http_proxy_server(bind: str, port: int) -> ThreadingHTTPServer:
srv = ReuseThreadingHTTPServer((bind, port), OCIProxyHandler)
register_server(srv, f"http-proxy-{port}")
log(f"HTTP OCI proxy listening on {bind}:{port}")
return srv
def start_https_proxy_server(bind: str, port: int, cert_file: str, key_file: str) -> ThreadingHTTPServer:
global TLS_CONTEXT
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.load_cert_chain(certfile=cert_file, keyfile=key_file)
TLS_CONTEXT = ctx
srv = ReuseThreadingHTTPServer((bind, port), OCIProxyHandler, ssl_context=ctx)
register_server(srv, f"https-proxy-{port}")
log(f"HTTPS OCI proxy listening on {bind}:{port}")
return srv
INSTALL_TARGET = Path("/opt/oci_stream_mirror.py")
SYSTEMD_DIR = Path("/etc/systemd/system")
def _require_root(action: str) -> None:
if os.geteuid() != 0:
raise SystemExit(f"[{action}] please run with sudo/root")
def _validate_service_name(service_name: str) -> str:
name = service_name.strip()
if not name:
raise SystemExit("[service] empty --service-name is not allowed")
if "/" in name or name.endswith(".service"):
raise SystemExit("[service] --service-name should be a bare name, e.g. oci-stream-mirror")
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", name):
raise SystemExit("[service] --service-name contains unsupported characters")
return name
def _strip_installer_args(argv: List[str]) -> List[str]:
out: List[str] = []
i = 0
while i < len(argv):
arg = argv[i]
if arg in {"--install", "--uninstall"}:
i += 1
continue
if arg == "--service-name":
i += 2
continue
if arg.startswith("--service-name="):
i += 1
continue
out.append(arg)
i += 1
return out
def _self_script_path() -> Path:
try:
src = Path(__file__).resolve()
except NameError:
raise SystemExit("[install] cannot locate current script path; run from a real .py file")
if not src.exists() or not src.is_file():
raise SystemExit(f"[install] current script is not a regular file: {src}")
return src
def _quote_exec_args(argv: List[str]) -> str:
return " ".join(shlex.quote(x) for x in argv)
def install_systemd_service(args: argparse.Namespace, original_argv: List[str]) -> None:
_require_root("install")
service_name = _validate_service_name(args.service_name)
src = _self_script_path()
dst = INSTALL_TARGET
unit_path = SYSTEMD_DIR / f"{service_name}.service"
runtime_argv = _strip_installer_args(original_argv)
python_bin = shutil.which("python3") or sys.executable or "/usr/bin/python3"
exec_start = f"{shlex.quote(python_bin)} {shlex.quote(str(dst))}"
if runtime_argv:
exec_start += " " + _quote_exec_args(runtime_argv)
print(f"[install] copying {src} -> {dst}")
dst.parent.mkdir(parents=True, exist_ok=True)
if src != dst:
shutil.copy2(src, dst)
dst.chmod(0o755)
unit = textwrap.dedent(f"""\
[Unit]
Description=OCI Streaming Mirror
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=root
WorkingDirectory=/opt
Environment=PYTHONUNBUFFERED=1
ExecStart={exec_start}
Restart=on-failure
RestartSec=3
KillSignal=SIGINT
TimeoutStopSec=20
LimitNOFILE=1048576
[Install]
WantedBy=multi-user.target
""")
print(f"[install] writing {unit_path}")
unit_path.write_text(unit, encoding="utf-8")
print("[install] running systemctl daemon-reload")
subprocess.run(["systemctl", "daemon-reload"], check=True)
print()
print("[install] installed successfully.")
print()
print("Start the service:")
print(f" sudo systemctl enable --now {service_name}")
print()
print("View logs:")
print(f" sudo journalctl -u {service_name} -f")
print()
print("Restart after editing arguments or script:")
print(f" sudo systemctl restart {service_name}")
print()
print("Uninstall:")
print(f" sudo python3 {dst} --uninstall --service-name {service_name}")
print()
print("Generated ExecStart:")
print(f" {exec_start}")
def uninstall_systemd_service(service_name_raw: str) -> None:
_require_root("uninstall")
service_name = _validate_service_name(service_name_raw)
unit_path = SYSTEMD_DIR / f"{service_name}.service"
print(f"[uninstall] stopping/disabling {service_name}.service if present")
subprocess.run(["systemctl", "disable", "--now", service_name], check=False)
if unit_path.exists():
print(f"[uninstall] removing {unit_path}")
unit_path.unlink()
else:
print(f"[uninstall] unit not found: {unit_path}")
if INSTALL_TARGET.exists():
print(f"[uninstall] removing {INSTALL_TARGET}")
INSTALL_TARGET.unlink()
else:
print(f"[uninstall] script not found: {INSTALL_TARGET}")
print("[uninstall] running systemctl daemon-reload")
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "reset-failed", service_name], check=False)
print("[uninstall] done")
def build_arg_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(description="No-cache streaming OCI/Docker registry reverse proxy POC")
p.add_argument("--install", action="store_true", help="install this script as a systemd service and exit")
p.add_argument("--uninstall", action="store_true", help="remove the systemd service and /opt/oci_stream_mirror.py, then exit")
p.add_argument("--service-name", default="oci-stream-mirror", help="systemd service name used by --install/--uninstall")
p.add_argument("--base-domain", help="base domain, e.g. mirrors.example.com")
p.add_argument("--email", help="email for Let's Encrypt / certbot; omitted => --register-unsafely-without-email")
p.add_argument("--bind", default="0.0.0.0")
p.add_argument("--http-port", type=int, default=80)
p.add_argument("--https-port", type=int, default=443)
p.add_argument("--http-only", action="store_true", help="serve OCI proxy as plain HTTP; use behind HTTPS reverse proxy or as an insecure registry")
p.add_argument("--external-scheme", choices=["https", "http"], default="https", help="scheme advertised in WWW-Authenticate realm; use http only for direct insecure-registry testing")
p.add_argument("--no-http-server", action="store_true", help="do not bind the built-in HTTP challenge/redirect server; use with --acme-webroot served by an existing port-80 webserver")
p.add_argument("--acme-webroot", default="/var/lib/oci-stream-mirror/acme")
p.add_argument("--cert-name", help="certbot certificate name; default is first configured host")
p.add_argument("--cert-file", help="use existing certificate file instead of /etc/letsencrypt/live/<cert-name>/fullchain.pem")
p.add_argument("--key-file", help="use existing private key file instead of /etc/letsencrypt/live/<cert-name>/privkey.pem")
p.add_argument("--skip-certbot", action="store_true", help="do not call certbot; use existing cert files")
p.add_argument("--staging", action="store_true", help="use Let's Encrypt staging CA for testing")
p.add_argument("--renew-loop", action="store_true", help="run certbot renew once per week and reload cert in memory")
p.add_argument("--allow-cidr", action="append", default=[], help="allow client CIDR; repeatable. Empty means allow all")
p.add_argument("--trust-forwarded-for", action="store_true", help="trust X-Forwarded-For/X-Real-IP from a local reverse proxy for allow-cidr and bans")
p.add_argument("--path-auth", metavar="TOKEN", help="enable path-token auth: ghcr.<base>/<token>/owner/image:tag")
p.add_argument("--serve-base-domain", action="store_true", help="serve an informational page on the base domain itself; requires DNS for the base domain")
p.add_argument("--web-root", help="serve this static directory on the base domain instead of the built-in informational page; implies --serve-base-domain")
p.add_argument("--ban-threshold", type=int, default=10, help="bad path-token attempts before temporary ban")
p.add_argument("--ban-seconds", type=int, default=3600, help="temporary ban duration after too many bad path-token attempts")
p.add_argument("--upstream", action="append", default=[], help="add/override upstream, e.g. --upstream ghcr=https://ghcr.io")
p.add_argument("--upstream-timeout", type=int, default=60, help="per upstream socket operation timeout in seconds")
p.add_argument("--client-timeout", type=int, default=300, help="per client socket read/write idle timeout in seconds")
p.add_argument("--tls-handshake-timeout", type=int, default=10, help="TLS handshake timeout in seconds; protects against handshake slowloris")
p.add_argument("--max-clients", type=int, default=256, help="maximum concurrent client handler threads per listening server")
p.add_argument("--pull-log-idle", type=float, default=DEFAULT_PULL_LOG_IDLE_SECONDS, help="seconds of pull inactivity before printing the PULL END summary")
p.add_argument("--verbose", action="store_true")
return p
def shutdown_all() -> None:
log("shutting down")
STOP_EVENT.set()
PULL_LOG.finalize_all()
shutdown_threads: List[threading.Thread] = []
for srv in list(SERVERS):
t = threading.Thread(target=srv.shutdown, daemon=True)
t.start()
shutdown_threads.append(t)
for t in shutdown_threads:
t.join(timeout=3.0)
for srv in list(SERVERS):
try:
srv.server_close()
except Exception:
pass
for t in list(SERVER_THREADS):
t.join(timeout=2.0)
def main() -> None:
global CONFIG
args = build_arg_parser().parse_args()
if args.install and args.uninstall:
die("--install and --uninstall cannot be used together")
if args.uninstall:
uninstall_systemd_service(args.service_name)
return
if not args.base_domain:
die("--base-domain is required unless --uninstall is used")
base_domain = validate_dns_name(args.base_domain, "--base-domain")
http_port = validate_port(args.http_port, "--http-port")
https_port = validate_port(args.https_port, "--https-port")
upstreams = parse_upstreams(args.upstream, base_domain)
serve_base_domain = bool(args.serve_base_domain or args.web_root)
domains = sorted(upstreams.keys())
if serve_base_domain and base_domain not in domains:
domains.append(base_domain)
domains.sort()
cert_name = validate_cert_name(args.cert_name) if args.cert_name else domains[0]
cert_file = args.cert_file or f"/etc/letsencrypt/live/{cert_name}/fullchain.pem"
key_file = args.key_file or f"/etc/letsencrypt/live/{cert_name}/privkey.pem"
allowed_cidrs = []
for item in args.allow_cidr:
try:
allowed_cidrs.append(ipaddress.ip_network(item, strict=False))
except ValueError as e:
die(f"bad --allow-cidr {item!r}: {e}")
web_root = None
if args.web_root:
web_root_path = Path(args.web_root).resolve()
if not web_root_path.exists() or not web_root_path.is_dir():
die("--web-root must point to an existing directory")
web_root = str(web_root_path)
path_auth_enabled = bool(args.path_auth)
path_token = args.path_auth
if path_token and not SAFE_TOKEN_RE.fullmatch(path_token):
die("--path-auth TOKEN must match [A-Za-z0-9._~-]{1,64}")
if args.ban_threshold <= 0:
die("--ban-threshold must be positive")
if args.ban_seconds <= 0:
die("--ban-seconds must be positive")
if args.upstream_timeout <= 0:
die("--upstream-timeout must be positive")
if args.client_timeout <= 0:
die("--client-timeout must be positive")
if args.tls_handshake_timeout <= 0:
die("--tls-handshake-timeout must be positive")
if args.max_clients <= 0:
die("--max-clients must be positive")
if args.pull_log_idle <= 0:
die("--pull-log-idle must be positive")
# --install is intentionally handled after validation, so invalid runtime
# arguments are not embedded into systemd.
if args.install:
install_systemd_service(args, sys.argv[1:])
return
CONFIG = AppConfig(
base_domain=base_domain,
bind=args.bind,
http_port=http_port,
https_port=https_port,
acme_webroot=args.acme_webroot,
cert_name=cert_name,
cert_file=cert_file,
key_file=key_file,
serve_base_domain=serve_base_domain,
web_root=web_root,
upstream_by_host=upstreams,
allowed_cidrs=allowed_cidrs,
upstream_timeout=args.upstream_timeout,
client_timeout=args.client_timeout,
tls_handshake_timeout=args.tls_handshake_timeout,
max_clients=args.max_clients,
pull_log_idle=args.pull_log_idle,
verbose=args.verbose,
http_only=args.http_only,
external_scheme=args.external_scheme,
trust_forwarded_for=args.trust_forwarded_for,
path_auth_enabled=path_auth_enabled,
path_token=path_token,
auth_relay_secret=secrets.token_bytes(32),
ban_threshold=args.ban_threshold,
ban_seconds=args.ban_seconds,
)
log("configured mirror hosts:")
for host, up in sorted(upstreams.items()):
log(f" {host} -> {up.registry_base}")
if path_auth_enabled:
log(f"path-token auth enabled; use image names like ghcr.{base_domain}/<TOKEN>/owner/image:tag")
log(f"bad path-token attempts: ban after {args.ban_threshold}, duration {args.ban_seconds}s")
if serve_base_domain:
log(f"base-domain page enabled on {base_domain}; ensure DNS has both {base_domain} and *.{base_domain} pointing here")
if web_root:
log(f"serving base-domain static files from {web_root}")
if allowed_cidrs:
log("allowed CIDRs: " + ", ".join(str(x) for x in allowed_cidrs))
log(f"timeouts: upstream={args.upstream_timeout}s, client_idle={args.client_timeout}s, tls_handshake={args.tls_handshake_timeout}s, max_clients={args.max_clients}, pull_log_idle={args.pull_log_idle}s")
if args.trust_forwarded_for:
log("trusting X-Forwarded-For/X-Real-IP headers")
def _on_signal(signum, frame):
STOP_EVENT.set()
signal.signal(signal.SIGTERM, _on_signal)
signal.signal(signal.SIGINT, _on_signal)
if args.http_only:
if not args.skip_certbot:
log("--http-only: skipping built-in certbot/TLS; terminate HTTPS in your reverse proxy or configure Docker insecure-registries for direct HTTP")
start_http_proxy_server(args.bind, http_port)
else:
if not args.no_http_server:
start_http_challenge_server(args.bind, http_port)
else:
log("built-in HTTP challenge/redirect server disabled; certbot webroot must be served by an existing external port-80 webserver")
if not args.skip_certbot:
ensure_certbot_cert(args, domains, cert_name, cert_file)
if not os.path.exists(cert_file) or not os.path.exists(key_file):
die(f"certificate files not found: {cert_file}, {key_file}")
start_https_proxy_server(args.bind, https_port, cert_file, key_file)
if args.renew_loop and not args.skip_certbot:
threading.Thread(
target=renewal_loop,
args=(7 * 24 * 3600, cert_file, key_file),
daemon=True,
name="certbot-renew-loop",
).start()
try:
while not STOP_EVENT.wait(3600):
pass
finally:
shutdown_all()
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment