diff --git a/maxlab-gateway/data/.gitkeep b/maxlab-gateway/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/maxlab-gateway/docker-compose.yml b/maxlab-gateway/docker-compose.yml new file mode 100644 index 0000000..eb1368f --- /dev/null +++ b/maxlab-gateway/docker-compose.yml @@ -0,0 +1,52 @@ +version: "3.7" + +# Umbrel Lab Gateway - accesso esterno on-demand alle app (*.lab.solarpi.net) +services: + app_proxy: + environment: + # Apertura locale da Umbrel (umbrel.local:8898), protetta dal login Umbrel + APP_HOST: maxlab-gateway_net_1 + APP_PORT: 8080 + + net: + # Detiene la rete condivisa e pubblica la porta del gateway + image: gitea.solarpi.net/truetype/lab-gateway:0.2.0 + command: ["net"] + user: "1000:1000" + restart: on-failure + ports: + - "8899:8080" + + proxy: + image: caddy:2.10.2-alpine + command: ["caddy", "run"] + restart: on-failure + network_mode: "service:net" + + core: + image: gitea.solarpi.net/truetype/lab-gateway:0.2.0 + command: ["core"] + user: "1000:1000" + restart: on-failure + network_mode: "service:net" + volumes: + - ${APP_DATA_DIR}/data:/data + environment: + BASE_DOMAIN: lab.solarpi.net + TRUSTED_PROXIES: 10.6.133.122/32 + DEFAULT_TTL_MIN: "120" + MAX_TTL_MIN: "1440" + SELF_APP_ID: maxlab-gateway + + broker: + # Unico servizio con accesso a Docker e app-data, in sola lettura + image: gitea.solarpi.net/truetype/lab-gateway:0.2.0 + command: ["broker"] + user: "0:0" + restart: on-failure + network_mode: "service:net" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ${APP_DATA_DIR}/..:/umbrel-app-data:ro + environment: + SELF_APP_ID: maxlab-gateway diff --git a/maxlab-probe/icon.svg b/maxlab-gateway/icon.svg similarity index 100% rename from maxlab-probe/icon.svg rename to maxlab-gateway/icon.svg diff --git a/maxlab-gateway/umbrel-app.yml b/maxlab-gateway/umbrel-app.yml new file mode 100644 index 0000000..72d9c2a --- /dev/null +++ b/maxlab-gateway/umbrel-app.yml @@ -0,0 +1,27 @@ +manifestVersion: 1 +id: maxlab-gateway +name: Max Lab Gateway +tagline: Accesso esterno on-demand alle tue app su lab.solarpi.net +icon: https://gitea.solarpi.net/truetype/umbrel-maxlab-store/raw/branch/main/maxlab-gateway/icon.svg +category: networking +version: "0.2.0" +port: 8898 +description: >- + Accende e spegne su richiesta l'accesso da Internet alle app di Umbrel, + su https://.lab.solarpi.net, tramite Caddy sulla VPS e la rete + ZeroTier maxlan, senza aprire porte a casa. Ogni app accesa si spegne da + sola alla scadenza. Fase 2: API con token; login, dashboard e bot + Telegram arrivano nelle versioni successive. +developer: truetype +website: https://gitea.solarpi.net/truetype/lab-gateway +submitter: truetype +submission: https://gitea.solarpi.net/truetype/umbrel-maxlab-store +repo: https://gitea.solarpi.net/truetype/lab-gateway +support: https://gitea.solarpi.net/truetype/lab-gateway/issues +gallery: [] +releaseNotes: >- + 0.2.0 - Core, scoperta app, proxy interno e API (Fase 2). +dependencies: [] +path: "" +defaultUsername: "" +defaultPassword: "" diff --git a/maxlab-probe/docker-compose.yml b/maxlab-probe/docker-compose.yml deleted file mode 100644 index 005c833..0000000 --- a/maxlab-probe/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -version: "3.7" - -# Max Lab Probe - app di prova della Fase 0 (da disinstallare dopo il test) -services: - app_proxy: - environment: - # __1, come richiesto da Umbrel - APP_HOST: maxlab-probe_web_1 - APP_PORT: 8080 - PROXY_AUTH_ADD: "false" - - web: - image: python:3.12-alpine - restart: on-failure - command: ["python", "-u", "/probe/probe.py"] - ports: - # Verifica: porta pubblicata direttamente, senza app_proxy - - "8899:8080" - volumes: - - ${APP_DATA_DIR}/probe.py:/probe/probe.py:ro - # Verifica: socket Docker e app-data in sola lettura - - /var/run/docker.sock:/var/run/docker.sock:ro - - ${APP_DATA_DIR}/..:/umbrel-app-data:ro - - sidecar: - # Verifica: secondo servizio che condivide la rete di "web" - image: caddy:2-alpine - restart: on-failure - network_mode: "service:web" - command: ["caddy", "respond", "--listen", "127.0.0.1:9000", "sidecar ok"] diff --git a/maxlab-probe/probe.py b/maxlab-probe/probe.py deleted file mode 100644 index ada22bc..0000000 --- a/maxlab-probe/probe.py +++ /dev/null @@ -1,170 +0,0 @@ -"""Max Lab Probe - verifiche tecniche della Fase 0. - -Server HTTP minimale (solo libreria standard) che, a ogni richiesta, -esegue le verifiche e restituisce un report testuale: - 1. header ricevuti (nome host, X-Forwarded-*) - 2. accesso in sola lettura al socket Docker - 3. accesso in sola lettura a ~/umbrel/app-data e lettura dei manifest - 4. raggiungibilita' dei container delle altre app (saltando app_proxy) - 5. rete condivisa con il servizio "sidecar" (network_mode: service:web) -""" - -import http.client -import json -import os -import re -import socket -from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - -DOCKER_SOCK = "/var/run/docker.sock" -APP_DATA = "/umbrel-app-data" -SIDECAR = ("127.0.0.1", 9000) -LISTEN_PORT = 8080 - - -class UnixHTTPConnection(http.client.HTTPConnection): - def __init__(self, path, timeout=3): - 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_get(path): - conn = UnixHTTPConnection(DOCKER_SOCK) - try: - conn.request("GET", path) - resp = conn.getresponse() - return json.loads(resp.read()) - finally: - conn.close() - - -def http_status(host, port, path="/"): - try: - conn = http.client.HTTPConnection(host, int(port), timeout=2) - conn.request("GET", path, headers={"Host": f"{host}:{port}"}) - resp = conn.getresponse() - resp.read(256) - location = resp.getheader("Location") or "" - conn.close() - return f"HTTP {resp.status}" + (f" -> {location}" if location else "") - except Exception as exc: # noqa: BLE001 - riportiamo qualunque errore - return f"ERRORE {type(exc).__name__}: {exc}" - - -def read_text(path): - try: - with open(path, encoding="utf-8", errors="replace") as fh: - return fh.read() - except OSError: - return "" - - -def find(pattern, text): - m = re.search(pattern, text, re.MULTILINE) - return m.group(1).strip().strip("\"'") if m else None - - -def discover_apps(): - apps = [] - for app_id in sorted(os.listdir(APP_DATA)): - base = os.path.join(APP_DATA, app_id) - if not os.path.isdir(base): - continue - manifest = read_text(os.path.join(base, "umbrel-app.yml")) - compose = read_text(os.path.join(base, "docker-compose.yml")) - apps.append( - { - "id": app_id, - "name": find(r"^name:\s*(.+)$", manifest) or "?", - "port": find(r"^port:\s*(\d+)", manifest), - "app_host": find(r"APP_HOST[:=]\s*([^\s#]+)", compose), - "app_port": find(r"APP_PORT[:=]\s*([^\s#]+)", compose), - } - ) - return apps - - -def build_report(handler): - out = [] - ok = lambda cond: "OK " if cond else "FAIL" # noqa: E731 - - out.append("MAX LAB PROBE - verifiche Fase 0") - out.append("=" * 60) - - # 1. Header ricevuti - out.append("\n[1] Richiesta ricevuta") - out.append(f" client: {handler.client_address[0]}") - for name in ("Host", "X-Forwarded-For", "X-Forwarded-Proto", "X-Forwarded-Host"): - out.append(f" {name}: {handler.headers.get(name, '-')}") - - # 2. Socket Docker - out.append("\n[2] Socket Docker (sola lettura)") - containers = [] - try: - containers = docker_get("/containers/json") - out.append(f" {ok(True)} {len(containers)} container in esecuzione") - me = next( - (c for c in containers if c["Id"].startswith(socket.gethostname())), None - ) - if me: - nets = ", ".join(me["NetworkSettings"]["Networks"].keys()) - out.append(f" questo container: {me['Names'][0]} - reti: {nets}") - except Exception as exc: # noqa: BLE001 - out.append(f" {ok(False)} {type(exc).__name__}: {exc}") - - # 3. app-data - out.append("\n[3] Cartella app-data (sola lettura)") - apps = [] - try: - apps = discover_apps() - out.append(f" {ok(bool(apps))} {len(apps)} app trovate") - except Exception as exc: # noqa: BLE001 - out.append(f" {ok(False)} {type(exc).__name__}: {exc}") - - # 4. Raggiungibilita' container delle app, saltando app_proxy - out.append("\n[4] Container delle app raggiunti direttamente") - running = {n.lstrip("/") for c in containers for n in c.get("Names", [])} - for app in apps: - if not app["app_host"] or not app["app_port"]: - out.append(f" ---- {app['id']}: nessun app_proxy nel compose") - continue - host, port = app["app_host"], app["app_port"] - state = "attivo" if host in running else "non trovato tra i container" - result = http_status(host, port) - out.append( - f" {app['id']} ({app['name']}) porta UI {app['port']} -> " - f"{host}:{port} [{state}] {result}" - ) - - # 5. Sidecar con rete condivisa - out.append("\n[5] Servizio sidecar sulla stessa rete (127.0.0.1:9000)") - res = http_status(*SIDECAR) - out.append(f" {ok(res.startswith('HTTP 200'))} {res}") - - out.append("\nFine report. Disinstalla l'app dopo il test: elenca le tue app.") - return "\n".join(out) + "\n" - - -class Handler(BaseHTTPRequestHandler): - def do_GET(self): # noqa: N802 - nome imposto da BaseHTTPRequestHandler - body = build_report(self).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/plain; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(body) - - def log_message(self, fmt, *args): - print(f"{self.client_address[0]} {fmt % args}", flush=True) - - -if __name__ == "__main__": - print(f"Max Lab Probe in ascolto su :{LISTEN_PORT}", flush=True) - ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler).serve_forever() diff --git a/maxlab-probe/umbrel-app.yml b/maxlab-probe/umbrel-app.yml deleted file mode 100644 index 1e30223..0000000 --- a/maxlab-probe/umbrel-app.yml +++ /dev/null @@ -1,25 +0,0 @@ -manifestVersion: 1 -id: maxlab-probe -name: Max Lab Probe -tagline: Verifiche tecniche per Umbrel Lab Gateway (Fase 0) -icon: https://gitea.solarpi.net/truetype/umbrel-maxlab-store/raw/branch/main/maxlab-probe/icon.svg -category: developer -version: "0.0.1" -port: 8898 -description: >- - App di prova temporanea. Mostra un report su: porta pubblicata - direttamente (8899), accesso in sola lettura al socket Docker e ad - app-data, raggiungibilita' dei container delle altre app senza - app_proxy, rete condivisa tra due servizi. Da disinstallare dopo il test. -developer: truetype -website: https://gitea.solarpi.net/truetype/umbrel-maxlab-store -submitter: truetype -submission: https://gitea.solarpi.net/truetype/umbrel-maxlab-store -repo: https://gitea.solarpi.net/truetype/umbrel-maxlab-store -support: https://gitea.solarpi.net/truetype/umbrel-maxlab-store/issues -gallery: [] -releaseNotes: Prima versione di prova. -dependencies: [] -path: "" -defaultUsername: "" -defaultPassword: ""