infra: rongorongo state-sync backend + Traefik basicAuth

Add a Flask backend service for cross-device state persistence, storing
JSON state on an NFS volume at /mnt/docker-data/configs/rongorongo/data.
Frontend Dockerfile/nginx.conf reorganised into frontend/ subdirectory;
frontend API calls now route to /api/* handled by the backend container
over a dedicated bridge network. Traefik basicAuth middleware added to
the public URL with the bcrypt hash deliberately kept out of git
(REPLACE_AT_DEPLOY placeholder in rongorongo.yml). Prometheus probe moved
from https-external to internal http://192.168.86.192:8181/health to
bypass the new auth wall.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-07 18:51:47 -05:00
parent 795908e1ef
commit a908973264
10 changed files with 354 additions and 34 deletions
+2 -2
View File
@@ -35,7 +35,7 @@ hf-pdocker-01 (23GB RAM, i7-7700T, NVMe, HD 630 GPU) -- RESTORED (2026-03-08, ha
├── [COMPOSE] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094) ├── [COMPOSE] Monitoring (Grafana :3001, Prometheus :9095, Alertmanager :9093, InfluxDB :8086, Blackbox :9115, matrix-receiver :9094)
├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server ├── [COMPOSE] Farm Manager API (:8888) -- dashboard + API server
├── [COMPOSE] Farm Agent (:8889) ├── [COMPOSE] Farm Agent (:8889)
├── [COMPOSE] Rongorongo (:8181) -- static HTML/JS decipherment engine (Anthropic API direct from browser) ├── [COMPOSE] Rongorongo decipherment engine -- 2 containers (rongorongo-web :8181, rongorongo-api). Server-synced state at /mnt/docker-data/configs/rongorongo/data. Traefik basicAuth on rongorongo.hobbs.farm.
├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04 ├── [REMOVED] VoiceCoach -- moved to GPU machine (192.168.86.150) 2026-04-04
├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host ├── [COMPOSE] Node-exporter (:9100) -- standalone with pid:host
├── [COMPOSE] Telegraf -- metrics collection to InfluxDB ├── [COMPOSE] Telegraf -- metrics collection to InfluxDB
@@ -164,7 +164,7 @@ compose/ # Standalone Compose definitions (deployed per-node)
- AI Agents (margaret :18790, spirit :18795 [infra ops — monitors #notifications, auto-remediates alerts]) -- LAN-accessible (bind=lan, 0.0.0.0) - AI Agents (margaret :18790, spirit :18795 [infra ops — monitors #notifications, auto-remediates alerts]) -- LAN-accessible (bind=lan, 0.0.0.0)
- Farm Manager: :8888 (dashboard + API) - Farm Manager: :8888 (dashboard + API)
- Farm Agent: :8889 - Farm Agent: :8889
- Rongorongo: :8181 (web at rongorongo.hobbs.farm) - Rongorongo: :8181 (rongorongo-web nginx; reverse-proxies /api/* to rongorongo-api Flask backend on the internal `rongorongo` bridge net). State + images at /mnt/docker-data/configs/rongorongo/data/. Public URL gated by Traefik basicAuth (creds in Vaultwarden).
- ~~VoiceCoach~~ -- moved to GPU machine (192.168.86.150) 2026-04-04 - ~~VoiceCoach~~ -- moved to GPU machine (192.168.86.150) 2026-04-04
**hf-pdocker-02 (192.168.86.100):** **hf-pdocker-02 (192.168.86.100):**
@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)" || exit 1
CMD ["gunicorn", "-w", "2", "-t", "60", "-b", "0.0.0.0:8000", "app:app"]
@@ -0,0 +1,139 @@
"""
Rongorongo state-sync backend.
Single-user. Stores state as one JSON file and images as content-addressed
files on disk. No DB, no auth — auth is enforced by Traefik basicAuth in
front of the whole app.
"""
import hashlib
import json
import mimetypes
import os
import tempfile
from pathlib import Path
from flask import Flask, abort, jsonify, request, send_file
DATA_DIR = Path(os.environ.get("RONGORONGO_DATA_DIR", "/data"))
STATE_FILE = DATA_DIR / "state.json"
IMAGES_DIR = DATA_DIR / "images"
MAX_IMAGE_BYTES = 25 * 1024 * 1024 # 25 MB per image
DATA_DIR.mkdir(parents=True, exist_ok=True)
IMAGES_DIR.mkdir(parents=True, exist_ok=True)
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = MAX_IMAGE_BYTES + 1024
def _atomic_write(path: Path, data: bytes) -> None:
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".tmp-", suffix=".part")
try:
with os.fdopen(fd, "wb") as fh:
fh.write(data)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except FileNotFoundError:
pass
raise
def _ext_for(mime: str) -> str:
if not mime:
return ".bin"
guess = mimetypes.guess_extension(mime) or ".bin"
# mimetypes returns .jpe for image/jpeg on some systems
return ".jpg" if guess == ".jpe" else guess
@app.get("/health")
def health():
return ("ok\n", 200, {"Content-Type": "text/plain"})
@app.get("/api/health")
def api_health():
return ("ok\n", 200, {"Content-Type": "text/plain"})
@app.get("/api/state")
def get_state():
if not STATE_FILE.exists():
return jsonify(
{
"images": [],
"glyphs": [],
"anchors": [],
"analyses": [],
"feasibilityRuns": [],
"confidenceHistory": {},
}
)
return send_file(STATE_FILE, mimetype="application/json")
@app.put("/api/state")
def put_state():
if not request.is_json:
abort(415, description="expected application/json")
payload = request.get_json(silent=False)
if not isinstance(payload, dict):
abort(400, description="state must be a JSON object")
body = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
_atomic_write(STATE_FILE, body)
return jsonify({"ok": True, "bytes": len(body)})
def _find_image(image_hash: str) -> Path | None:
for p in IMAGES_DIR.glob(f"{image_hash}.*"):
return p
return None
@app.post("/api/images")
def upload_image():
f = request.files.get("file")
if f is None or f.filename == "":
abort(400, description="missing file")
data = f.read()
if len(data) == 0:
abort(400, description="empty file")
if len(data) > MAX_IMAGE_BYTES:
abort(413, description="image too large")
digest = hashlib.sha256(data).hexdigest()
mime = f.mimetype or "application/octet-stream"
existing = _find_image(digest)
if existing is None:
target = IMAGES_DIR / f"{digest}{_ext_for(mime)}"
_atomic_write(target, data)
return jsonify(
{"hash": digest, "name": f.filename, "mimeType": mime, "size": len(data)}
)
@app.get("/api/images/<image_hash>")
def get_image(image_hash: str):
if not all(c in "0123456789abcdef" for c in image_hash) or len(image_hash) != 64:
abort(400, description="invalid hash")
p = _find_image(image_hash)
if p is None:
abort(404)
mime, _ = mimetypes.guess_type(str(p))
return send_file(p, mimetype=mime or "application/octet-stream")
@app.delete("/api/images/<image_hash>")
def delete_image(image_hash: str):
if not all(c in "0123456789abcdef" for c in image_hash) or len(image_hash) != 64:
abort(400, description="invalid hash")
p = _find_image(image_hash)
if p is None:
return ("", 204)
p.unlink()
return ("", 204)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8000)
@@ -0,0 +1,2 @@
flask==3.0.3
gunicorn==23.0.0
@@ -1,17 +1,19 @@
services: services:
rongorongo: rongorongo-api:
build: build:
context: . context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
image: rongorongo:latest image: rongorongo-api:latest
container_name: rongorongo container_name: rongorongo-api
restart: unless-stopped restart: unless-stopped
ports: networks:
- "8181:80" - rongorongo
mem_limit: 128M volumes:
memswap_limit: 128M - /mnt/docker-data/configs/rongorongo/data:/data
mem_limit: 256M
memswap_limit: 256M
healthcheck: healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost/health"] test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8000/health').status==200 else 1)"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 3 retries: 3
@@ -23,3 +25,36 @@ services:
options: options:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
rongorongo-web:
build:
context: ./frontend
dockerfile: Dockerfile
image: rongorongo-web:latest
container_name: rongorongo-web
restart: unless-stopped
depends_on:
- rongorongo-api
networks:
- rongorongo
ports:
- "8181:80"
mem_limit: 128M
memswap_limit: 128M
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
labels:
- "com.centurylinklabs.watchtower.enable=false"
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
networks:
rongorongo:
driver: bridge
@@ -2,11 +2,12 @@ FROM nginx:1.25-alpine
RUN rm -rf /usr/share/nginx/html/* RUN rm -rf /usr/share/nginx/html/*
COPY frontend/index.html /usr/share/nginx/html/index.html COPY index.html /usr/share/nginx/html/index.html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN chmod 644 /usr/share/nginx/html/index.html
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost/health || exit 1 CMD wget -qO- http://127.0.0.1/health || exit 1
EXPOSE 80 EXPOSE 80
@@ -727,6 +727,7 @@ select option { background: var(--surface); }
<div class="sidebar-footer"> <div class="sidebar-footer">
<label class="api-key-label">Anthropic API Key</label> <label class="api-key-label">Anthropic API Key</label>
<input class="api-key-input" type="password" id="apiKeyInput" placeholder="sk-ant-…" oninput="saveApiKey(this.value)"> <input class="api-key-input" type="password" id="apiKeyInput" placeholder="sk-ant-…" oninput="saveApiKey(this.value)">
<div id="syncStatus" style="margin-top:0.5rem;font-size:0.75rem;color:#888;">● synced</div>
</div> </div>
</nav> </nav>
@@ -1167,32 +1168,107 @@ const S = {
confidenceHistory: {}, // anchorId -> [{ts, score}] confidenceHistory: {}, // anchorId -> [{ts, score}]
}; };
// ── PERSISTENCE ──────────────────────────── // ── PERSISTENCE (server-synced via /api/state) ────────────────────────
let _saveTimer = null;
let _saveSuppressed = false; // true while load() is rehydrating
let _loaded = false; // true after first successful load
function save() { function save() {
if (_saveSuppressed || !_loaded) return;
clearTimeout(_saveTimer);
_saveTimer = setTimeout(_flushSave, 400);
}
async function _flushSave() {
try { try {
const snap = { const snap = {
images: S.images.map(i => ({ name: i.name, mimeType: i.mimeType, hash: i.hash })),
glyphs: S.glyphs, glyphs: S.glyphs,
anchors: S.anchors, anchors: S.anchors,
analyses: S.analyses.slice(0,10), analyses: S.analyses.slice(0,10),
feasibilityRuns: S.feasibilityRuns.slice(0,5), feasibilityRuns: S.feasibilityRuns.slice(0,5),
confidenceHistory: S.confidenceHistory, confidenceHistory: S.confidenceHistory,
}; };
localStorage.setItem('rongo_state', JSON.stringify(snap)); const r = await fetch('/api/state', {
} catch(e) {} method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(snap),
});
if (!r.ok) throw new Error('save failed: HTTP ' + r.status);
setSyncStatus('synced');
} catch (e) {
setSyncStatus('error', e.message);
}
} }
function load() { function setSyncStatus(state, detail) {
const el = document.getElementById('syncStatus');
if (!el) return;
if (state === 'syncing') { el.textContent = '⟳ syncing…'; el.style.color = '#888'; }
else if (state === 'synced') { el.textContent = '● synced'; el.style.color = '#4caf50'; }
else if (state === 'error') { el.textContent = '✕ sync error'; el.style.color = '#e57373'; el.title = detail||''; }
else if (state === 'loading') { el.textContent = '⟳ loading…'; el.style.color = '#888'; }
}
async function _hydrateImage(meta) {
// Fetch image bytes by hash and rebuild {dataUrl, base64} in memory.
const r = await fetch('/api/images/' + encodeURIComponent(meta.hash));
if (!r.ok) throw new Error('image fetch failed: ' + meta.hash + ' HTTP ' + r.status);
const blob = await r.blob();
const dataUrl = await new Promise((res, rej) => {
const fr = new FileReader();
fr.onload = () => res(fr.result);
fr.onerror = () => rej(fr.error);
fr.readAsDataURL(blob);
});
return {
name: meta.name,
mimeType: meta.mimeType || blob.type || 'image/jpeg',
hash: meta.hash,
dataUrl,
base64: dataUrl.split(',')[1],
};
}
async function load() {
setSyncStatus('loading');
_saveSuppressed = true;
let imgMetas = [];
try { try {
const raw = localStorage.getItem('rongo_state'); const r = await fetch('/api/state');
if (!raw) return; if (!r.ok) throw new Error('GET /api/state HTTP ' + r.status);
const snap = JSON.parse(raw); const snap = await r.json();
S.glyphs = snap.glyphs || []; S.glyphs = snap.glyphs || [];
S.anchors = snap.anchors || []; S.anchors = snap.anchors || [];
S.analyses = snap.analyses || []; S.analyses = snap.analyses || [];
S.feasibilityRuns = snap.feasibilityRuns || []; S.feasibilityRuns = snap.feasibilityRuns || [];
S.confidenceHistory = snap.confidenceHistory || {}; S.confidenceHistory = snap.confidenceHistory || {};
imgMetas = snap.images || [];
S.images = imgMetas.map(m => ({
name: m.name, mimeType: m.mimeType, hash: m.hash,
dataUrl: '', base64: '',
}));
renderImageStrip();
refreshAll(); refreshAll();
} catch(e) {} setSyncStatus('synced');
} catch (e) {
setSyncStatus('error', e.message);
console.error('load failed', e);
} finally {
_saveSuppressed = false;
_loaded = true;
}
// Hydrate image bytes in parallel; render thumbs as each arrives.
// Don't block save() on this — save snapshots only metadata anyway.
await Promise.all(imgMetas.map(async (meta, idx) => {
try {
const full = await _hydrateImage(meta);
Object.assign(S.images[idx], full);
renderImageStrip();
} catch (e) {
console.error('image hydrate failed', meta, e);
}
}));
} }
function saveApiKey(v) { function saveApiKey(v) {
@@ -1290,17 +1366,35 @@ uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('over
uploadZone.addEventListener('drop', e => { e.preventDefault(); uploadZone.classList.remove('over'); handleFiles([...e.dataTransfer.files].filter(f=>f.type.startsWith('image/'))); }); uploadZone.addEventListener('drop', e => { e.preventDefault(); uploadZone.classList.remove('over'); handleFiles([...e.dataTransfer.files].filter(f=>f.type.startsWith('image/'))); });
fileInput.addEventListener('change', e => handleFiles([...e.target.files])); fileInput.addEventListener('change', e => handleFiles([...e.target.files]));
function handleFiles(files) { async function handleFiles(files) {
files.forEach(f => { for (const f of files) {
const fr = new FileReader(); try {
fr.onload = e => { const dataUrl = await new Promise((res, rej) => {
S.images.push({ name: f.name, dataUrl: e.target.result, base64: e.target.result.split(',')[1], mimeType: f.type||'image/jpeg' }); const fr = new FileReader();
fr.onload = e => res(e.target.result);
fr.onerror = () => rej(fr.error);
fr.readAsDataURL(f);
});
const fd = new FormData();
fd.append('file', f, f.name);
const r = await fetch('/api/images', { method: 'POST', body: fd });
if (!r.ok) throw new Error('upload failed: HTTP ' + r.status);
const meta = await r.json();
S.images.push({
name: f.name,
mimeType: f.type || meta.mimeType || 'image/jpeg',
hash: meta.hash,
dataUrl,
base64: dataUrl.split(',')[1],
});
renderImageStrip(); renderImageStrip();
refreshAll(); refreshAll();
toast(`Loaded ${f.name}`); toast(`Loaded ${f.name}`);
}; } catch (e) {
fr.readAsDataURL(f); console.error('upload failed', f.name, e);
}); toast(`Upload failed: ${f.name}`);
}
}
} }
function renderImageStrip() { function renderImageStrip() {
@@ -1312,10 +1406,17 @@ function renderImageStrip() {
</div>`).join(''); </div>`).join('');
} }
function removeImage(i) { async function removeImage(i) {
const img = S.images[i];
if (!img) return;
S.images.splice(i,1); S.images.splice(i,1);
renderImageStrip(); renderImageStrip();
refreshAll(); refreshAll();
if (img.hash) {
// best-effort backend delete; safe to ignore failures (state is already saved)
try { await fetch('/api/images/' + encodeURIComponent(img.hash), { method: 'DELETE' }); }
catch(e) { console.warn('image delete failed on server', img.hash, e); }
}
} }
function refreshAll() { function refreshAll() {
@@ -2032,12 +2133,17 @@ function downloadReport() {
toast('Session exported'); toast('Session exported');
} }
function clearSession() { async function clearSession() {
if (!confirm('Clear all session data? This cannot be undone.')) return; if (!confirm('Clear all session data? This cannot be undone.')) return;
const oldHashes = S.images.map(i => i.hash).filter(Boolean);
S.images=[]; S.glyphs=[]; S.anchors=[]; S.analyses=[]; S.feasibilityRuns=[]; S.confidenceHistory={}; S.images=[]; S.glyphs=[]; S.anchors=[]; S.analyses=[]; S.feasibilityRuns=[]; S.confidenceHistory={};
try { localStorage.removeItem('rongo_state'); } catch(e){}
refreshAll(); refreshAll();
renderImageStrip(); renderImageStrip();
// Force-flush an empty state immediately, then drop image blobs server-side.
await _flushSave();
await Promise.all(oldHashes.map(h =>
fetch('/api/images/' + encodeURIComponent(h), { method: 'DELETE' }).catch(()=>{})
));
toast('Session cleared'); toast('Session cleared');
} }
@@ -5,6 +5,8 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
client_max_body_size 30m;
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always; add_header X-XSS-Protection "1; mode=block" always;
@@ -14,7 +16,7 @@ server {
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://api.anthropic.com; worker-src blob:;" always; add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: blob:; connect-src 'self' https://api.anthropic.com; worker-src blob:;" always;
gzip on; gzip on;
gzip_types text/html text/css application/javascript; gzip_types text/html text/css application/javascript application/json;
gzip_min_length 1024; gzip_min_length 1024;
location /health { location /health {
@@ -23,6 +25,19 @@ server {
add_header Content-Type text/plain; add_header Content-Type text/plain;
} }
location /api/ {
resolver 127.0.0.11 valid=10s;
set $backend "http://rongorongo-api:8000";
proxy_pass $backend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_request_buffering off;
proxy_read_timeout 60s;
}
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
expires -1; expires -1;
+1 -1
View File
@@ -70,6 +70,7 @@ scrape_configs:
- http://192.168.86.192:9093 # Alertmanager - http://192.168.86.192:9093 # Alertmanager
- http://192.168.86.192:18795 # Spirit (infra ops agent) - http://192.168.86.192:18795 # Spirit (infra ops agent)
- http://192.168.86.192:8085 # VoiceCoach web client - http://192.168.86.192:8085 # VoiceCoach web client
- http://192.168.86.192:8181/health # Rongorongo (probe internal — Traefik basicAuth blocks external)
- http://192.168.86.192:7880 # VoiceCoach LiveKit server - http://192.168.86.192:7880 # VoiceCoach LiveKit server
relabel_configs: relabel_configs:
- source_labels: [__address__] - source_labels: [__address__]
@@ -153,7 +154,6 @@ scrape_configs:
- https://vault.hobbs.farm # Vaultwarden - https://vault.hobbs.farm # Vaultwarden
- https://git.hobbs.farm # Gitea - https://git.hobbs.farm # Gitea
- https://coach.hobbs.farm # VoiceCoach web client - https://coach.hobbs.farm # VoiceCoach web client
- https://rongorongo.hobbs.farm # Rongorongo decipherment engine
relabel_configs: relabel_configs:
- source_labels: [__address__] - source_labels: [__address__]
target_label: __param_target target_label: __param_target
+7
View File
@@ -5,6 +5,7 @@ http:
entryPoints: entryPoints:
- websecure - websecure
middlewares: middlewares:
- rongorongo-auth
- security-headers - security-headers
service: rongorongo service: rongorongo
tls: tls:
@@ -21,6 +22,12 @@ http:
redirectScheme: redirectScheme:
scheme: https scheme: https
permanent: true permanent: true
rongorongo-auth:
basicAuth:
realm: "rongorongo.hobbs.farm"
# users entries are appended at deploy time as: <user>:<bcrypt-hash>
users:
- "REPLACE_AT_DEPLOY"
services: services:
rongorongo: rongorongo:
loadBalancer: loadBalancer: