#!/usr/bin/env python3
"""container-relay.py — a small, read-only local relay for the container dashboard on geekychris.github.io.

NOTE: the gitwebsite app (and `gw serve`) now has this built in — its *companion* gateway on
127.0.0.1:7317 relays the same things, and the tool uses it first when the app is running. This
script stays as the fallback for when the app is not running.

A web page cannot talk to Docker's unix socket, and the Kubernetes API sends no CORS headers and needs
credentials a page should never hold. This script runs on YOUR computer, listens on http://127.0.0.1:8098
(browsers treat localhost as a secure origin, so an https page may call it) and forwards READ-ONLY requests:

  /docker/...  -> the Docker Engine API on the local socket (auto-detected, or --docker-host)
  /k8s/...     -> a Kubernetes API: through `kubectl proxy` (--k8s-url), or directly with a kubeconfig
                  the relay reads (--kubeconfig, optional --context). It never changes your kubeconfig
                  or its current context; credentials stay in this process and never reach the page.
  /status      -> what the relay is connected to (no tokens, no certificates)

  python3 container-relay.py                                        # Docker only
  kubectl proxy --port 8001 --context my-cluster &                  # kubectl does auth, contexts, plugins
  python3 container-relay.py --k8s-url http://127.0.0.1:8001
  python3 container-relay.py --kubeconfig ~/.kube/config --context my-cluster

Safety:
  * GET only (and CORS preflight). Every other method is refused with 405: nothing is created, changed,
    started, stopped or deleted. Streaming reads (docker logs follow=1, docker stats, pod logs follow=true,
    watches) are passed through as they arrive.
  * Only an allow-list of read endpoints is forwarded. exec, attach, port-forward and the API server's
    proxy subresources are refused even over GET, as are Kubernetes Secrets and Docker endpoints that
    copy files out of containers or images (archive, export, images/get).
  * Requests go only to the Docker socket and the one Kubernetes API you configured: it is not an open
    proxy. Paths with '..', '//', backslashes or encoded slashes are refused.
  * Only these pages may read through it: https://geekychris.github.io and local previews
    (http://127.0.0.1:*, http://localhost:*). Add others with --allow-origin. A request from any other
    web page (it carries an Origin header) is refused; tools such as curl send none and are allowed.
  * The Host header must name this machine (defeats DNS rebinding). It binds to 127.0.0.1 unless you pass
    --host; do not expose it to a network.
Standard library only (Python 3.8+).
"""
import argparse, base64, http.client, json, os, re, signal, socket, ssl, stat, subprocess, sys, tempfile, threading, time, urllib.parse
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

VERSION = '1.0'
ALLOWED = {'https://geekychris.github.io'}


def origin_ok(o):
    return o in ALLOWED or o.startswith(('http://127.0.0.1:', 'http://localhost:', 'http://[::1]:')) or o in ('http://127.0.0.1', 'http://localhost')


# ---------------------------------------------------------------------------------------------- Docker
DOCKER_RE = [re.compile(p) for p in (
    r'^/_ping$', r'^/version$', r'^/info$', r'^/system/df$', r'^/events$',
    r'^/containers/json$', r'^/containers/[\w.-]+/(json|logs|stats|top|changes)$',
    r'^/images/json$', r'^/images/[\w.:@/-]+/(json|history)$',
    r'^/volumes$', r'^/volumes/[\w.-]+$', r'^/networks$', r'^/networks/[\w.-]+$',
)]
DOCKER_VER = re.compile(r'^/v\d+\.\d+(?=/)')


def docker_path_ok(path):
    p = DOCKER_VER.sub('', path)
    return any(r.match(p) for r in DOCKER_RE)


def find_docker_socket(explicit=None):
    """--docker-host, then $DOCKER_HOST (unix://), then the usual places (Docker Desktop, Rancher Desktop, Colima, OrbStack)."""
    if explicit:
        return explicit
    env = os.environ.get('DOCKER_HOST', '')
    if env.startswith('unix://'):
        return env
    home = os.path.expanduser('~')
    for p in ('/var/run/docker.sock', home + '/.rd/docker.sock', home + '/.docker/run/docker.sock',
              home + '/.colima/default/docker.sock', home + '/.orbstack/run/docker.sock'):
        try:
            if stat.S_ISSOCK(os.stat(p).st_mode):
                return 'unix://' + p
        except OSError:
            pass
    return None


class UnixHTTPConnection(http.client.HTTPConnection):
    def __init__(self, path, timeout=None):
        super().__init__('localhost', timeout=timeout)
        self.unix_path = path

    def connect(self):
        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        s.settimeout(self.timeout)
        s.connect(self.unix_path)
        self.sock = s


def docker_conn(host, timeout):
    if host.startswith('unix://'):
        return UnixHTTPConnection(host[7:], timeout=timeout), 'localhost'
    if host.startswith(('tcp://', 'http://')):
        u = urllib.parse.urlsplit('http://' + host.split('://', 1)[1])
        return http.client.HTTPConnection(u.hostname, u.port or 2375, timeout=timeout), u.netloc
    raise ValueError('unsupported --docker-host (use unix:///path or tcp://host:port): ' + host)


# ---------------------------------------------------------------------------------------------- Kubernetes
K8S_BLOCKED_SUB = {'exec', 'attach', 'portforward', 'proxy'}


def k8s_path_ok(path):
    """Read-only Kubernetes API paths: discovery, version/health, and resources — not exec/attach/portforward/proxy, not Secrets."""
    if re.match(r'^/(version|healthz|readyz|livez)/?$', path) or path in ('/api', '/apis', '/api/', '/apis/'):
        return True
    if re.match(r'^/openapi/v[23](/[\w./-]*)?$', path):
        return True
    m = re.match(r'^/api/v1(/.*)?$', path) or re.match(r'^/apis/[\w.-]+/[\w.-]+(/.*)?$', path)
    if not m:
        return bool(re.match(r'^/apis/[\w.-]+/?$', path))
    segs = [s for s in (m.group(1) or '').split('/') if s]
    if segs and segs[0] == 'watch':      # legacy /watch/ prefix
        segs = segs[1:]
    if len(segs) >= 3 and segs[0] == 'namespaces':
        segs = segs[2:]                   # namespaced: <resource>/<name>/<subresource>
    elif len(segs) == 2 and segs[0] == 'namespaces':
        return True                       # a namespace object itself
    if not segs:
        return True
    if segs[0] == 'secrets':
        return False
    if len(segs) >= 3 and segs[2] in K8S_BLOCKED_SUB:
        return False
    return True


def parse_yaml(text):
    """A small YAML reader, enough for kubeconfig files (block maps and sequences, plain/quoted scalars)."""
    text = text.lstrip('﻿')
    try:
        return json.loads(text)
    except ValueError:
        pass
    lines = []
    for raw in text.splitlines():
        s = _strip_comment(raw.rstrip())
        if not s.strip() or s.strip() in ('---', '...'):
            continue
        lines.append([len(s) - len(s.lstrip(' ')), s.strip()])
    pos = [0]

    def is_item(t):
        return t == '-' or t.startswith('- ')

    def block(ind):
        if pos[0] >= len(lines):
            return None
        i, t = lines[pos[0]]
        return seq(i) if is_item(t) else mapping(i)

    def seq(ind):
        out = []
        while pos[0] < len(lines) and lines[pos[0]][0] == ind and is_item(lines[pos[0]][1]):
            t = lines[pos[0]][1]
            rest = t[1:].lstrip()
            if not rest:
                pos[0] += 1
                out.append(block(ind + 1) if pos[0] < len(lines) and lines[pos[0]][0] > ind else None)
                continue
            if _split_key(rest):
                lines[pos[0]] = [ind + len(t) - len(rest), rest]
                out.append(mapping(lines[pos[0]][0]))
            else:
                pos[0] += 1
                out.append(_scalar(rest))
        return out

    def mapping(ind):
        out = {}
        while pos[0] < len(lines) and lines[pos[0]][0] == ind and not is_item(lines[pos[0]][1]):
            kv = _split_key(lines[pos[0]][1])
            if not kv:
                raise ValueError('cannot read line: ' + lines[pos[0]][1])
            key, rest = kv
            pos[0] += 1
            if rest in ('|', '|-', '>', '>-'):
                buf = []
                while pos[0] < len(lines) and lines[pos[0]][0] > ind:
                    buf.append(lines[pos[0]][1]); pos[0] += 1
                out[key] = ('\n' if rest[0] == '|' else ' ').join(buf)
            elif rest == '':
                nxt = lines[pos[0]] if pos[0] < len(lines) else None
                if nxt and (nxt[0] > ind or (nxt[0] == ind and is_item(nxt[1]))):
                    out[key] = block(nxt[0])
                else:
                    out[key] = None
            else:
                out[key] = _scalar(rest)
        return out

    return block(0) or {}


