Redeploy game em

This commit is contained in:
Aner Zakobar
2026-07-30 13:40:44 +03:00
parent cc4803bb82
commit fc457ddf7c
9 changed files with 1353 additions and 25 deletions
+1 -1
View File
@@ -18,6 +18,6 @@ creation_rules:
- path_regex: secrets/secrets\.yaml$
key_groups:
- pgp:
- 076AA297579A0064
- A4EB2A1C815D21B028ABD5D4D2DE7AB86CC060CF
age:
- age120j8ty7nn04l3s3kgph5ty3v9g4e52fknn8xtnmzwakq9nv2la3skgte0p
+2
View File
@@ -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
+7 -1
View File
@@ -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;
+124
View File
@@ -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:
# <dataDir>/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 2728)
# -------------------------------------------------------------------------
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;
}];
};
}
+868
View File
@@ -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 = ("""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Game Library</title>
<style>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #eee;
max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; }
h1 { color: #e94560; margin: 0 0 1rem; }
#offline-banner {
display: none; background: #c0392b; color: #fff; text-align: center;
padding: .5rem; font-size: .9rem; margin: -1.5rem -1rem 1rem;
}
#dbg-bar {
font-size: .7rem; color: #9ab; margin-bottom: .75rem; font-family: monospace;
background: #0d1527; border-radius: 6px; padding: .4rem .75rem;
}
#search {
width: 100%; padding: .7rem 1rem; font-size: 1rem;
background: #16213e; border: 1px solid #0f3460; border-radius: 8px;
color: #eee; outline: none; margin-bottom: 1.5rem; -webkit-appearance: none;
}
#search:focus { border-color: #e94560; }
#search::placeholder { color: #555; }
.section-label {
grid-column: 1 / -1; font-size: .7rem; font-weight: 700; letter-spacing: .1em;
text-transform: uppercase; color: #9ab; padding-bottom: .4rem;
border-bottom: 1px solid #16213e; margin-top: .25rem;
}
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: .875rem; }
.card {
background: #16213e; border-radius: 10px; overflow: hidden; position: relative;
text-decoration: none; color: #eee; display: block;
transition: transform .15s, background .15s;
}
.card:hover { background: #0f3460; transform: translateY(-2px); }
.thumb-wrap { width: 100%; aspect-ratio: 3/4; background: #0d1527;
display: flex; align-items: center; justify-content: center; overflow: hidden; }
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; display: block; }
.no-thumb { font-size: 2.5rem; color: #222; }
.card-body { padding: .6rem .75rem; }
.card-title { font-weight: 600; font-size: .8rem; line-height: 1.3; word-break: break-word;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden; }
.card-sys { font-size: .65rem; color: #9ab; margin-top: .2rem; text-transform: uppercase; }
.star {
position: absolute; top: .4rem; right: .4rem; z-index: 1;
background: rgba(0,0,0,.55); border: none; border-radius: 50%;
width: 2rem; height: 2rem; font-size: 1rem; line-height: 1; cursor: pointer;
color: #888; display: flex; align-items: center; justify-content: center; padding: 0;
transition: background .15s, color .15s;
}
.star:hover { background: rgba(0,0,0,.85); }
.star.fav { color: #f5c518; }
.cache-dot {
display: none; position: absolute; bottom: .4rem; left: .4rem;
width: .55rem; height: .55rem; border-radius: 50%; background: #2ecc71;
title: "Cached for offline";
}
.empty { color: #555; grid-column: 1 / -1; }
</style>
</head>
<body>
<div id="offline-banner">Offline — saves will sync when reconnected</div>
<h1>Game Library</h1>
<div id="dbg-bar">SW: checking… | Online: checking…</div>
<input id="search" type="search" placeholder="Search…" autocomplete="off" spellcheck="false">
<div class="grid" id="grid"><p class="empty">Loading…</p></div>
<script>
""" + _IDB_JS + """
var allGames = [];
var favSet = new Set();
function thumbErr(img) {
img.parentNode.innerHTML = '<span class="no-thumb">\U0001f3ae</span>';
}
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function card(g) {
var isFav = favSet.has(g.name);
var thumb = '/thumbnails/' + encodeURIComponent(g.title);
var play = '/play/' + encodeURIComponent(g.name);
return '<a class="card" href="' + play + '" data-rom="' + esc(g.name) + '">'
+ '<div class="thumb-wrap">'
+ '<img src="' + thumb + '" alt="" loading="lazy" onerror="thumbErr(this)">'
+ '</div>'
+ '<div class="card-body">'
+ '<div class="card-title">' + esc(g.title) + '</div>'
+ '<div class="card-sys">' + esc(g.system) + '</div>'
+ '</div>'
+ '<button class="star' + (isFav ? ' fav' : '') + '"'
+ ' data-name="' + esc(g.name) + '"'
+ ' onclick="toggleFav(event,this)"'
+ ' title="' + (isFav ? 'Remove from favourites' : 'Add to favourites') + '">'
+ (isFav ? '' : '')
+ '</button>'
+ '<span class="cache-dot" title="Cached for offline"></span>'
+ '</a>';
}
function render(q) {
var term = q.trim().toLowerCase();
var list = term ? allGames.filter(function(g) {
return g.title.toLowerCase().indexOf(term) !== -1;
}) : allGames;
if (!list.length) {
document.getElementById('grid').innerHTML = '<p class="empty">No games found.</p>';
return;
}
var faved = list.filter(function(g) { return favSet.has(g.name); });
var others = list.filter(function(g) { return !favSet.has(g.name); });
var html = '';
if (faved.length && others.length) html += '<div class="section-label">Favourites</div>';
faved.forEach(function(g) { html += card(g); });
if (faved.length && others.length) html += '<div class="section-label">All Games</div>';
others.forEach(function(g) { html += card(g); });
document.getElementById('grid').innerHTML = html;
updateCacheStatus();
}
async function toggleFav(e, btn) {
e.preventDefault();
e.stopPropagation();
var name = btn.dataset.name;
var adding = !favSet.has(name);
if (adding) favSet.add(name); else favSet.delete(name);
render(document.getElementById('search').value);
await fetch('/favorites/' + encodeURIComponent(name), { method: adding ? 'POST' : 'DELETE' });
}
document.getElementById('search').addEventListener('input', function() {
render(this.value);
});
async function updateCacheStatus() {
if (!('caches' in window)) return;
try {
const cache = await caches.open('emulatorjs-v4');
const keys = await cache.keys();
const cached = new Set(
keys.map(function(r) {
return decodeURIComponent(new URL(r.url).pathname);
}).filter(function(p) {
return p.startsWith('/roms/');
}).map(function(p) {
return p.slice('/roms/'.length);
})
);
document.querySelectorAll('.card[data-rom]').forEach(function(card) {
if (cached.has(card.dataset.rom)) {
card.querySelector('.cache-dot').style.display = 'block';
}
});
return cached;
} catch(e) { return new Set(); }
}
async function updateDbgBar() {
var swReg = 'none', swCtrl = 'NO';
if ('serviceWorker' in navigator) {
var reg = await navigator.serviceWorker.getRegistration('/');
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
if (navigator.serviceWorker.controller) swCtrl = 'YES';
}
var cached = await updateCacheStatus();
var dbg = document.getElementById('dbg-bar');
dbg.textContent = 'SW reg: ' + swReg + ' ctrl: ' + swCtrl
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
+ ' | Cached ROMs: ' + (cached ? cached.size : '?');
}
Promise.all([
fetch('/roms/').then(function(r) { return r.json(); }),
fetch('/favorites').then(function(r) { return r.json(); }).catch(function() { return []; })
]).then(function(results) {
allGames = results[0];
favSet = new Set(results[1]);
render(document.getElementById('search').value);
updateDbgBar();
});
window.addEventListener('online', function() {
document.getElementById('offline-banner').style.display = 'none';
syncPendingSaves();
updateDbgBar();
});
window.addEventListener('offline', function() {
document.getElementById('offline-banner').style.display = '';
updateDbgBar();
});
if (!navigator.onLine) { document.getElementById('offline-banner').style.display = ''; }
""" + _SW_REG + """\
</script>
</body>
</html>""")
# ── Player page ────────────────────────────────────────────────────────────────
PLAYER_TMPL = Template("""\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>$title</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #000; }
#game { width: 100vw; height: 100vh; }
#back {
position: fixed; top: .75rem; left: .75rem; z-index: 9999;
color: #fff; text-decoration: none; background: rgba(0,0,0,.6);
padding: .3rem .8rem; border-radius: 6px; font-family: system-ui;
}
#save-btn {
position: fixed; top: .75rem; left: 7.5rem; z-index: 9999;
color: #fff; background: rgba(0,0,0,.6); border: none;
padding: .3rem .8rem; border-radius: 6px;
font-family: system-ui; font-size: 1rem; cursor: pointer;
}
#save-btn:disabled { opacity: .6; cursor: default; }
#offline-banner {
display: none; position: fixed; top: 0; left: 0; right: 0; z-index: 9998;
background: #c0392b; color: #fff; text-align: center;
padding: .5rem; font-family: system-ui; font-size: .9rem;
}
</style>
</head>
<body>
<div id="offline-banner">Offline — saves will sync when reconnected</div>
<a id="back" href="/">&#8592; Library</a>
<button id="save-btn" onclick="manualSave()">Save</button>
<div id="game"></div>
<script>
const GAME = $game_json;
const SAVE_URL = '/saves/' + encodeURIComponent(GAME.name);
""" + _IDB_JS + """
function showOfflineBanner(msg) {
var b = document.getElementById('offline-banner');
b.textContent = msg || 'Offline — saves will sync when reconnected';
b.style.display = '';
}
function hideOfflineBanner() { document.getElementById('offline-banner').style.display = 'none'; }
async function preflightCheck() {
void('1/4 start');
try {
var swReg = 'none', swCtrl = 'NO';
if ('serviceWorker' in navigator) {
void('2/4 SW reg…');
const reg = await navigator.serviceWorker.getRegistration('/');
if (reg) swReg = reg.active ? 'active' : (reg.installing ? 'installing' : 'waiting');
if (navigator.serviceWorker.controller) swCtrl = 'YES';
}
void('3/4 cache…');
var romCached = false, romInfo = 'NOT in cache';
if ('caches' in window) {
const cache = await caches.open('emulatorjs-v4');
const hit = await cache.match('/roms/' + encodeURIComponent(GAME.name));
if (hit) {
const cl = hit.headers.get('content-length');
const sz = cl ? parseInt(cl) : -1;
romCached = sz > 0;
romInfo = sz > 0 ? 'cached ' + sz + 'B' : 'in cache but 0B (EMPTY!)';
}
}
void('4/4 done — SW: ' + swReg + '/' + swCtrl
+ ' | ROM: ' + romInfo
+ ' | Online: ' + (navigator.onLine ? 'yes' : 'NO')
+ '\n' + GAME.name);
if (!navigator.onLine && !romCached) {
showOfflineBanner('Not available offline — open this game while connected to download it.');
return false;
}
return true;
} catch(e) {
void('preflight error: ' + e + '\n' + GAME.name);
return true; // fail open — try to load EmulatorJS anyway
}
}
async function manualSave() {
const btn = document.getElementById('save-btn');
btn.disabled = true;
btn.textContent = 'Saving...';
await pushSave();
btn.textContent = 'Saved!';
setTimeout(function() { btn.textContent = 'Save'; btn.disabled = false; }, 2000);
}
async function pushSave() {
try {
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (!data || data.byteLength === 0) return;
if (navigator.onLine) {
const r = await fetch(SAVE_URL, { method: 'PUT', body: data,
headers: { 'Content-Type': 'application/octet-stream' } });
if (r.ok) return;
}
await idbPut(GAME.name, new Uint8Array(data));
} catch(e) { console.warn('save failed, stored locally', e); }
}
async function pullSave() {
try {
let sav;
if (navigator.onLine) {
const r = await fetch(SAVE_URL);
if (r.ok) sav = new Uint8Array(await r.arrayBuffer());
}
if (!sav) sav = await idbGet(GAME.name);
if (!sav || sav.byteLength === 0) return;
const gm = window.EJS_emulator.gameManager;
const path = gm.getSaveFilePath();
const parts = path.split('/');
let cp = '';
for (let i = 0; i < parts.length - 1; i++) {
if (!parts[i]) continue;
cp += '/' + parts[i];
if (!gm.FS.analyzePath(cp).exists) gm.FS.mkdir(cp);
}
if (gm.FS.analyzePath(path).exists) gm.FS.unlink(path);
gm.FS.writeFile(path, sav);
gm.loadSaveFiles();
} catch(e) { console.info('no save available', e); }
}
window.EJS_player = '#game';
window.EJS_gameUrl = '/roms/' + encodeURIComponent(GAME.name);
window.EJS_core = GAME.system;
window.EJS_pathtodata = '/static/';
window.EJS_onGameStart = function() {
// Sync offline-queued saves first so server has latest, then pull.
// Runs after EmulatorJS is ready so IDB access never blocks startup.
(navigator.onLine ? syncPendingSaves().catch(function(){}) : Promise.resolve())
.then(function() { return pullSave(); })
.then(function() {
void('running | Online: ' + (navigator.onLine ? 'yes' : 'NO') + '\n' + GAME.name);
setInterval(pushSave, 30000);
window.addEventListener('pagehide', function() {
const data = window.EJS_emulator && window.EJS_emulator.gameManager.getSaveFile();
if (data && data.byteLength > 0)
navigator.sendBeacon(SAVE_URL, new Blob([data], { type: 'application/octet-stream' }));
});
window.addEventListener('online', function() {
hideOfflineBanner();
syncPendingSaves().catch(function(){}).then(function() { pullSave(); });
preflightCheck();
});
window.addEventListener('offline', function() { showOfflineBanner(); preflightCheck(); });
if (!navigator.onLine) showOfflineBanner();
});
};
// Preflight: check cache and block if offline + ROM not cached.
// loader.js loads immediately after — syncPendingSaves runs inside EJS_onGameStart
// so IDB access never holds up the startup path.
preflightCheck().then(function(ok) {
if (!ok) return;
var s = document.createElement('script');
s.src = '/static/loader.js';
document.body.appendChild(s);
}).catch(function(e) {
void('startup error: ' + e + '\n' + GAME.name);
var s = document.createElement('script');
s.src = '/static/loader.js';
document.body.appendChild(s);
});
</script>
""" + _SW_REG + """\
</body>
</html>""")
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()
+139
View File
@@ -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/<name> → EmulatorJS player page for a specific game
# /saves/<name>→ 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.<domain> — saves go to
# /mnt/data/emulatorjs/saves/<username>/ 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 <base32-from-step-2>
# 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;
}];
};
}
+144
View File
@@ -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=<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:
# <dataDir>/mlflow/data/ → /mlflow/data (SQLite tracking DB, auth DB, basic-auth.ini)
# <dataDir>/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;
}];
};
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
set -euo pipefail
KEY_FILE="${1:?Usage: $0 <gpg-key-file> [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" <<EOF
enable-ssh-support
pinentry-program $PINENTRY
EOF
gpg --import "$KEY_FILE" || true
KEYGRIP=$(gpg --with-keygrip -k 2>/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"
+26 -23
View File
@@ -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