diff --git a/.sops.yaml b/.sops.yaml index f24d552..ba3bfc3 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -18,6 +18,6 @@ creation_rules: - path_regex: secrets/secrets\.yaml$ key_groups: - pgp: - - 076AA297579A0064 + - A4EB2A1C815D21B028ABD5D4D2DE7AB86CC060CF age: - age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p diff --git a/flake.nix b/flake.nix index ed66c16..ac866ce 100644 --- a/flake.nix +++ b/flake.nix @@ -81,6 +81,8 @@ ./modules/services/attic.nix ./modules/services/mealie.nix ./modules/services/stirling-pdf.nix + ./modules/services/mlflow.nix + ./modules/services/emulatorjs.nix ./modules/services/uptime-kuma.nix ./modules/services/ntfy.nix ./modules/monitoring.nix diff --git a/hosts/pi-main/default.nix b/hosts/pi-main/default.nix index 62991cd..b8b99b3 100644 --- a/hosts/pi-main/default.nix +++ b/hosts/pi-main/default.nix @@ -54,7 +54,7 @@ extraGroups = [ "wheel" "podman" ]; # Paste your SSH public key here openssh.authorizedKeys.keys = [ - "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDMdozJewuuqG4VuwGhI5Uw2xDIUrkQwyPGQ2q3tvnPq/VoV+zfdnou42I6XYEfOGd6xfLR0byOGCk1Ia1F/picBsxADEaJFBnTny5MWNA5MxwJIYUPW5+kFN1TmxdNGmj0WJ8qZeKkS3ffixdHlHTVu4Rzrlu8qAdw8TmgcvD7sHpNaBB+hwG9B/s5BAozfXvyXtW51lLLrEZdmUXJxAP51ubVgysZ985PA89o4G7LUE20IKAj3rgylLwq+Lz2PoGmVCptPTknLLzGlxTy2mU/tESJtS4iUvshSYiKak73BSen88xLWL/eZGFzOLW4TNem/qT0ykqK1s1BaGWCcQiDWN+Lpa3XqTbFN054MfhLdEMp+NjrdsCbZORzp9p2ZPDqi5jI1Hy7IN/8VzzB+T13MX6YhW7CBBqUZaivGhVQGsNjrFusPGhCaZrzNJ1l4qssLAlOh9qdaYzqDe+BIHsty55vw4Ph+Axou82babpLoiQzkCol2TWjPr7JSfHFkm6CmoTQ7mK8m7jRCodmxF+BEe4Usr+HrTKDc9fr5P4mdfL0O+ZPrlkPuG1zBTMal9MPxeCk4emzOZf7vBMNzb0aKHbOtSdVk/IcjatlDd2EIo34e5A6anLRMAdxb7901mnCAcjvCn6+ArNV8nr+NBk1+UvumZ+Q59LTrAkgRt3rBw== openpgp:0x13E0C25D" + "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDMdozJewuuqG4VuwGhI5Uw2xDIUrkQwyPGQ2q3tvnPq/VoV+zfdnou42I6XYEfOGd6xfLR0byOGCk1Ia1F/picBsxADEaJFBnTny5MWNA5MxwJIYUPW5+kFN1TmxdNGmj0WJ8qZeKkS3ffixdHlHTVu4Rzrlu8qDdw8TmgcvD7sHpNaBB+hwG9B/s5BAozfXvyXtW51lLLrEZdmUXJxAP51ubVgysZ985PA89o4G7LUE20IKAj3rgylLwq+Lz2PoGmVCptPTknLLzGlxTy2mU/tESJtS4iUvshSYiKak73BSen88xLWL/eZGFzOLW4TNem/qT0ykqK1s1BaGWCcQiDWN+Lpa3XqTbFN054MfhLdEMp+NjrdsCbZORzp9p2ZPDqi5jI1Hy7IN/8VzzB+T13MX6YhW7CBBqUZaivGhVQGsNjrFusPGhCaZrzNJ1l4qssLAlOh9qdaYzqDe+BIHsty55vw4Ph+Axou82babpLoiQzkCol2TWjPr7JSfHFkm6CmoTQ7mK8m7jRCodmxF+BEe4Usr+HrTKDc9fr5P4mdfL0O+ZPrlkPuG1zBTMal9MPxeCk4emzOZf7vBMNzb0aKHbOtSdVk/IcjatlDd2EIo34e5A6anLRMAdxb7901mnCAcjvCn6+ArNV8nr+NBk1+UvumZ+Q59LTrAkgRt3rBw== openpgp:0x13E0C25D" ]; }; @@ -93,6 +93,12 @@ homey.mealie.enable = true; homey.stirlingPdf.enable = true; + # ML experiment tracking + homey.mlflow.enable = true; + + # Game emulator + homey.emulatorjs.enable = true; + # Reverse proxy + Cloudflare homey.caddy.enable = true; homey.cloudflared.enable = true; diff --git a/modules/services/aim.nix b/modules/services/aim.nix new file mode 100644 index 0000000..207481c --- /dev/null +++ b/modules/services/aim.nix @@ -0,0 +1,124 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# Aim — ML experiment tracking server. +# +# Auth model: none on the server itself. +# - Web UI: protected by Caddy → Authelia two_factor (admins only). +# - Python SDK: connects via aim://192.168.1.100:53800 directly on LAN; +# the firewall restricts port 53800 to 192.168.1.0/24 only. +# +# Two containers share one volume: +# aim-server — tracking protocol server (aim:// SDK connections) +# aim-ui — web UI + REST API (behind Caddy) +# +# Volume layout: +# /aim/ → /aim in both containers (shared .aim data store) + +let + cfg = config.homey.aim; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; +in +{ + options.homey.aim = { + enable = lib.mkEnableOption "Aim experiment tracking server"; + + image = lib.mkOption { + type = lib.types.str; + default = "aimstack/aim:latest"; + }; + + uiPort = lib.mkOption { + type = lib.types.port; + default = 43800; + description = "Host port for the Aim web UI (bound to 127.0.0.1, Caddy proxy)."; + }; + + serverPort = lib.mkOption { + type = lib.types.port; + default = 53800; + description = "Host port for the Aim tracking server (aim:// protocol, LAN-accessible)."; + }; + }; + + config = lib.mkIf cfg.enable { + # ------------------------------------------------------------------------- + # Tracking server — aim:// SDK protocol, LAN only + # ------------------------------------------------------------------------- + virtualisation.oci-containers.containers.aim-server = { + image = cfg.image; + # Bound to 0.0.0.0 so LAN training scripts can reach it directly. + # Port 53800 is restricted to 192.168.1.0/24 via extraCommands below. + ports = [ "0.0.0.0:${toString cfg.serverPort}:53800" ]; + volumes = [ "${dataDir}/aim:/aim" ]; + extraOptions = [ "--network=homey" ]; + cmd = [ "aim" "server" "--host" "0.0.0.0" "--port" "53800" "/aim" ]; + }; + + systemd.services."podman-aim-server" = { + after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; + }; + + # ------------------------------------------------------------------------- + # Web UI — Caddy proxy, Authelia-protected + # ------------------------------------------------------------------------- + virtualisation.oci-containers.containers.aim-ui = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.uiPort}:43800" ]; + volumes = [ "${dataDir}/aim:/aim" ]; + extraOptions = [ "--network=homey" ]; + cmd = [ "aim" "ui" "--host" "0.0.0.0" "--port" "43800" "/aim" ]; + }; + + systemd.services."podman-aim-ui" = { + after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" "podman-aim-server.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; + }; + + # ------------------------------------------------------------------------- + # Firewall — restrict tracking port to LAN subnet only + # ------------------------------------------------------------------------- + networking.firewall.extraCommands = lib.mkAfter '' + iptables -A nixos-fw -p tcp --dport ${toString cfg.serverPort} -s 192.168.1.0/24 -j nixos-fw-accept + ''; + + # ------------------------------------------------------------------------- + # Caddy virtual host — UI behind Authelia forward_auth + # ------------------------------------------------------------------------- + homey.caddy.virtualHosts = [{ + subdomain = "aim"; + port = cfg.uiPort; + auth = true; + }]; + + # ------------------------------------------------------------------------- + # Authelia — two_factor + deny, admins only (priority 27–28) + # ------------------------------------------------------------------------- + homey.authelia.accessControlRules = [ + { priority = 27; domain = [ "aim.${domain}" ]; policy = "two_factor"; subject = [ "group:admins" ]; } + { priority = 28; domain = [ "aim.${domain}" ]; policy = "deny"; } + ]; + + # ------------------------------------------------------------------------- + # Storage directory + # ------------------------------------------------------------------------- + homey.storage.extraDirs = [ + { path = "aim"; mode = "0755"; } + ]; + + # ------------------------------------------------------------------------- + # Backup — the .aim binary store holds all run data + # ------------------------------------------------------------------------- + homey.backup.extraPaths = [ "${dataDir}/aim" ]; + + # ------------------------------------------------------------------------- + # Uptime Kuma monitor + # ------------------------------------------------------------------------- + homey.monitoring.monitors = [{ + name = "Aim"; + url = "https://aim.${domain}"; + interval = 60; + }]; + }; +} diff --git a/modules/services/emulatorjs-server.py b/modules/services/emulatorjs-server.py new file mode 100644 index 0000000..d393df1 --- /dev/null +++ b/modules/services/emulatorjs-server.py @@ -0,0 +1,868 @@ +#!/usr/bin/env python3 +"""EmulatorJS launcher and per-user save-sync server. + +Saves and favourites are keyed by the Remote-User header (set by Authelia +via Caddy forward_auth), so each authenticated user gets their own data. + +Offline mode: a Service Worker caches static assets and ROMs in the browser +after first play. Saves made while offline are queued in IndexedDB and synced +to the server automatically when connectivity is restored. +""" +import json +import os +import re +import urllib.parse +import zipfile +from http.server import BaseHTTPRequestHandler, HTTPServer +from string import Template + +SAVE_DIR = os.environ.get("SAVE_DIR", "/mnt/data/emulatorjs/saves") +ROM_DIR = os.environ.get("ROM_DIR", "/mnt/data/emulatorjs/roms") +STATIC_DIR = os.environ.get("STATIC_DIR", "/mnt/data/emulatorjs/static") +THUMBNAILS_DIR = os.environ.get("THUMBNAILS_DIR", "/mnt/data/emulatorjs/thumbnails") +PORT = int(os.environ.get("PORT", "8085")) + +SAFE_RE = re.compile(r"^[A-Za-z0-9 _\-\.\(\)\[\]',!?:&+#]+$") + +ROM_SYSTEMS = { + ".gb": "gameboy", + ".gbc": "gameboy_color", + ".gba": "gba", + ".nes": "nes", + ".snes": "snes", + ".sfc": "snes", + ".md": "segaMD", + ".gen": "segaMD", + ".n64": "n64", + ".z64": "n64", +} + +RECOGNIZED_EXTS = set(ROM_SYSTEMS.keys()) | {".zip"} + +_system_cache: dict = {} + +def detect_system(filepath: str) -> str: + """Return the EmulatorJS core name for a ROM, peeking inside zips.""" + if filepath in _system_cache: + return _system_cache[filepath] + ext = os.path.splitext(filepath)[1].lower() + if ext != ".zip": + result = ROM_SYSTEMS.get(ext, "gameboy") + else: + result = "gameboy" + try: + with zipfile.ZipFile(filepath, "r") as zf: + for inner in zf.namelist(): + inner_ext = os.path.splitext(inner)[1].lower() + if inner_ext in ROM_SYSTEMS: + result = ROM_SYSTEMS[inner_ext] + break + except Exception: + pass + _system_cache[filepath] = result + return result + +MIME_TYPES = { + ".js": "application/javascript", + ".css": "text/css", + ".wasm": "application/wasm", + ".data": "application/octet-stream", + ".json": "application/json", + ".png": "image/png", + ".svg": "image/svg+xml", + ".ico": "image/x-icon", +} + +# ── Service Worker ───────────────────────────────────────────────────────────── +SW_JS = """\ +const CACHE_NAME = 'emulatorjs-v4'; + +self.addEventListener('install', function(event) { + event.waitUntil( + caches.open(CACHE_NAME).then(function(cache) { + return cache.addAll(['/static/loader.js', '/']); + }) + ); + self.skipWaiting(); +}); + +self.addEventListener('activate', function(event) { + event.waitUntil( + caches.keys().then(function(keys) { + return Promise.all( + keys.filter(function(k) { return k !== CACHE_NAME; }) + .map(function(k) { return caches.delete(k); }) + ); + }) + ); + self.clients.claim(); +}); + +self.addEventListener('fetch', function(event) { + var url = new URL(event.request.url); + var path = url.pathname; + + if (path === '/sw.js' || path === '/manifest.json') return; + + if (path.startsWith('/saves/')) { + event.respondWith( + fetch(event.request).catch(function() { + return new Response('', { status: 503 }); + }) + ); + return; + } + + // Static assets and individual ROM files: cache-first. + // Use arrayBuffer() instead of clone() to avoid an iOS Safari bug where + // a cloned response body can be empty when the original stream is consumed. + if (path.startsWith('/static/') || (path.startsWith('/roms/') && path !== '/roms/')) { + event.respondWith( + caches.match(event.request).then(function(cached) { + if (cached) return cached; + return fetch(event.request).then(function(response) { + if (!response.ok) return response; + var status = response.status; + var headers = new Headers(response.headers); + return response.arrayBuffer().then(function(body) { + caches.open(CACHE_NAME).then(function(cache) { + cache.put(event.request, + new Response(body, { status: status, headers: headers }) + ).catch(function(e) { + console.warn('[SW] cache.put failed (quota?):', event.request.url, e); + }); + }); + return new Response(body, { status: status, headers: headers }); + }); + }).catch(function() { + return new Response('', { status: 503 }); + }); + }) + ); + return; + } + + // Player pages: network-first so new deployments always take effect. + // Cache is used only as offline fallback. + if (path.startsWith('/play/')) { + event.respondWith( + fetch(event.request).then(function(response) { + var status = response.status, headers = new Headers(response.headers); + return response.arrayBuffer().then(function(body) { + caches.open(CACHE_NAME).then(function(cache) { + cache.put(event.request, new Response(body, { status: status, headers: headers })); + }); + return new Response(body, { status: status, headers: headers }); + }); + }).catch(function() { + return caches.match(event.request).then(function(cached) { + return cached || new Response('Offline', { status: 503 }); + }); + }) + ); + return; + } + + // Thumbnails: cache on first successful fetch + if (path.startsWith('/thumbnails/')) { + event.respondWith( + caches.match(event.request).then(function(cached) { + if (cached) return cached; + return fetch(event.request).then(function(response) { + if (!response.ok) return response; + var status = response.status, headers = new Headers(response.headers); + return response.arrayBuffer().then(function(body) { + caches.open(CACHE_NAME).then(function(cache) { + cache.put(event.request, new Response(body, { status: status, headers: headers })); + }); + return new Response(body, { status: status, headers: headers }); + }); + }).catch(function() { return new Response('', { status: 404 }); }); + }) + ); + return; + } + + // ROM listing, favorites, launcher: network-first, cache fallback (small text responses) + event.respondWith( + fetch(event.request).then(function(response) { + var status = response.status, headers = new Headers(response.headers); + return response.arrayBuffer().then(function(body) { + caches.open(CACHE_NAME).then(function(cache) { + cache.put(event.request, new Response(body, { status: status, headers: headers })); + }); + return new Response(body, { status: status, headers: headers }); + }); + }).catch(function() { + return caches.match(event.request).then(function(cached) { + return cached || new Response('', { status: 503 }); + }); + }) + ); +}); +""" + +# ── Shared JS: IndexedDB offline save queue ──────────────────────────────────── +_IDB_JS = """\ +function openIDB() { + if (!window.indexedDB) return Promise.reject(new Error('no IDB')); + return new Promise(function(resolve, reject) { + var req = indexedDB.open('emulatorjs', 1); + req.onupgradeneeded = function(e) { + e.target.result.createObjectStore('pending-saves', { keyPath: 'name' }); + }; + req.onsuccess = function(e) { resolve(e.target.result); }; + req.onerror = reject; + req.onblocked = function() { reject(new Error('IDB blocked')); }; + }); +} + +async function idbPut(name, data) { + const db = await openIDB(); + return new Promise(function(resolve, reject) { + const tx = db.transaction('pending-saves', 'readwrite'); + tx.objectStore('pending-saves').put({ name: name, data: Array.from(data), ts: Date.now() }); + tx.oncomplete = resolve; + tx.onerror = reject; + }); +} + +async function idbGet(name) { + const db = await openIDB(); + return new Promise(function(resolve) { + const tx = db.transaction('pending-saves', 'readonly'); + const req = tx.objectStore('pending-saves').get(name); + req.onsuccess = function(e) { + resolve(e.target.result ? new Uint8Array(e.target.result.data) : null); + }; + req.onerror = function() { resolve(null); }; + }); +} + +async function idbGetAll() { + const db = await openIDB(); + return new Promise(function(resolve) { + const tx = db.transaction('pending-saves', 'readonly'); + const req = tx.objectStore('pending-saves').getAll(); + req.onsuccess = function(e) { resolve(e.target.result || []); }; + req.onerror = function() { resolve([]); }; + }); +} + +async function idbDelete(name) { + const db = await openIDB(); + return new Promise(function(resolve) { + const tx = db.transaction('pending-saves', 'readwrite'); + tx.objectStore('pending-saves').delete(name); + tx.oncomplete = resolve; + tx.onerror = resolve; + }); +} + +async function syncPendingSaves() { + if (!navigator.onLine) return; + try { + const pending = await idbGetAll(); + for (const entry of pending) { + try { + const r = await fetch('/saves/' + encodeURIComponent(entry.name), { + method: 'PUT', + body: new Uint8Array(entry.data), + headers: { 'Content-Type': 'application/octet-stream' } + }); + if (r.ok) await idbDelete(entry.name); + } catch(e) { break; } + } + } catch(e) {} +} +""" + +# ── Shared JS: Service Worker registration ───────────────────────────────────── +_SW_REG = """\ +if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js').catch(function(e) { + console.warn('SW registration failed', e); + }); +} +""" + +# ── Game launcher ────────────────────────────────────────────────────────────── +LAUNCHER_HTML = ("""\ + + + + + +Game Library + + + +
Offline — saves will sync when reconnected
+