def _strip_comment(s):
    q = None
    for i, c in enumerate(s):
        if q:
            if c == q:
                q = None
        elif c in '"\'':
            q = c
        elif c == '#' and (i == 0 or s[i - 1] in ' \t'):
            return s[:i].rstrip()
    return s


def _split_key(t):
    if t[0] in '"\'':
        end = t.find(t[0], 1)
        if end > 0 and t[end + 1:end + 2] == ':' and (len(t) == end + 2 or t[end + 2] == ' '):
            return t[1:end], t[end + 2:].strip()
        return None
    m = re.match(r'^([^\s:{}\[\],][^:]*?):(?:\s+(.*)|$)', t)
    return (m.group(1), (m.group(2) or '').strip()) if m else None


def _scalar(v):
    if v[:1] == '"':
        return json.loads(v)
    if v[:1] == "'":
        return v[1:-1].replace("''", "'")
    if v == '{}':
        return {}
    if v == '[]':
        return []
    if v[:1] == '{' and v[-1:] == '}':   # simple flow map: {cluster: lab, user: dev}
        out = {}
        for part in v[1:-1].split(','):
            kv = _split_key(part.strip())
            if kv:
                out[kv[0]] = _scalar(kv[1]) if kv[1] else None
        return out
    if v[:1] == '[' and v[-1:] == ']':
        return [_scalar(x.strip()) for x in v[1:-1].split(',') if x.strip()]
    if v in ('true', 'True'):
        return True
    if v in ('false', 'False'):
        return False
    if v in ('null', '~'):
        return None
    if re.match(r'^-?\d+$', v):
        return int(v)
    return v


class Kube:
    """A Kubernetes API endpoint: kubectl proxy (no auth) or a context from a kubeconfig (read once, never written)."""

    def __init__(self, url=None, kubeconfig=None, context=None):
        self.mode, self.context, self.auth, self.headers = None, None, 'none', {}
        self.ctx, self._tmp, self._exec, self._exec_tok = None, None, None, (None, 0)
        if url:
            self.mode, self.base = 'proxy', url.rstrip('/')
        elif kubeconfig:
            self.mode = 'kubeconfig'
            self._load(os.path.expanduser(kubeconfig), context)
        else:
            return
        u = urllib.parse.urlsplit(self.base)
        self.scheme, self.host, self.port, self.prefix = u.scheme, u.hostname, u.port or (443 if u.scheme == 'https' else 80), u.path.rstrip('/')

    def _load(self, path, context):
        with open(path) as f:
            cfg = parse_yaml(f.read())
        name = context or cfg.get('current-context')
        if not name:
            raise SystemExit(f'{path}: no current-context; pass --context')
        find = lambda key, n: next((x for x in cfg.get(key) or [] if x.get('name') == n), None)
        c = find('contexts', name)
        if not c:
            raise SystemExit(f'{path}: no context named {name!r}')
        self.context = name
        cl = (find('clusters', c['context'].get('cluster')) or {}).get('cluster') or {}
        us = (find('users', c['context'].get('user')) or {}).get('user') or {}
        self.namespace = c['context'].get('namespace')
        self.base = cl.get('server') or ''
        if not self.base:
            raise SystemExit(f'{path}: context {name!r} has no cluster server')
        here = os.path.dirname(os.path.abspath(path))
        rel = lambda p: p if os.path.isabs(p) else os.path.join(here, p)
        self._tmp = tempfile.mkdtemp(prefix='container-relay-')
        os.chmod(self._tmp, 0o700)

        def data_file(b64, nm):
            p = os.path.join(self._tmp, nm)
            fd = os.open(p, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
            with os.fdopen(fd, 'wb') as f:
                f.write(base64.b64decode(b64))
            return p

        ctx = ssl.create_default_context()
        if cl.get('certificate-authority-data'):
            ctx.load_verify_locations(cadata=base64.b64decode(cl['certificate-authority-data']).decode())
        elif cl.get('certificate-authority'):
            ctx.load_verify_locations(rel(cl['certificate-authority']))
        if cl.get('insecure-skip-tls-verify'):
            ctx.check_hostname, ctx.verify_mode = False, ssl.CERT_NONE
        self.server_name = cl.get('tls-server-name')
        cert = data_file(us['client-certificate-data'], 'c.pem') if us.get('client-certificate-data') else (rel(us['client-certificate']) if us.get('client-certificate') else None)
        key = data_file(us['client-key-data'], 'k.pem') if us.get('client-key-data') else (rel(us['client-key']) if us.get('client-key') else None)
        if cert and key:
            ctx.load_cert_chain(cert, key)
            self.auth = 'client certificate'
        if us.get('token'):
            self.headers['Authorization'] = 'Bearer ' + us['token']; self.auth = 'bearer token'
        elif us.get('tokenFile'):
            self._token_file = rel(us['tokenFile']); self.auth = 'token file'
        elif us.get('username') is not None and us.get('password') is not None:
            self.headers['Authorization'] = 'Basic ' + base64.b64encode(f"{us['username']}:{us['password']}".encode()).decode(); self.auth = 'basic'
        elif us.get('exec'):
            self._exec = us['exec']; self.auth = 'exec plugin (' + os.path.basename(str(us['exec'].get('command'))) + ')'
        elif us.get('auth-provider'):
            raise SystemExit(f'context {name!r} uses an auth-provider; run `kubectl proxy --context {name}` and pass --k8s-url instead')
        self.ctx = ctx

    def _auth_headers(self):
        h = dict(self.headers)
        if getattr(self, '_token_file', None):
            with open(self._token_file) as f:
                h['Authorization'] = 'Bearer ' + f.read().strip()
        if self._exec:
            tok, exp = self._exec_tok
            if not tok or time.time() > exp - 60:
                e = self._exec
                env = dict(os.environ, **{x['name']: x['value'] for x in e.get('env') or []})
                env['KUBERNETES_EXEC_INFO'] = json.dumps({'apiVersion': e.get('apiVersion'), 'kind': 'ExecCredential', 'spec': {'interactive': False}})
                out = subprocess.run([e['command']] + list(e.get('args') or []), env=env, capture_output=True, timeout=60, check=True).stdout
                st = json.loads(out).get('status') or {}
                tok = st.get('token')
                exp = time.time() + 300
                if st.get('expirationTimestamp'):
                    try:
                        exp = time.mktime(time.strptime(st['expirationTimestamp'][:19], '%Y-%m-%dT%H:%M:%S')) - time.timezone
                    except ValueError:
                        pass
                self._exec_tok = (tok, exp)
            if tok:
                h['Authorization'] = 'Bearer ' + tok
        return h

    def conn(self, timeout):
        if self.scheme != 'https':
            return http.client.HTTPConnection(self.host, self.port, timeout=timeout)
        ctx, name, host, port = self.ctx or ssl.create_default_context(), getattr(self, 'server_name', None), self.host, self.port

        class Conn(http.client.HTTPSConnection):
            def connect(c):   # honour tls-server-name from the kubeconfig
                sock = socket.create_connection((host, port), timeout)
                c.sock = ctx.wrap_socket(sock, server_hostname=name or host)
        return Conn(host, port, timeout=timeout, context=ctx)

    def describe(self):
        if not self.mode:
            return None
        d = {'mode': self.mode, 'server': f'{self.scheme}://{self.host}:{self.port}{self.prefix}', 'auth': self.auth}
        if self.context:
            d['context'] = self.context
            d['namespace'] = self.namespace
        return d

    def cleanup(self):
        if self._tmp:
            for f in os.listdir(self._tmp):
                try:
                    os.remove(os.path.join(self._tmp, f))
                except OSError:
                    pass
            try:
                os.rmdir(self._tmp)
            except OSError:
                pass


# ---------------------------------------------------------------------------------------------- HTTP
BAD_PATH = re.compile(r'(\.\.|//|\\|%2f|%5c|%2e|%00|[\x00-\x1f])', re.I)


class Relay(BaseHTTPRequestHandler):
    server_version = 'container-relay/' + VERSION
    protocol_version = 'HTTP/1.1'

    def log_message(self, fmt, *args):
        if not self.server.quiet:
            sys.stderr.write('%s %s\n' % (time.strftime('%H:%M:%S'), fmt % args))

    def cors(self):
        o = self.headers.get('Origin', '')
        if o and origin_ok(o):
            self.send_header('Access-Control-Allow-Origin', o)
            self.send_header('Vary', 'Origin')
            self.send_header('Access-Control-Expose-Headers', 'X-Relay-Upstream, Api-Version, Docker-Experimental')

    def reply(self, code, body, ctype='application/json'):
        data = (body if isinstance(body, (bytes, str)) else json.dumps(body))
        data = data if isinstance(data, bytes) else data.encode()
        self.send_response(code)
        self.cors()
        self.send_header('Content-Type', ctype)
        self.send_header('Cache-Control', 'no-store')
        self.send_header('Content-Length', str(len(data)))
        self.end_headers()
        if self.command != 'HEAD':
            self.wfile.write(data)

    def gate(self):
        """Host and Origin checks shared by every method. Returns True when the request may go on."""
        host = (self.headers.get('Host') or '').lower()
        hn = host.rsplit(':', 1)[0] if not host.startswith('[') else host.split(']')[0] + ']'
        if hn not in self.server.hostnames:
            self.reply(403, {'error': f'host {host!r} not allowed'})
            return False
        o = self.headers.get('Origin')
        if o and not origin_ok(o):
            self.reply(403, {'error': f'origin {o} not allowed (see --allow-origin)'})
            return False
        return True

    def do_OPTIONS(self):
        if not self.gate():
            return
        self.send_response(204)
        self.cors()
        self.send_header('Access-Control-Allow-Methods', 'GET, OPTIONS')
        self.send_header('Access-Control-Allow-Headers', 'Accept, Content-Type')
        self.send_header('Access-Control-Allow-Private-Network', 'true')
        self.send_header('Access-Control-Max-Age', '600')
        self.send_header('Content-Length', '0')
        self.end_headers()

    def refuse_method(self):
        if self.gate():
            self.send_response(405)
            self.cors()
            self.send_header('Allow', 'GET, OPTIONS')
            body = json.dumps({'error': f'{self.command} refused: this relay is read-only (GET only)'}).encode()
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            if self.command != 'HEAD':
                self.wfile.write(body)
    do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_CONNECT = do_TRACE = refuse_method

    def do_GET(self):
        if not self.gate():
            return
        if self.headers.get('Upgrade'):
            return self.reply(400, {'error': 'WebSocket / upgrade requests are not relayed (no exec, attach or port-forward)'})
        raw = self.path
        if not raw.startswith('/') or raw.startswith('//'):
            return self.reply(400, {'error': 'bad path'})
        path, _, query = raw.partition('?')
        if BAD_PATH.search(path):
            return self.reply(400, {'error': 'path refused'})
        first, _, rest = path.lstrip('/').partition('/')
        rest = '/' + rest
        if first in ('', 'status'):
            return self.reply(200, self.status())
        if first == 'docker':
            if not self.server.docker:
                return self.reply(503, {'error': 'no Docker socket found (pass --docker-host)'})
            if not docker_path_ok(rest):
                return self.reply(403, {'error': f'not a read-only Docker endpoint this relay forwards: {rest}'})
            return self.forward('docker', rest, query)
        if first == 'k8s':
            if not self.server.kube.mode:
                return self.reply(503, {'error': 'no Kubernetes API configured (pass --k8s-url or --kubeconfig)'})
            if not k8s_path_ok(rest):
                return self.reply(403, {'error': f'not a read-only Kubernetes path this relay forwards: {rest}'})
            return self.forward('k8s', rest, query)
        self.reply(404, {'error': 'unknown route', 'routes': ['/status', '/docker/...', '/k8s/...']})

    def status(self):
        d = {'relay': 'container-relay', 'version': VERSION, 'readOnly': True, 'docker': None, 'k8s': self.server.kube.describe()}
        if self.server.docker:
            d['docker'] = {'host': self.server.docker}
            try:
                c, h = docker_conn(self.server.docker, 3)
                c.request('GET', '/_ping', headers={'Host': h})
                r = c.getresponse(); r.read()
                d['docker'].update(ok=r.status == 200, apiVersion=r.getheader('Api-Version'), os=r.getheader('Ostype'))
                c.close()
            except Exception as e:
                d['docker'].update(ok=False, error=f'{type(e).__name__}: {e}')
        return d

    def forward(self, which, path, query):
        follow = bool(re.search(r'(^|&)(follow=(1|true)|watch=(1|true)|stream=(1|true))(&|$)', query)) or (which == 'docker' and path.endswith('/stats') and 'stream=0' not in query and 'stream=false' not in query) or path.endswith('/events')
        timeout = None if follow else 30
        try:
            if which == 'docker':
                c, host = docker_conn(self.server.docker, timeout)
                headers = {'Host': host, 'Accept': self.headers.get('Accept', '*/*')}
                target = path
            else:
                k = self.server.kube
                c = k.conn(timeout)
                headers = {'Accept': self.headers.get('Accept', 'application/json'), 'User-Agent': 'container-relay/' + VERSION}
                if k.mode == 'kubeconfig':
                    headers.update(k._auth_headers())
                target = k.prefix + path
            c.request('GET', target + ('?' + query if query else ''), headers=headers)
            r = c.getresponse()
        except Exception as e:
            return self.reply(502, {'error': f'{type(e).__name__}: {e}', 'upstream': which})
        self.send_response(r.status)
        self.cors()
        self.send_header('Content-Type', r.getheader('Content-Type', 'application/octet-stream'))
        self.send_header('Cache-Control', 'no-store')
        self.send_header('X-Relay-Upstream', which)
        if r.getheader('Api-Version'):
            self.send_header('Api-Version', r.getheader('Api-Version'))
        length = r.getheader('Content-Length')
        chunked = length is None
        self.send_header('Transfer-Encoding', 'chunked') if chunked else self.send_header('Content-Length', length)
        self.end_headers()
        try:
            while True:
                data = r.read1(65536) if hasattr(r, 'read1') else r.read(4096)
                if not data:
                    break
                if chunked:
                    self.wfile.write(b'%x\r\n%s\r\n' % (len(data), data))
                else:
                    self.wfile.write(data)
                self.wfile.flush()
            if chunked:
                self.wfile.write(b'0\r\n\r\n')
        except (BrokenPipeError, ConnectionResetError, socket.timeout, OSError):
            self.close_connection = True
        finally:
            c.close()


def main(argv=None):
    p = argparse.ArgumentParser(description='Read-only local relay for the geekychris.github.io container dashboard (Docker and Kubernetes).')
    p.add_argument('--port', type=int, default=8098)
    p.add_argument('--host', default='127.0.0.1', help='address to listen on (default 127.0.0.1 — keep it local)')
    p.add_argument('--docker-host', help='unix:///path/to/docker.sock or tcp://host:2375 (default: auto-detect)')
    p.add_argument('--no-docker', action='store_true', help='do not forward to Docker')
    p.add_argument('--k8s-url', help='a `kubectl proxy` address, e.g. http://127.0.0.1:8001')
    p.add_argument('--kubeconfig', help='read this kubeconfig (it is never modified)')
    p.add_argument('--context', help='use this context from --kubeconfig instead of its current-context (the file is not changed)')
    p.add_argument('--allow-origin', action='append', default=[], help='another web origin allowed to use the relay')
    p.add_argument('--quiet', action='store_true')
    a = p.parse_args(argv)
    ALLOWED.update(a.allow_origin)
    if a.k8s_url and a.kubeconfig:
        p.error('use either --k8s-url or --kubeconfig')
    if a.context and not a.kubeconfig:
        p.error('--context needs --kubeconfig (with kubectl proxy, pass --context to kubectl)')
    kube = Kube(a.k8s_url, a.kubeconfig, a.context)
    srv = ThreadingHTTPServer((a.host, a.port), Relay)
    srv.daemon_threads = True
    srv.docker = None if a.no_docker else find_docker_socket(a.docker_host)
    srv.kube, srv.quiet = kube, a.quiet
    srv.hostnames = {'127.0.0.1', 'localhost', '[::1]', a.host.lower()}
    signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
    print(f'container-relay {VERSION} (read-only) on http://{a.host}:{srv.server_address[1]}/  (Ctrl+C to stop)', flush=True)
    print('  docker:', srv.docker or '-', flush=True)
    k = kube.describe()
    print('  kubernetes:', f"{k['server']} ({k['mode']}{', context ' + k['context'] if k.get('context') else ''}, {k['auth']})" if k else '-', flush=True)
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        pass
    finally:
        kube.cleanup()


if __name__ == '__main__':
    main()