Game Library

+
SW: checking… | Online: checking…
+ +

Loading…

+ + +""") + +# ── Player page ──────────────────────────────────────────────────────────────── +PLAYER_TMPL = Template("""\ + + + + + +$title + + + +
Offline — saves will sync when reconnected
+← Library + +
+ +""" + _SW_REG + """\ + +""") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, fmt, *args): + pass + + def send_body(self, data: bytes, ct: str, status: int = 200): + self.send_response(status) + self.send_header("Content-Type", ct) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def err(self, code: int): + self.send_response(code) + self.end_headers() + + def get_user(self): + u = self.headers.get("Remote-User", "").strip() + return u if (u and SAFE_RE.match(u)) else None + + def safe_name(self, s: str): + s = urllib.parse.unquote(s).strip() + return s if (s and ".." not in s and "/" not in s and SAFE_RE.match(s)) else None + + def decoded_path(self): + return urllib.parse.unquote(self.path.split("?")[0]) + + # ── Favourites ───────────────────────────────────────────────────────────── + + def favs_path(self, user: str) -> str: + return os.path.join(SAVE_DIR, user, "favorites.json") + + def read_favs(self, user: str) -> list: + fp = self.favs_path(user) + if not os.path.isfile(fp): + return [] + try: + with open(fp) as f: + return json.load(f) + except Exception: + return [] + + def write_favs(self, user: str, favs: list) -> None: + fp = self.favs_path(user) + os.makedirs(os.path.dirname(fp), exist_ok=True) + tmp = fp + ".tmp" + with open(tmp, "w") as f: + json.dump(favs, f) + os.replace(tmp, fp) + + def handle_fav_write(self, adding: bool) -> None: + p = self.decoded_path() + user = self.get_user() + if not user: self.err(401); return + name = self.safe_name(p[len("/favorites/"):]) + if not name: self.err(400); return + favs = self.read_favs(user) + if adding and name not in favs: + favs.append(name) + elif not adding and name in favs: + favs.remove(name) + self.write_favs(user, favs) + self.send_response(204) + self.end_headers() + + # ── Saves ────────────────────────────────────────────────────────────────── + + def write_save(self) -> None: + p = self.decoded_path() + if not p.startswith("/saves/"): + self.err(404); return + user = self.get_user() + if not user: self.err(401); return + name = self.safe_name(p[len("/saves/"):]) + if not name: self.err(400); return + length = int(self.headers.get("Content-Length", 0)) + data = self.rfile.read(length) + user_dir = os.path.join(SAVE_DIR, user) + os.makedirs(user_dir, exist_ok=True) + with open(os.path.join(user_dir, name + ".sav"), "wb") as f: + f.write(data) + self.send_response(204) + self.end_headers() + + # ── HTTP verbs ───────────────────────────────────────────────────────────── + + def do_GET(self): + p = self.decoded_path() + + if p == "/sw.js": + body = SW_JS.encode() + self.send_response(200) + self.send_header("Content-Type", "application/javascript") + self.send_header("Content-Length", str(len(body))) + self.send_header("Service-Worker-Allowed", "/") + self.send_header("Cache-Control", "no-cache") + self.end_headers() + self.wfile.write(body) + return + + if p in ("/", ""): + self.send_body(LAUNCHER_HTML.encode(), "text/html; charset=utf-8") + return + + if p == "/roms/": + roms = [] + if os.path.isdir(ROM_DIR): + for f in sorted(os.listdir(ROM_DIR)): + ext = os.path.splitext(f)[1].lower() + if ext in RECOGNIZED_EXTS: + roms.append({"name": f, "title": os.path.splitext(f)[0], + "system": detect_system(os.path.join(ROM_DIR, f))}) + self.send_body(json.dumps(roms).encode(), "application/json") + return + + if p.startswith("/roms/"): + name = self.safe_name(p[len("/roms/"):]) + if not name: self.err(400); return + fp = os.path.join(ROM_DIR, name) + if not os.path.isfile(fp): self.err(404); return + with open(fp, "rb") as f: + self.send_body(f.read(), "application/octet-stream") + return + + if p.startswith("/play/"): + name = self.safe_name(p[len("/play/"):]) + if not name: self.err(400); return + game = {"name": name, "title": os.path.splitext(name)[0], + "system": detect_system(os.path.join(ROM_DIR, name))} + html = PLAYER_TMPL.substitute(title=game["title"], + game_json=json.dumps(game)) + self.send_body(html.encode(), "text/html; charset=utf-8") + return + + if p == "/favorites": + user = self.get_user() + if not user: self.err(401); return + self.send_body(json.dumps(self.read_favs(user)).encode(), "application/json") + return + + if p.startswith("/thumbnails/"): + title = self.safe_name(p[len("/thumbnails/"):]) + if not title: self.err(400); return + fp = os.path.join(THUMBNAILS_DIR, title + ".png") + if not os.path.isfile(fp): self.err(404); return + with open(fp, "rb") as f: + self.send_body(f.read(), "image/png") + return + + if p.startswith("/saves/"): + user = self.get_user() + if not user: self.err(401); return + name = self.safe_name(p[len("/saves/"):]) + if not name: self.err(400); return + fp = os.path.join(SAVE_DIR, user, name + ".sav") + if not os.path.isfile(fp): self.err(404); return + with open(fp, "rb") as f: + self.send_body(f.read(), "application/octet-stream") + return + + if p.startswith("/static/"): + rel = p[len("/static/"):] + if ".." in rel or not rel: self.err(400); return + fp = os.path.join(STATIC_DIR, rel) + if not os.path.isfile(fp): self.err(404); return + ext = os.path.splitext(rel)[1].lower() + with open(fp, "rb") as f: + self.send_body(f.read(), MIME_TYPES.get(ext, "application/octet-stream")) + return + + self.err(404) + + def do_PUT(self): + self.write_save() + + def do_POST(self): + p = self.decoded_path() + if p.startswith("/saves/"): + self.write_save() + elif p.startswith("/favorites/"): + self.handle_fav_write(adding=True) + else: + self.err(404) + + def do_DELETE(self): + p = self.decoded_path() + if p.startswith("/favorites/"): + self.handle_fav_write(adding=False) + else: + self.err(404) + + +if __name__ == "__main__": + HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/modules/services/emulatorjs.nix b/modules/services/emulatorjs.nix new file mode 100644 index 0000000..d4cdcdb --- /dev/null +++ b/modules/services/emulatorjs.nix @@ -0,0 +1,139 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# EmulatorJS — self-hosted web game emulator with per-user save sync. +# +# The Python sidecar (emulatorjs-server.py) serves: +# / → game launcher (lists ROMs) +# /roms/ → ROM listing (JSON) and ROM files +# /play/ → EmulatorJS player page for a specific game +# /saves/→ GET/PUT save file (SRAM), keyed by Remote-User header +# /static/ → EmulatorJS JS/CSS/WASM assets (served from Nix store) +# +# Multi-device sync: saves are stored server-side keyed by Authelia +# Remote-User header, so any device the user logs into shares the same save. +# +# EmulatorJS assets are fetched from GitHub at build time — no manual +# download step. To upgrade, change `rev` and replace `hash` with the +# value Nix reports after a failed build (same pattern as caddy.nix). +# +# Setup after first enable: +# 1. Drop ROM files into /mnt/data/emulatorjs/roms/ +# Supported: .gb .gbc .gba .nes .snes .sfc .md .gen .n64 .z64 +# 2. Visit https://games. — saves go to +# /mnt/data/emulatorjs/saves// automatically. + +let + cfg = config.homey.emulatorjs; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; + port = 8085; + + # EmulatorJS static assets fetched from GitHub at build time. + # Pre-built WASM cores are committed to the repo so no build step is needed. + # + # To upgrade to a new version: + # 1. Find the latest tag: + # git ls-remote --tags https://github.com/EmulatorJS/EmulatorJS | tail -10 + # 2. Prefetch the tarball hash: + # nix-prefetch-url --unpack "https://github.com/EmulatorJS/EmulatorJS/archive/refs/tags/vX.Y.Z.tar.gz" + # 3. Convert to SRI format: + # nix hash convert --hash-algo sha256 --to sri + # 4. Update rev and hash below. + emulatorjsAssets = pkgs.fetchFromGitHub { + owner = "EmulatorJS"; + repo = "EmulatorJS"; + rev = "v4.2.3"; + hash = "sha256-hIgvcVNjl9qGJw0GgkkLQQyd2JhdUOVOQhLAeZuQcMk="; + }; +in +{ + options.homey.emulatorjs.enable = + lib.mkEnableOption "EmulatorJS self-hosted game emulator"; + + config = lib.mkIf cfg.enable { + + # ------------------------------------------------------------------------- + # Dedicated system user so save-file ownership is stable across restarts + # ------------------------------------------------------------------------- + users.users.emulatorjs = { + isSystemUser = true; + group = "emulatorjs"; + description = "EmulatorJS save-sync server"; + }; + users.groups.emulatorjs = {}; + + # ------------------------------------------------------------------------- + # Launcher + save-sync server + # ------------------------------------------------------------------------- + systemd.services.emulatorjs = { + description = "EmulatorJS launcher and save-sync server"; + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" "mnt-data.mount" "systemd-tmpfiles-setup.service" ]; + requires = [ "mnt-data.mount" ]; + + environment = { + PORT = toString port; + SAVE_DIR = "${dataDir}/emulatorjs/saves"; + ROM_DIR = "${dataDir}/emulatorjs/roms"; + # EmulatorJS assets live in the Nix store — read-only, no manual setup. + # The data/ subdirectory contains loader.js and the emulator cores. + STATIC_DIR = "${emulatorjsAssets}/data"; + THUMBNAILS_DIR = "${dataDir}/emulatorjs/thumbnails"; + }; + + serviceConfig = { + ExecStart = "${pkgs.python3}/bin/python3 ${./emulatorjs-server.py}"; + User = "emulatorjs"; + Group = "emulatorjs"; + Restart = "on-failure"; + RestartSec = "5s"; + + NoNewPrivileges = true; + ProtectSystem = "strict"; + ProtectHome = true; + PrivateTmp = true; + ReadWritePaths = [ "${dataDir}/emulatorjs" ]; + }; + }; + + # ------------------------------------------------------------------------- + # Caddy — one_factor SSO; Remote-User header forwarded to the sidecar + # ------------------------------------------------------------------------- + homey.caddy.virtualHosts = [{ + subdomain = "games"; + port = port; + auth = true; + }]; + + # ------------------------------------------------------------------------- + # Storage directories (owned by the service user) + # ------------------------------------------------------------------------- + homey.storage.extraDirs = [ + { path = "emulatorjs"; user = "emulatorjs"; group = "emulatorjs"; } + { path = "emulatorjs/saves"; user = "emulatorjs"; group = "emulatorjs"; } + { path = "emulatorjs/roms"; user = "emulatorjs"; group = "emulatorjs"; } + { path = "emulatorjs/thumbnails"; user = "emulatorjs"; group = "emulatorjs"; } + ]; + + # Only saves need backing up; ROMs and static assets are easy to restore + homey.backup.extraPaths = [ "${dataDir}/emulatorjs/saves" ]; + + # ------------------------------------------------------------------------- + # Authelia access control — one_factor for all household users + # ------------------------------------------------------------------------- + homey.authelia.accessControlRules = [{ + priority = 57; + domain = [ "games.${domain}" ]; + policy = "one_factor"; + }]; + + # ------------------------------------------------------------------------- + # Uptime Kuma monitor + # ------------------------------------------------------------------------- + homey.monitoring.monitors = [{ + name = "EmulatorJS"; + url = "https://games.${domain}"; + interval = 60; + }]; + }; +} diff --git a/modules/services/mlflow.nix b/modules/services/mlflow.nix new file mode 100644 index 0000000..2886fdb --- /dev/null +++ b/modules/services/mlflow.nix @@ -0,0 +1,144 @@ +{ config, lib, pkgs, homeyConfig, ... }: + +# MLflow — ML experiment tracking server. +# +# Auth model: MLflow's built-in basic-auth plugin (--app-name basic-auth). +# - Web UI: login form (MLflow's own user database — separate from LDAP). +# - Python SDK: set MLFLOW_TRACKING_TOKEN= for bearer-token auth, +# or MLFLOW_TRACKING_USERNAME/PASSWORD for basic auth. +# - No Authelia forward_auth — MLflow manages its own user database. +# +# On first boot, basic-auth.ini seeds the initial admin account. After that, +# credentials live in /mlflow/data/basic_auth.db and the ini is ignored for auth. +# +# Secrets consumed from sops: +# mlflow/secret_key — Flask CSRF secret (generate: openssl rand -hex 32) +# mlflow/admin_password — initial admin password (used once on first boot) +# +# Volume layout: +# /mlflow/data/ → /mlflow/data (SQLite tracking DB, auth DB, basic-auth.ini) +# /mlflow/artifacts/ → /mlflow/artifacts (model files, plots, etc.) + +let + cfg = config.homey.mlflow; + dataDir = config.homey.storage.mountPoint; + domain = homeyConfig.domain; +in +{ + options.homey.mlflow = { + enable = lib.mkEnableOption "MLflow experiment tracking server"; + + image = lib.mkOption { + type = lib.types.str; + default = "ghcr.io/mlflow/mlflow:latest"; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 5050; + description = "Host port MLflow listens on (bound to 127.0.0.1, Caddy proxy)."; + }; + }; + + config = lib.mkIf cfg.enable { + # ------------------------------------------------------------------------- + # Secrets + # ------------------------------------------------------------------------- + sops.secrets."mlflow/secret_key" = { owner = "root"; }; + sops.secrets."mlflow/admin_password" = { owner = "root"; }; + + # ------------------------------------------------------------------------- + # Container + # ------------------------------------------------------------------------- + virtualisation.oci-containers.containers.mlflow = { + image = cfg.image; + ports = [ "127.0.0.1:${toString cfg.port}:5000" ]; + volumes = [ + "${dataDir}/mlflow/data:/mlflow/data" + "${dataDir}/mlflow/artifacts:/mlflow/artifacts" + ]; + extraOptions = [ "--network=homey" ]; + environment = { + # v3.x: auth config path is env var, not a CLI flag + MLFLOW_AUTH_CONFIG_PATH = "/mlflow/data/basic-auth.ini"; + }; + environmentFiles = [ "/run/mlflow-secrets.env" ]; + cmd = [ + "mlflow" "server" + "--host" "0.0.0.0" + "--port" "5000" + "--backend-store-uri" "sqlite:////mlflow/data/mlflow.db" + "--default-artifact-root" "/mlflow/artifacts" + "--app-name" "basic-auth" + # v3.x security middleware: must explicitly allow the public hostname + "--allowed-hosts" "mlflow.${domain}" + # Allow browser UI (ajax-api) requests from the public origin. + # Without this, fastapi_security blocks all cross-origin requests with 403, + # breaking chart data and UI telemetry (SDK api/2.0 calls are unaffected). + "--cors-allowed-origins" "https://mlflow.${domain}" + ]; + }; + + # ------------------------------------------------------------------------- + # ExecStartPre: write secrets env file and seed basic-auth.ini on first boot + # ------------------------------------------------------------------------- + systemd.services."podman-mlflow" = { + serviceConfig.ExecStartPre = [ + (pkgs.writeShellScript "mlflow-write-secrets" '' + set -euo pipefail + + install -m 600 /dev/null /run/mlflow-secrets.env + printf 'MLFLOW_FLASK_SERVER_SECRET_KEY=%s\n' \ + "$(cat ${config.sops.secrets."mlflow/secret_key".path})" \ + >> /run/mlflow-secrets.env + + # Seed basic-auth.ini on first boot only. + # After first run MLflow stores credentials in basic_auth.db. + if [ ! -f "${dataDir}/mlflow/data/basic-auth.ini" ]; then + printf '[mlflow]\ndefault_permission = NO_PERMISSIONS\nadmin_username = admin\nadmin_password = %s\ndatabase_uri = sqlite:////mlflow/data/basic_auth.db\n' \ + "$(cat ${config.sops.secrets."mlflow/admin_password".path})" \ + > "${dataDir}/mlflow/data/basic-auth.ini" + fi + '') + ]; + postStop = "rm -f /run/mlflow-secrets.env"; + after = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; + requires = lib.mkAfter [ "mnt-data.mount" "podman-homey-network.service" ]; + }; + + # ------------------------------------------------------------------------- + # Caddy virtual host — auth=false, MLflow handles its own login + # ------------------------------------------------------------------------- + homey.caddy.virtualHosts = [{ + subdomain = "mlflow"; + port = cfg.port; + auth = false; + }]; + + # ------------------------------------------------------------------------- + # Storage directories on external HD + # ------------------------------------------------------------------------- + homey.storage.extraDirs = [ + { path = "mlflow"; } + { path = "mlflow/data"; mode = "0750"; } + { path = "mlflow/artifacts"; mode = "0750"; } + ]; + + # ------------------------------------------------------------------------- + # Backup + # ------------------------------------------------------------------------- + homey.backup.extraPaths = [ + "${dataDir}/mlflow/data" + "${dataDir}/mlflow/artifacts" + ]; + + # ------------------------------------------------------------------------- + # Monitoring + # ------------------------------------------------------------------------- + homey.monitoring.monitors = [{ + name = "MLflow"; + url = "https://mlflow.${domain}"; + interval = 60; + }]; + }; +} diff --git a/scripts/ssh-with-gpg-key.sh b/scripts/ssh-with-gpg-key.sh new file mode 100755 index 0000000..a796f7f --- /dev/null +++ b/scripts/ssh-with-gpg-key.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +KEY_FILE="${1:?Usage: $0 [user@host]}" +PI_HOST="${2:-admin@192.168.1.100}" + +GNUPGHOME=$(mktemp -d) +chmod 700 "$GNUPGHOME" +export GNUPGHOME + +cleanup() { + gpgconf --kill gpg-agent 2>/dev/null || true + rm -rf "$GNUPGHOME" +} +trap cleanup EXIT + +PINENTRY=$(which pinentry-curses 2>/dev/null || which pinentry-tty 2>/dev/null || which pinentry 2>/dev/null) +cat > "$GNUPGHOME/gpg-agent.conf" </dev/null | awk ' + /\[A\]/ { found=1; next } + found && /Keygrip = / { print $3; found=0 } +') + +if [[ -z "$KEYGRIP" ]]; then + echo "Error: no authentication subkey [A] found in $KEY_FILE" >&2 + exit 1 +fi + +echo "$KEYGRIP" > "$GNUPGHOME/sshcontrol" +gpg-connect-agent reloadagent /bye >/dev/null + +export SSH_AUTH_SOCK +SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket) + +echo "Connecting to $PI_HOST..." +ssh "$PI_HOST" diff --git a/secrets/secrets.yaml b/secrets/secrets.yaml index e487fdf..9c505c0 100644 --- a/secrets/secrets.yaml +++ b/secrets/secrets.yaml @@ -40,39 +40,42 @@ mealie: attic: jwt_secret: ENC[AES256_GCM,data:6g1wDau2rEqrmirzamrE6q0Sf38tosCp7EM0EtMLHXANoEfUdK8aL2Jo6z+tWL5bhNTkHwOl55j2mbyUWDlFN3I9vtI9uPKjlP+SgGbSJoKv++UYIhBmcg==,iv:DBgrMPQG/V9g0vG6Ax/fb1xCpvTYSfvAhqojH84wgn8=,tag:9WJjMFuo9kSfxRI9DVpdlg==,type:str] pull_token: ENC[AES256_GCM,data:FDMRf8El1APXdE1+CraGDKBk9PvAnLFNL9YqvDA++5keV/M7ynAdvAhzJV1dkQ2PcRJKalAkWY0zkoQsXzmWRdY/30WzhHa60GPRRfdX4Bc1N2DqK9mFfO4eWFSBRF5EgZkqWJ+XcijiKHTr3W6MNt8oD+YQ6XkKLvRs6tOep085g2ZdK9jmaQnWTsFMhYmUt//THscDPBq8Jh81Uh2WcLJYB4hEGxxIZZtsbdK6AsRjlMsxkzr+W4kwVKs8aGjqJ5LvUOCHPGY9DvdGtWMMvMs9aw20b05ViuKzemMfDd0=,iv:CzzhhbYtJhtrAIMkERGim+j0pvC5anHVwguV//VrJRQ=,tag:6uGz1f1w76Bk8bbZItYzDg==,type:str] +mlflow: + secret_key: ENC[AES256_GCM,data:2fw2/ROyGEVE9k9v4ULf05bUwccm/cVtFcn8WIeml7tGO2dw8R4YCg+noPJjQPJmN3kGhwgzp0zo0objCnkPhA==,iv:B/FUPvbd09pP6pL8P0pdGvf549orUOXs/6X/vj5xpPg=,tag:o6SKLTbFiHMnaPu0bhqK2g==,type:str] + admin_password: ENC[AES256_GCM,data:ficqSYVDdA2ztFlsmv1opkLeUEA3SEDoBQ==,iv:bbsTXg9dvZ7wX7sLVql3tDMUQfXs+hu4nH6djdcMyi0=,tag:d1KWoy/2g1eDWzL/R1lXTQ==,type:str] sops: age: - recipient: age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBZSGpPdTBIaTZ0TER2NkNO - U2ZPKzNwelJHUEpyU2VBSmd5Yjd5bEtibFZzCjlZZTRFa2FHN1JtK2JUSm51a3By - QmFyV1ZZNWI0OGJVM1NNZERjd2hWcDAKLS0tIG9VSVFTSTJBMjk5ZzBSL0ZQV2Ev - QXVkRlJHeW52NFZFYnVwaW8ycytDSzAKZt+p5QnZKcEOBghHA2xkH6d7NObtTEoE - wMwCYasnBHzy2unXRbZq/4v9NQ5HJd0Nu1iqbqKgIxMCD3dnxEdK7g== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBObjloTm12dXc5NEhvanQ2 + cWhmcWpEY2hmT3pYeXdoTzFuSUVjTktYM2x3CmZKNjZvMXpBVzlHTkFXMkd4SFZx + b09KeWp3OE1ZQk41VU9vQ25Fd1ErdEkKLS0tIHcrSmpHSjJ5dytVRk9yaGxRci92 + b0RlaHcyNUJSUHRXRlhEUG1NWXBlb28KS5RrYXQL1Joc5I3IJGsa+mfWKtvtpVvc + w9uLPKkQDROeS8dBqWQf4YVqtIDHU81X+P5i0dVr7roDNzhN+3RLXQ== -----END AGE ENCRYPTED FILE----- - lastmodified: "2026-05-30T09:31:03Z" - mac: ENC[AES256_GCM,data:Mnu3wtu6gfGWtU+03KyTKa9n0uWsRCISRZJcZaF2n9wCD/GDikqUX6QFFZcHHoablXEqN6yu5u0wc7efX80PCnDlkr8C0gQF3i9+p9Kj+i+pfguG47sfqP3ITXIjJpwwZwiFlbCJ/Hj3bpIpUCwr3gb6KQjQZ2bm7SGDlNeV9Ys=,iv:SbFCyuMKaYA3yKvh/DcslA98/cBXTBI7sn3TJ3RZ+y4=,tag:Eh9kMKe4pT8H9O1UZWaTRA==,type:str] + lastmodified: "2026-07-29T09:55:26Z" + mac: ENC[AES256_GCM,data:VgiX5YeFYFcanGzBRbnwJGcS1JcguoKU2MaBZOG/m9xI7lIO8BAJU9mwuGOWq2vRCq5lQIiWVlDZmHksyBntrcRlQGF4ByKZO5F76QlXJ4u6DynJ1mIl2CWt6+VIfJolaIoYf1TL3B1dDu9xThSDd3qakm0NU52NqiZoeUv9QNc=,iv:dsvJlzuoVH2xsx3AkIM7sy0GuXqvOe02/xbOVC5O/FU=,tag:YQEUsD4zDWFYBZGcuArKPg==,type:str] pgp: - - created_at: "2026-04-21T06:39:49Z" + - created_at: "2026-07-29T09:53:25Z" enc: |- -----BEGIN PGP MESSAGE----- - hQIMAwdqopdXmgBkAQ//TzlOz/QYwiYAc6NGo2O8YJi5ERkS1+0qNpptD51g2dLF - V4iUx7400tc6IEEhZ0N54R7AO5mSX55XCWJxVQDTRJXmLDHcOR+9vThb4H571XBa - 3mcmE8Dj3sN3a1K2RwajZJXl1o5d1oNvWJ83pVsCnrJegi92+GmvmOt4QZ1l5aCf - TGYgUXAz1RreqsGKjJsSXscZOvRnp+cslJ9xY8OXeKLbQvLg0Z3pSQG2QGgDmHPD - fRxYnlc2lKe32uoBlD2LXK+NoBnrRYEVrrwGf6P5GpTDpJbc0bR5BiRIYDhPxtqK - SiXWHaebg73+kbWdcm+2kiac6hW6xW/iJL4eFBT1v/NgZmNoQCnJOIA7v2vjv9vl - 81Y1FM5MpIfwNiTwkJjVsgM2tHkANlbixBHJdbjlnKpo9pTS7RuttWtdCmFdmXr0 - oiuKDDRPVGvykPqvHzvCLf/k5j1nYvqvb7Wn2Bycc5kIOjFYEDEeM0r37vOX9nDM - SW1HtaWoZuVceTJEit0WR63kmXYLZ/AHvXcmq6ucUw8Fmw79n+7brQiX2RMtCK1E - pfrNey3EEqvPs2RDd6XdF4/73CdMDN5s3xiFAIfLGeZ6h0Eq27fazSZNmdh4MGYb - Wzj81ur8dimoSP+W9eW1TjIfY4deH5FRnN19ldKPuHdazvikWWsdN05evNlSZsDS - XgHafkhKiNSNZLw/VVzf+1SDLhN1H5QoxZ2YsxCc+psd5CFxU1x3llIDg4hXScAR - OQvRR1VjQOLFCwdFErW7sd6nQlkS7LnAskgT/0ZJGsxfkh1gJO3YqDnEKF7+P9w= - =zKa+ + hQIMAya4Ufgfj3xVAQ/+IfyGMI4+elF64+LmKlUZtoOYW8ad3DEL7t6bawVTV/is + DP5JVVPQ9aauG+VuHFCvGNMtq60JNPLU+xy1f9rM2hdd+qyI+BdlhLwxcS3uqdxG + Uikc0PuYvgOLYDr7KsloS0x+5k+Oc7RUacGv/Ga2aNwWg2UEp2dj4adkRwA/KWxi + NlAhHEUiFpKGzrsfw86BBD+86tx+vDZQXBzJ7G2R7OnTOsPO/0jAqeSSbdCLnDPp + GbLdhuCj6pm4jDoLflMtJ77zWUoIhmAeCy4iZ2uxt3UFGtf0B+4m3/VK/O3UqLOX + OYom9OahZ/X9D4Z5F87cxbDwxw+VbaTHpHPnxrv5eL0/DTd03zhGHEvtq4TpmFqf + NBWAiKEKBn6Z1sH6pMbiDnp+cRIc3WKQjRQSD8nKrYuvt2kjErLrBLme3IAM3+0d + iaTWI3evNrRJ+CrAYiUMo/3ot+g3yTYmDNUCCLpsrbHBo/2OkanmaXiogkuTjotP + nUDrLTQPdaRXHktb1B35lFIgeLysu+Z+stGsHcQwbWN0uRspGdNHLExt5EurhIiP + MI88IrcxnexDeAikP1GWNwvqlHaaxTh5kLLi8D+gvSMrRA0iJUruKQDLideGKZpy + h21Q3iocXNL1uF84+a5ZWtjMUIr/15ZWgoIDYDYBiUOik14RjMx7dB1UE7JExZXS + XgEjIM0ac61FHOUelOs+xFuVDfDel6iFQpYK0jPywN70h6v4CgzvyvsrLIw9BYeH + iQ13gqOJinTZGH7/M+t1bYHsMix4lsJlXcI2tRnv4QyjssLAIy1nIer8q5UpXEE= + =Tn6d -----END PGP MESSAGE----- - fp: 076AA297579A0064 + fp: A4EB2A1C815D21B028ABD5D4D2DE7AB86CC060CF unencrypted_suffix: _unencrypted version: 3.12.2