Archipelago — open-source initial import

This commit is contained in:
Archipelago
2026-08-12 10:55:49 +00:00
commit 081dab5934
2056 changed files with 468143 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
# Stable symlink for USB serial adapters used as mesh radios.
# Creates /dev/mesh-radio pointing to the underlying ttyUSB device.
# Supports MeshCore and Meshtastic radios using CP2102 (Heltec V3),
# CH340 (T-Beam), FTDI (RAK WisBlock), and known USB CDC ACM radios.
SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{idProduct}=="ea60", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="1a86", ATTRS{idProduct}=="7523", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="0403", ATTRS{idProduct}=="6001", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
SUBSYSTEM=="tty", ATTRS{idVendor}=="2e8a", KERNEL=="ttyACM[0-9]*", SYMLINK+="mesh-radio", MODE="0660", GROUP="dialout"
@@ -0,0 +1,20 @@
[Unit]
Description=Archipelago audio router (HDMI hot-plug follow + ELD boot-race heal)
# Talks to the archipelago user's PipeWire (running under the lingering user
# manager) and pokes the kiosk X server for the ELD re-modeset nudge; start
# after both are plausibly up. Missing/inactive units here are harmless.
After=user@1000.service archipelago-kiosk.service
Wants=user@1000.service
ConditionPathExists=/usr/local/bin/archipelago-audio-router
[Service]
Type=simple
User=archipelago
ExecStart=/usr/local/bin/archipelago-audio-router
Restart=always
RestartSec=10
# Polls a few pactl calls every 5s — keep it invisible to the scheduler.
Nice=10
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,145 @@
#!/bin/bash
# Archipelago audio router — keep audio flowing out of the display the user is
# actually looking at.
#
# The kiosk's Chromium plays through PipeWire-Pulse, but WirePlumber's stock
# profile priorities rank the laptop's analog output above HDMI, so a node
# driving a TV plays video sound out of its own tiny speakers (or nowhere).
# ALSA jack detection (ELD) tells us when an HDMI/DP sink has a listening
# monitor; this daemon polls that via the card's profile availability and:
#
# - switches the card to the best *available* HDMI stereo profile (the
# "+input:analog-stereo" combined variant when offered, so the mic keeps
# working), and back to analog when HDMI is unplugged;
# - keeps the default sink pointed at the routed output and migrates any
# live streams so playback follows a hot-plug without a page reload;
# - unmutes the routed sink (HDMI additionally forced to 100% — the TV owns
# the real volume control; analog volume is left where the user set it).
#
# Runs as the archipelago user (systemd system unit with User=archipelago),
# talks only to the user-session PipeWire; polling is a few pactl calls every
# 5s — negligible. Surround profiles are deliberately ignored: stereo is the
# lowest-common-denominator every TV decodes.
# - re-modesets an external output once when it is connected but no ELD
# reports a monitor: the kiosk's boot-time Xorg modeset can beat the
# i915→HDA audio-component bind, the ELD notify is lost, and every HDMI
# profile stays "available: no" forever (no sound, no error). One
# off/on cycle re-delivers the ELD (verified on Framework PT / LG TV).
RUNTIME_DIR="/run/user/$(id -u)"
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-$RUNTIME_DIR}"
export DISPLAY="${DISPLAY:-:0}"
# The user manager socket-activates pipewire, but after a live package install
# (bootstrap self-heal) nothing has poked it yet — start best-effort.
systemctl --user start pipewire.socket pipewire-pulse.socket 2>/dev/null || true
systemctl --user start wireplumber.service 2>/dev/null || true
LAST_SINK=""
NUDGED_OUTPUTS=""
# Re-deliver a lost ELD by cycling the connected external output once. Guarded:
# only when X answers, only when NO ELD anywhere reports a monitor, and only
# once per connector while it stays connected (a display with no audio support
# never produces an ELD — without the flag we would blank it every pass).
eld_nudge_once() {
# Any ELD already valid → audio path is live; reset the nudge memory.
if grep -q "monitor_present[[:space:]]*1" /proc/asound/card*/eld* 2>/dev/null; then
NUDGED_OUTPUTS=""
return 0
fi
local xrandr_out conn name output mode
xrandr_out=$(xrandr --query 2>/dev/null) || return 0
for conn in /sys/class/drm/card*-*/status; do
[ -e "$conn" ] || continue
[ "$(cat "$conn" 2>/dev/null)" = "connected" ] || continue
name=${conn%/status}; name=${name##*/card?-}
case "$name" in eDP*|LVDS*) continue ;; esac
case " $NUDGED_OUTPUTS " in *" $name "*) continue ;; esac
# DRM connector names match the modesetting driver's output names.
output=$(printf '%s\n' "$xrandr_out" | awk -v n="$name" '$1 == n && $2 == "connected" {print $1; exit}')
[ -n "$output" ] || continue
# Keep the mode the kiosk chose (it may have capped a 4K panel);
# --auto only as a fallback.
mode=$(printf '%s\n' "$xrandr_out" | awk -v out="$output" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ { if ($0 ~ /\*/) { print $1; exit } }
active && /^[^[:space:]]/ { active = 0 }')
NUDGED_OUTPUTS="$NUDGED_OUTPUTS $name"
xrandr --output "$output" --off 2>/dev/null || true
sleep 1
if [ -n "$mode" ]; then
xrandr --output "$output" --mode "$mode" 2>/dev/null \
|| xrandr --output "$output" --auto 2>/dev/null || true
else
xrandr --output "$output" --auto 2>/dev/null || true
fi
sleep 2
done
}
route_once() {
local cards_dump card active want sink default_sink
cards_dump=$(pactl list cards 2>/dev/null) || return 0
[ -n "$cards_dump" ] || return 0
# One line per card: "<card>\t<active>\t<wanted-profile>"
# wanted = highest-priority available output:hdmi-stereo* profile, else
# highest-priority available output:analog* profile.
while IFS=$'\t' read -r card active want; do
[ -n "$card" ] && [ -n "$want" ] || continue
if [ "$active" != "$want" ]; then
pactl set-card-profile "$card" "$want" 2>/dev/null || true
fi
done < <(printf '%s\n' "$cards_dump" | awk '
function flush() {
if (card != "") print card "\t" active "\t" (hdmi != "" ? hdmi : analog)
card=""; active=""; hdmi=""; analog=""; hdmi_p=-1; analog_p=-1
}
/^Card #/ { flush() }
/^\tName: / { card=$2 }
/^\t\toutput:/ {
line=$0; sub(/^\t\t/, "", line)
prof=line; sub(/: .*/, "", prof)
prio=0
if (match(line, /priority: [0-9]+/)) prio=substr(line, RSTART+10, RLENGTH-10)+0
avail = (line ~ /available: yes/ || line ~ /availability unknown/)
if (!avail) next
if (prof ~ /^output:hdmi-stereo/) { if (prio > hdmi_p) { hdmi=prof; hdmi_p=prio } }
else if (prof ~ /^output:analog/) { if (prio > analog_p) { analog=prof; analog_p=prio } }
}
/^\tActive Profile: / { active=$3 }
END { flush() }
')
# Point the default sink at HDMI when one exists, else the first sink.
sink=$(pactl list short sinks 2>/dev/null | awk '/hdmi/{print $2; exit}')
[ -n "$sink" ] || sink=$(pactl list short sinks 2>/dev/null | awk 'NR==1{print $2}')
[ -n "$sink" ] || return 0
default_sink=$(pactl get-default-sink 2>/dev/null)
if [ "$sink" != "$default_sink" ] || [ "$sink" != "$LAST_SINK" ]; then
pactl set-default-sink "$sink" 2>/dev/null || true
pactl set-sink-mute "$sink" 0 2>/dev/null || true
case "$sink" in
*hdmi*) pactl set-sink-volume "$sink" 100% 2>/dev/null || true ;;
esac
# Migrate live streams so playing audio follows the hot-plug.
pactl list short sink-inputs 2>/dev/null | while read -r id _; do
pactl move-sink-input "$id" "$sink" 2>/dev/null || true
done
LAST_SINK="$sink"
fi
}
while true; do
eld_nudge_once
route_once
sleep 5
done
@@ -0,0 +1,12 @@
[Unit]
Description=Archipelago Container Doctor
After=archipelago.service
[Service]
Type=oneshot
# Runs as root: needs to kill orphaned conmon processes, fix permissions
User=root
ExecStart=/home/archipelago/archy/scripts/container-doctor.sh --local
TimeoutStartSec=300
StandardOutput=journal
StandardError=journal
@@ -0,0 +1,14 @@
[Unit]
Description=Archipelago container doctor (periodic)
[Timer]
# First run 2 minutes after boot, then every 5 minutes. The doctor is
# idempotent and exits quickly when no drift exists; this keeps vanished
# rootless port listeners and stopped containers from remaining broken.
OnBootSec=2min
OnUnitActiveSec=5min
# Jitter to avoid load spikes
RandomizedDelaySec=60
[Install]
WantedBy=timers.target
@@ -0,0 +1,21 @@
[Unit]
Description=Archipelago FIPS mesh transport (wraps upstream fips daemon)
# Stay dark until onboarding materialises the seed-derived key. Archipelago
# backend unmasks + starts this unit via `sudo systemctl` once the key is
# present; pre-onboarding the unit must be masked so no traffic is sent
# from an ephemeral identity.
ConditionPathExists=/var/lib/archipelago/identity/fips_key
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStartPre=/bin/sh -c 'test -x /usr/bin/fips || { echo "fips daemon not installed — run fips.install from dashboard" >&2; exit 1; }'
ExecStart=/usr/bin/fips --config /etc/fips/fips.yaml
Restart=on-failure
RestartSec=5
# UDP 8668 is reachable on all interfaces by default; the daemon does its
# own Noise authentication so no firewall gate is added here.
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""Archipelago gamepad→keyboard bridge (kiosk nodes).
Reads every attached game controller via evdev and mirrors it as a virtual
uinput KEYBOARD, so gamepad input works in every app — including cross-origin
iframes (IndeeHub, Jellyfin, …) where the web shell can never inject events.
The browser just sees arrow/Enter/Escape keys from a real-looking keyboard;
X autorepeat handles held directions. Design: docs/tv-input-iframe-apps.md.
Mapping (standard pad):
D-pad / left stick -> arrow keys
A (BTN_SOUTH) -> Enter B (BTN_EAST) -> Escape
X (BTN_NORTH/WEST*) -> Space Y -> f (player fullscreen)
LB (BTN_TL) -> Shift+Tab RB (BTN_TR) -> Tab
Start -> Enter Select -> Escape
*Controllers disagree on NORTH/WEST for X/Y; both map to Space/f — either way
one is play/pause and one is fullscreen, which is fine for a TV.
Pure stdlib (struct/fcntl/select) — no python3-evdev dependency on the node.
Runs as root (uinput + /dev/input need it); hotplug via 5s rescans.
"""
import fcntl
import os
import select
import struct
import time
# ---- kernel constants ------------------------------------------------------
EV_SYN, EV_KEY, EV_ABS = 0x00, 0x01, 0x03
SYN_REPORT = 0
KEY_ESC, KEY_TAB, KEY_ENTER, KEY_SPACE = 1, 15, 28, 57
KEY_LEFTSHIFT, KEY_F = 42, 33
KEY_UP, KEY_LEFT, KEY_RIGHT, KEY_DOWN = 103, 105, 106, 108
BTN_SOUTH, BTN_EAST, BTN_NORTH, BTN_WEST = 0x130, 0x131, 0x133, 0x134
BTN_TL, BTN_TR, BTN_SELECT, BTN_START = 0x136, 0x137, 0x13A, 0x13B
BTN_DPAD_UP, BTN_DPAD_DOWN, BTN_DPAD_LEFT, BTN_DPAD_RIGHT = 0x220, 0x221, 0x222, 0x223
ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11
EVIOCGBIT_EV_KEY = 0x80604521 # EVIOCGBIT(EV_KEY, 96) — enough for BTN range
UI_SET_EVBIT, UI_SET_KEYBIT = 0x40045564, 0x40045565
UI_DEV_CREATE, UI_DEV_DESTROY = 0x5501, 0x5502
INPUT_EVENT = struct.Struct("llHHi") # timeval sec/usec, type, code, value
BUTTON_MAP = {
BTN_SOUTH: (KEY_ENTER,),
BTN_EAST: (KEY_ESC,),
BTN_NORTH: (KEY_SPACE,),
BTN_WEST: (KEY_F,),
BTN_TL: (KEY_LEFTSHIFT, KEY_TAB),
BTN_TR: (KEY_TAB,),
BTN_START: (KEY_ENTER,),
BTN_SELECT: (KEY_ESC,),
BTN_DPAD_UP: (KEY_UP,),
BTN_DPAD_DOWN: (KEY_DOWN,),
BTN_DPAD_LEFT: (KEY_LEFT,),
BTN_DPAD_RIGHT: (KEY_RIGHT,),
}
EMITTED_KEYS = sorted({k for keys in BUTTON_MAP.values() for k in keys}
| {KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT})
STICK_THRESHOLD = 0.55 # fraction of full deflection before a stick "presses"
def is_gamepad(fd) -> bool:
buf = bytearray(96)
try:
fcntl.ioctl(fd, EVIOCGBIT_EV_KEY, buf)
except OSError:
return False
def has(code):
return bool(buf[code // 8] & (1 << (code % 8)))
return has(BTN_SOUTH) or has(BTN_START)
class VirtualKeyboard:
def __init__(self):
self.fd = os.open("/dev/uinput", os.O_WRONLY | os.O_NONBLOCK)
fcntl.ioctl(self.fd, UI_SET_EVBIT, EV_KEY)
for key in EMITTED_KEYS:
fcntl.ioctl(self.fd, UI_SET_KEYBIT, key)
# Legacy uinput_user_dev setup struct: name[80] + input_id + ff_effects
# + absmax/absmin/absfuzz/absflat (64 ints each) — works on every kernel.
name = b"Archipelago Gamepad Keys"
setup = name.ljust(80, b"\0") + struct.pack("HHHHi", 0x06, 0x1, 0x1, 1, 0)
setup += b"\0" * (64 * 4 * 4)
os.write(self.fd, setup)
fcntl.ioctl(self.fd, UI_DEV_CREATE)
def _emit(self, etype, code, value):
os.write(self.fd, INPUT_EVENT.pack(0, 0, etype, code, value))
def set_key(self, key, pressed):
self._emit(EV_KEY, key, 1 if pressed else 0)
self._emit(EV_SYN, SYN_REPORT, 0)
def chord(self, keys, pressed):
seq = keys if pressed else tuple(reversed(keys))
for k in seq:
self._emit(EV_KEY, k, 1 if pressed else 0)
self._emit(EV_SYN, SYN_REPORT, 0)
class PadState:
"""Per-device axis state → synthetic arrow presses."""
def __init__(self):
self.axis_keys = {} # axis -> currently-pressed arrow key (or None)
self.abs_range = {} # axis -> (min, max) for sticks
def arrow_for(self, axis, value):
if axis in (ABS_HAT0X, ABS_HAT0Y):
if value < 0:
return KEY_LEFT if axis == ABS_HAT0X else KEY_UP
if value > 0:
return KEY_RIGHT if axis == ABS_HAT0X else KEY_DOWN
return None
lo, hi = self.abs_range.get(axis, (-32768, 32767))
span = (hi - lo) or 1
norm = (2 * (value - lo) / span) - 1
if norm <= -STICK_THRESHOLD:
return KEY_LEFT if axis == ABS_X else KEY_UP
if norm >= STICK_THRESHOLD:
return KEY_RIGHT if axis == ABS_X else KEY_DOWN
return None
def stick_range(fd, axis):
# EVIOCGABS(axis): struct input_absinfo { value, min, max, fuzz, flat, res }
buf = bytearray(24)
try:
fcntl.ioctl(fd, 0x80184540 + axis, buf)
_, lo, hi = struct.unpack("iii", bytes(buf[:12]))
if hi > lo:
return (lo, hi)
except OSError:
pass
return (-32768, 32767)
def main():
os.system("modprobe uinput 2>/dev/null")
kbd = VirtualKeyboard()
pads = {} # path -> (fd, PadState)
last_scan = 0.0
while True:
now = time.monotonic()
if now - last_scan > 5:
last_scan = now
try:
names = sorted(os.listdir("/dev/input"))
except FileNotFoundError:
names = []
for name in names:
if not name.startswith("event"):
continue
path = "/dev/input/" + name
if path in pads:
continue
try:
fd = os.open(path, os.O_RDONLY | os.O_NONBLOCK)
except OSError:
continue
if is_gamepad(fd):
state = PadState()
for axis in (ABS_X, ABS_Y):
state.abs_range[axis] = stick_range(fd, axis)
pads[path] = (fd, state)
print(f"gamepad attached: {path}", flush=True)
else:
os.close(fd)
if not pads:
time.sleep(2)
continue
readable, _, _ = select.select([fd for fd, _ in pads.values()], [], [], 2.0)
gone = []
for path, (fd, state) in list(pads.items()):
if fd not in readable:
continue
try:
data = os.read(fd, INPUT_EVENT.size * 64)
except OSError:
gone.append(path)
continue
for off in range(0, len(data) - INPUT_EVENT.size + 1, INPUT_EVENT.size):
_, _, etype, code, value = INPUT_EVENT.unpack_from(data, off)
if etype == EV_KEY and code in BUTTON_MAP and value in (0, 1):
keys = BUTTON_MAP[code]
if len(keys) == 1:
kbd.set_key(keys[0], value == 1)
else:
kbd.chord(keys, value == 1)
elif etype == EV_ABS and code in (ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y):
want = state.arrow_for(code, value)
held = state.axis_keys.get(code)
if want != held:
if held is not None:
kbd.set_key(held, False)
if want is not None:
kbd.set_key(want, True)
state.axis_keys[code] = want
for path in gone:
fd, state = pads.pop(path)
for held in state.axis_keys.values():
if held is not None:
kbd.set_key(held, False)
try:
os.close(fd)
except OSError:
pass
print(f"gamepad detached: {path}", flush=True)
if __name__ == "__main__":
main()
@@ -0,0 +1,16 @@
[Unit]
Description=Archipelago gamepad->keyboard bridge (TV/kiosk input in every app iframe)
# Only meaningful where a display UI runs; the kiosk unit is the marker.
ConditionPathExists=/etc/systemd/system/archipelago-kiosk.service
ConditionPathExists=/usr/local/bin/archipelago-gamepad-keys
[Service]
Type=simple
# Root: /dev/uinput device creation + raw /dev/input readers.
ExecStart=/usr/bin/python3 /usr/local/bin/archipelago-gamepad-keys
Restart=always
RestartSec=10
Nice=5
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,28 @@
[Unit]
Description=Archipelago host-secret audit (are this node's SSH/TLS keys per-node?)
Documentation=file:///opt/archipelago/scripts/security/host-secrets-audit.sh
# Ordered after first-boot regeneration so a fresh node is judged on the keys
# it ends up with, not the ones it booted with. network.target because the
# fingerprints are only meaningful once the node has an identity to report as.
After=archipelago-first-boot-secrets.service network.target
ConditionPathExists=/opt/archipelago/scripts/security/host-secrets-audit.sh
[Service]
Type=oneshot
# Reads /etc/ssh and /etc/archipelago/ssl and writes
# /var/lib/archipelago/host-secrets-audit.json, all of which are root-owned.
User=root
# DETECT ONLY. D-06 chose detect-report-then-apply
# rotation is one-way and must never
# fire unattended across the fleet during an OTA. There is deliberately NO
# --apply here. Adding one is a decision, not a configuration change.
ExecStart=-/opt/archipelago/scripts/security/host-secrets-audit.sh --detect
# The leading `-` above: a failed audit must never fail a boot. The verdict is
# informational; a node that cannot be judged is still a node that must come up.
TimeoutStartSec=60
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,220 @@
#!/bin/bash
# Start a dedicated X server for the attached kiosk display.
/usr/bin/Xorg :0 vt1 -nolisten tcp -keeptty &
XPID=$!
export DISPLAY=:0
export HOME=/home/archipelago
X_READY=false
for _ in $(seq 1 30); do
if kill -0 "$XPID" 2>/dev/null && xrandr --query >/tmp/archipelago-kiosk-xrandr.txt 2>/dev/null; then
X_READY=true
break
fi
sleep 0.5
done
if [ "$X_READY" != "true" ]; then
echo 'ERROR: Xorg failed to become ready'
kill "$XPID" 2>/dev/null || true
exit 1
fi
# Settings-managed display overrides (system.kiosk-display.set writes this
# file, then restarts the kiosk): may set ARCHIPELAGO_KIOSK_SCALE,
# ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH or ARCHIPELAGO_KIOSK_MAX_WIDTH.
[ -f /etc/archipelago/kiosk-display.conf ] && . /etc/archipelago/kiosk-display.conf
KIOSK_SAFE_AREA_X_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_X_PX:-}
KIOSK_SAFE_AREA_Y_PX=${ARCHIPELAGO_KIOSK_SAFE_AREA_Y_PX:-}
# Actual mode of the kiosk output — configure_display overwrites these so
# Chromium's window matches the panel instead of assuming 1080p (a 1366x768
# laptop panel otherwise gets a clipped oversized window).
KIOSK_MODE_W=1920
KIOSK_MODE_H=1080
configure_display() {
command -v xrandr >/dev/null 2>&1 || return 0
local output mode internal width height
output=$(awk '/ connected/ && $1 !~ /^eDP|^LVDS/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || output=$(awk '/ connected/{print $1; exit}' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$output" ] || return 0
# Pick the EDID-preferred ("+") mode, falling back to the first-listed
# mode (EDID lists native first). Deliberately ignore "*" (currently
# active) — trusting "active" lets a bad clone/mirror state from a
# previous boot perpetuate itself forever instead of self-healing.
mode=$(awk -v out="$output" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
if ($0 ~ /\+/ && !preferred) { preferred = $1 }
if (!first) first = $1
}
active && /^[^[:space:]]/ { active = 0 }
END { if (preferred) print preferred; else if (first) print first }
' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$mode" ] || mode=1920x1080
# Optional resolution cap for weak hardware: ARCHIPELAGO_KIOSK_MAX_WIDTH=1920
# drops a 4K panel to its best <=1920-wide mode (the TV upscales) so the
# software rasterizer paints 1/4 the pixels. Off by default — native mode
# is sharp and fits the tile budget now that --window-size is in DIPs.
local max_w=${ARCHIPELAGO_KIOSK_MAX_WIDTH:-0}
if [ "$max_w" -gt 0 ] 2>/dev/null && [ "${mode%x*}" -gt "$max_w" ] 2>/dev/null; then
local capped
capped=$(awk -v out="$output" -v maxw="$max_w" '
$1 == out { active = 1; next }
active && /^[[:space:]]+[0-9]+x[0-9]+/ {
split($1, wh, "x")
if (wh[1] + 0 <= maxw && wh[1] + 0 > best_w) { best_w = wh[1] + 0; best = $1 }
}
active && /^[^[:space:]]/ { active = 0 }
END { if (best) print best }
' /tmp/archipelago-kiosk-xrandr.txt)
[ -n "$capped" ] && mode=$capped
fi
# Kiosk should use one native output. A spanning desktop makes Chromium land
# on the laptop panel or stretch across both outputs.
for internal in $(awk '/ connected/ && $1 ~ /^eDP|^LVDS/{print $1}' /tmp/archipelago-kiosk-xrandr.txt); do
[ "$internal" = "$output" ] || xrandr --output "$internal" --off 2>/dev/null || true
done
xrandr --output "$output" \
--primary \
--mode "$mode" \
--pos 0x0 \
--scale 1x1 \
--panning 0x0 \
--transform none 2>/dev/null || true
width=${mode%x*}
height=${mode#*x}
case "$width:$height" in *[!0-9:]*|:*) width=1920; height=1080 ;; esac
KIOSK_MODE_W=$width
KIOSK_MODE_H=$height
# Browser safe-area fallback for TVs that crop edges. Driver underscan is
# preferable, but many Intel HDMI outputs do not expose that property.
KIOSK_SAFE_AREA_X_PX=${KIOSK_SAFE_AREA_X_PX:-$((width * 3 / 100))}
KIOSK_SAFE_AREA_Y_PX=${KIOSK_SAFE_AREA_Y_PX:-$((height * 3 / 100))}
}
configure_display
# --- Kiosk UI scaling for large / high-res displays -----------------------
# REVERT: set env ARCHIPELAGO_KIOSK_SCALE=1 (per-node, no rebuild), or restore
# the hardcoded --force-device-scale-factor=1 below to disable entirely.
#
# A big TV reports its full native resolution as the CSS viewport, so a 4K
# panel becomes a 3840px-wide viewport and the UI renders tiny — and a
# keyboard-less kiosk can't zoom. Derive Chromium's device-scale-factor from
# the detected panel width so the *effective* CSS viewport lands near a
# comfortable target. Panels >=2560 wide get a 1920-wide layout (4K -> scale
# 2.0 — user-validated on a 72" TV: spacious desktop layout, 2x sharp);
# smaller panels get a 1280-wide layout (1920 -> 1.50, laptops -> 1.0).
if [ -z "${ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH:-}" ]; then
if [ "$KIOSK_MODE_W" -ge 2560 ] 2>/dev/null; then
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1920
else
ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH=1280
fi
fi
KIOSK_TARGET_CSS_WIDTH=$ARCHIPELAGO_KIOSK_TARGET_CSS_WIDTH
if [ -n "${ARCHIPELAGO_KIOSK_SCALE:-}" ]; then
KIOSK_SCALE=$ARCHIPELAGO_KIOSK_SCALE
else
KIOSK_SCALE=$(awk -v w="$KIOSK_MODE_W" -v t="$KIOSK_TARGET_CSS_WIDTH" \
'BEGIN{if(t<=0)t=1280; s=w/t; s=int(s*4+0.5)/4; if(s<1)s=1; if(s>3)s=3; printf "%.2f", s}')
fi
# Chromium's --window-size is in DIPs (CSS px), not physical pixels: at scale S
# the window paints S x larger. This X session has no window manager, so
# --kiosk/--start-fullscreen cannot snap an oversized window back to the panel
# — it just hangs off the right/bottom edges (content cropped, background art
# offscreen). Size the window in DIPs so DIPs x scale = the panel exactly.
KIOSK_WIN_W=$(awk -v w="$KIOSK_MODE_W" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", w/s}')
KIOSK_WIN_H=$(awk -v h="$KIOSK_MODE_H" -v s="$KIOSK_SCALE" 'BEGIN{printf "%d", h/s}')
xhost +SI:localuser:archipelago 2>/dev/null || true
xsetroot -solid black 2>/dev/null || true
xset s off 2>/dev/null || true
xset -dpms 2>/dev/null || true
xset s noblank 2>/dev/null || true
pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true
sleep 1
# GPU vs headless (#36, choppy-audio incident 2026-06-28). --enable-gpu-rasterization
# spins a dedicated GPU process at 55-92% CPU even on real GPU hardware (Intel HD 5500)
# because under X11 it falls back to software compositing anyway — that CPU
# starvation is what caused choppy HDMI audio. --in-process-gpu avoids the
# separate process; GpuRasterization is also disabled via --disable-features below.
# On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead.
if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then
GPU_FLAGS="--in-process-gpu --num-raster-threads=1"
else
GPU_FLAGS="--disable-gpu --num-raster-threads=1"
fi
ARCHIPELAGO_UID=$(id -u archipelago)
while true; do
# A profile lock left by a previous boot encodes <hostname>-<pid>; after a
# hostname change (node rename) Chromium reads it as another computer
# holding the profile and refuses to start — with --noerrdialogs that is an
# invisible failure and the kiosk black-screens forever. Any Chromium that
# owned the lock is dead by now (pkill above / previous loop iteration).
rm -f /var/lib/archipelago/chromium-kiosk/Singleton{Lock,Cookie,Socket}
# XDG_RUNTIME_DIR must be passed explicitly — without it Chromium's audio
# backend can't find PipeWire-Pulse's socket at /run/user/<uid>/pulse/native,
# falls back to raw ALSA "default", fails to connect, and produces no audio
# at all with no visible error (--noerrdialogs suppresses it).
# OverlayScrollbar: thin auto-hiding scrollbars (the Chrome-on-a-remote-
# device look) instead of classic X11 scrollbar chrome — a kiosk TV showed
# a permanent fat scrollbar on scrollable views (Peers).
# Force a DARK color-scheme preference. The main UI hardcodes its dark
# theme, but the bundled AIUI app themes via `@media (prefers-color-scheme)`
# and defaults to its LIGHT variant (white panels) when the browser reports
# no preference — which a minimal kiosk X session does. Two independent dark
# signals so this survives a Chromium enum change: GTK_THEME is version-
# independent and can never force light (worst case: no effect), and the
# blink-settings flag (0 = kDark in modern Chromium) reinforces it. Neither
# can regress the always-dark main UI.
sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago GTK_THEME=Adwaita:dark XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \
--app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \
--blink-settings=preferredColorScheme=0 \
--noerrdialogs \
--disable-infobars \
--disable-translate \
--no-first-run \
--check-for-update-interval=31536000 \
--disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \
--enable-features=OverlayScrollbar \
--disable-session-crashed-bubble \
--disable-save-password-bubble \
--disable-suggestions-service \
--disable-component-update \
$GPU_FLAGS \
--renderer-process-limit=2 \
--window-size=${KIOSK_WIN_W},${KIOSK_WIN_H} \
--window-position=0,0 \
--start-fullscreen \
--force-device-scale-factor=${KIOSK_SCALE} \
--disable-background-networking \
--disable-background-timer-throttling \
--disable-backgrounding-occluded-windows \
--disable-breakpad \
--disable-metrics \
--disable-metrics-reporting \
--disable-domain-reliability \
--js-flags="--max-old-space-size=256" \
--user-data-dir=/var/lib/archipelago/chromium-kiosk
sleep 3
done
kill "$XPID" 2>/dev/null || true
@@ -0,0 +1,14 @@
[Unit]
Description=Archipelago Kiosk Watchdog
After=archipelago.service
Wants=archipelago.service
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/archipelago-kiosk-watchdog
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,48 @@
[Unit]
Description=Archipelago Kiosk (X11 + Chromium)
After=archipelago.service systemd-user-sessions.service network-online.target
Wants=archipelago.service network-online.target
ConditionPathExists=/usr/local/bin/archipelago-kiosk-launcher
Conflicts=getty@tty1.service
[Service]
Type=simple
# Wait up to 5 min for archipelago to serve /health. On slow hardware
# first-boot is dominated by the FileBrowser pull (unbundled ISO),
# initial archipelago state sync, and frontend settle — .198 took
# longer than 120s and chromium launched against an empty backend,
# producing a white window that only recovered on reboot. 300s gives
# slow-but-functional hardware enough headroom; TimeoutStartSec is
# bumped in lockstep so systemd doesn't kill us mid-wait.
ExecStartPre=/bin/bash -c 'for i in $(seq 1 150); do curl -sf http://localhost/health >/dev/null 2>&1 && break; sleep 2; done'
# Also wait for the web-ui asset swap to finish. The first-boot rsync into
# /opt/archipelago/web-ui is non-atomic and writes the large bg-*.webp images
# last — a kiosk launched mid-swap rendered the UI with blank backgrounds
# (CSS background-image 404s are never refetched). A representative large
# asset answering 200 means the swap is effectively done. Bounded 60s so a
# renamed asset can never block kiosk startup.
ExecStartPre=/bin/bash -c 'for i in $(seq 1 30); do curl -sf -o /dev/null http://localhost/assets/img/bg-home.webp && break; sleep 2; done; exit 0'
ExecStart=/usr/local/bin/archipelago-kiosk-launcher
TimeoutStartSec=360
Restart=always
RestartSec=5
# Resource guardrail (#36). On GPU-less / headless hardware chromium could spin
# software compositing at ~92% of a core, saturating the node and starving the
# backend (it caused the .198 receive timeout + deploy storms). Cap CPU + memory
# so a runaway kiosk can never take the whole machine down; Delegate so the cap
# also binds the chromium/Xorg children in this unit's cgroup.
# CPUQuota=75% (0.75 cores) was too tight even for normal playback — the kiosk
# was throttled ~40% of the time, which is what caused choppy HDMI audio on
# a test node (2026-06-28 incident). 200% (2 cores) gives enough headroom.
Delegate=yes
CPUQuota=200%
# Raised from 1500M/1200M: a Framework (Tiger Lake) kiosk sat at 806M used /
# 1.1G peak, riding the old MemoryHigh reclaim-throttle line — the throttling
# itself was the perceived UI lag. Keep Max well above real peaks; High stays
# the soft reclaim line so a runaway kiosk still can't take the machine down.
MemoryMax=2800M
MemoryHigh=2200M
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,10 @@
# Archipelago persistent log directory and files
# Runtime log destination. Backend runs as `archipelago`, but /var/log/
# is root-owned, so we pre-create the directory and log files with the
# right ownership at boot / install-time.
#
# Logrotate (image-recipe/configs/logrotate.conf) rotates files in this
# directory daily, keeping 30 compressed copies.
d /var/log/archipelago 0755 archipelago archipelago - -
f /var/log/archipelago/container-installs.log 0644 archipelago archipelago - -
@@ -0,0 +1,9 @@
[Unit]
Description=Watch for Archipelago Tor management actions
[Path]
PathExists=/var/lib/archipelago/tor-config/tor-action
MakeDirectory=yes
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,9 @@
[Unit]
Description=Process Archipelago Tor management action
After=tor.service
[Service]
Type=oneshot
ExecStart=/opt/archipelago/scripts/tor-helper.sh
# Runs as root — needs to write /etc/tor/torrc and restart tor.service
User=root
@@ -0,0 +1,19 @@
[Unit]
Description=Archipelago Self-Update
After=network-online.target archipelago.service
Wants=network-online.target
ConditionPathExists=/home/archipelago/archy/.git
[Service]
Type=oneshot
User=archipelago
ExecStart=/home/archipelago/archy/scripts/self-update.sh
TimeoutStartSec=600
Environment="HOME=/home/archipelago"
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/archipelago/.cargo/bin"
# Allow sudo for service restart and file install
# Requires archipelago user in sudoers for specific commands
StandardOutput=journal
StandardError=journal
@@ -0,0 +1,14 @@
[Unit]
Description=Check for Archipelago updates daily
ConditionPathExists=/home/archipelago/archy/.git
[Timer]
# Check at 3 AM daily (low-activity window)
OnCalendar=*-*-* 03:00:00
# Randomize within 30 min window to avoid thundering herd
RandomizedDelaySec=1800
# Run once on boot if last check was missed
Persistent=true
[Install]
WantedBy=timers.target
@@ -0,0 +1,14 @@
[Unit]
Description=Assign WireGuard server address to wg0
After=archipelago-wg.service
Wants=archipelago-wg.service
ConditionPathExists=/sys/class/net/wg0
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c 'ip address show dev wg0 | grep -q "10.44.0.1" || ip address add 10.44.0.1/16 dev wg0'
ExecStart=/bin/bash -c 'iptables -t nat -C POSTROUTING -s 10.44.0.0/16 ! -o wg0 -j MASQUERADE 2>/dev/null || iptables -t nat -A POSTROUTING -s 10.44.0.0/16 ! -o wg0 -j MASQUERADE'
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,13 @@
[Unit]
Description=Archipelago Standalone WireGuard (wg0)
After=network.target
ConditionPathExists=/var/lib/archipelago/wireguard/private.key
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/archipelago-wg setup /var/lib/archipelago/wireguard/private.key
ExecStop=/bin/bash -c 'ip link del wg0 2>/dev/null || true'
[Install]
WantedBy=multi-user.target
+88
View File
@@ -0,0 +1,88 @@
[Unit]
Description=Archipelago Backend
After=network-online.target archipelago-setup-tor.service
Wants=network-online.target
# The data dir AND podman's graphroot (containers/storage) both live on the
# separate /var/lib/archipelago volume. Without this, on a cold boot the service
# (and its ExecStartPre) can start BEFORE var-lib-archipelago.mount, write to the
# bare mountpoint on rootfs, fail every podman call, exit, and get restarted every
# 5s until the volume mounts (~5 min of "[FAILED] Failed to start" on boot — B17).
# RequiresMountsFor adds both Requires= and After= on the mount unit so we never
# start until the data volume is mounted.
RequiresMountsFor=/var/lib/archipelago
[Service]
Type=notify
User=archipelago
Environment="ARCHIPELAGO_BIND=127.0.0.1:5678"
Environment="ARCHIPELAGO_USE_QUADLET_BACKENDS=true"
EnvironmentFile=-/var/lib/archipelago/telemetry.env
# DEV_MODE disabled in production — enabled via override.conf on dev servers
Environment="XDG_RUNTIME_DIR=/run/user/1000"
# + prefix runs these as root (needed for chown/mkdir outside ReadWritePaths)
ExecStartPre=+/bin/bash -c 'mkdir -p /run/user/1000 /var/lib/containers && chown archipelago:archipelago /run/user/1000 && chmod 700 /run/user/1000'
# Host IP from the main-table default route — hostname -I token order breaks
# once a VPN/bridge interface exists (netbird's wg tunnel sorted first and
# poisoned every host_ip consumer). Falls back to hostname -I when routeless.
ExecStartPre=+/bin/bash -c 'mkdir -p /var/lib/archipelago && chown archipelago:archipelago /var/lib/archipelago && IP=$(ip -4 route show default 2>/dev/null | sed -n "s/.* src \([0-9.]*\).*/\1/p" | head -1); [ -n "$$IP" ] || IP=$(hostname -I 2>/dev/null | awk "{print $$1}"); echo "ARCHIPELAGO_HOST_IP=$$IP" > /var/lib/archipelago/host-ip.env && chown archipelago:archipelago /var/lib/archipelago/host-ip.env'
# OTA crash-loop guard: if a just-applied binary can't start (SEGV loop), the
# in-binary post-OTA probe never runs — this restores the update-backup binary
# after 5 failed start attempts while the pending-verify marker exists.
# "-" so a missing/failed guard can never block the service itself.
ExecStartPre=+-/opt/archipelago/scripts/ota-crash-guard.sh
ExecStart=/usr/local/bin/archipelago
# always (not on-failure): the OTA restart path once stopped the daemon
# cleanly and the queued start never fired (a test node, v1.7.114->115,
# 2026-07-26) — the node sat dead all night behind "server starting up".
# Restart=always self-heals any lost start job; an explicit
# `systemctl stop` is still honored (systemd never auto-restarts after
# a manual stop).
Restart=always
RestartSec=5
WatchdogSec=300
TimeoutStartSec=300
# Backend shuts down in <1s; 15s is generous for any cleanup
TimeoutStopSec=15
# Filesystem protection
ProtectSystem=strict
# ProtectHome=no: rootless podman needs writable ~/.local/share/containers
ProtectHome=no
# PrivateTmp disabled: rootless podman runtime lives in /tmp/podman-run-UID/
# and must be shared between the service and SSH-created containers
ReadWritePaths=/var/lib/archipelago /etc/containers /var/lib/containers /run/user /tmp /home/archipelago/.local/share/containers /home/archipelago/.config/containers /etc
# Privilege restriction — NoNewPrivileges=no required for sudo archipelago-wg
# (WireGuard peer management). Scoped via sudoers to only archipelago-wg.
NoNewPrivileges=no
PrivateDevices=no
SupplementaryGroups=dialout debian-tor fips
# Syscall and network restrictions — safe on Debian 13 (systemd 256+)
# which respects NoNewPrivileges=no as an explicit override for seccomp filters
SystemCallArchitectures=native
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
RestrictRealtime=yes
# MemoryDenyWriteExecute removed: ring (rustls) and secp256k1 (bitcoin/nostr)
# use assembly code that requires executable memory mappings on some platforms
# Resource limits
MemoryMax=4G
LimitNOFILE=65535
TasksMax=2048
# Delegate cgroup controllers so rootless podman (run from this system service
# as user=archipelago, not user@1000.service) can create transient libpod-*.scope
# units with --memory / --cpus / --pids-limit. Without this, podman create fails
# at start time with: "MemoryMax is out of range" because systemd rejects resource
# limits on undelegated cgroup subtrees. Required for the ProdContainerOrchestrator
# code path (see core/archipelago/src/container/prod_orchestrator.rs).
Delegate=memory pids cpu io
# Logging
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+7
View File
@@ -0,0 +1,7 @@
pcm.!default {
type pulse
hint.description "Default ALSA Device (via PulseAudio)"
}
ctl.!default {
type pulse
}
@@ -0,0 +1,7 @@
[Journal]
Storage=persistent
SystemMaxUse=500M
RuntimeMaxUse=100M
ForwardToSyslog=no
RateLimitIntervalSec=30s
RateLimitBurst=10000
+24
View File
@@ -0,0 +1,24 @@
# Log rotation configuration for Archipelago
/var/log/archipelago/*.log {
daily
rotate 30
compress
delaycompress
missingok
notifempty
create 0644 root root
sharedscripts
postrotate
/usr/bin/systemctl reload archipelago > /dev/null 2>&1 || true
endscript
}
/var/lib/archipelago/logs/*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
create 0644 archipelago archipelago
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
# Gitea iframe proxy — strips X-Frame-Options so Gitea works in Archipelago iframe.
# Gitea container binds to port 3001, this proxy listens on port 3000 (the public port).
# Deployed to /etc/nginx/conf.d/gitea-iframe.conf
server {
listen 3000;
server_name _;
client_max_body_size 1G;
location / {
proxy_pass http://127.0.0.1:3001;
proxy_set_header Host $http_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_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
}
@@ -0,0 +1,19 @@
[info]
relay_url = "ws://0.0.0.0:7777/"
name = "Archipelago Private Relay"
description = "Private Nostr relay for Archipelago mesh VPN peer discovery"
[database]
data_directory = "/var/lib/archipelago/nostr-relay"
in_memory = true
[network]
address = "0.0.0.0"
port = 7777
ping_interval = 120
[limits]
messages_per_sec = 50
max_event_bytes = 65536
max_ws_message_bytes = 65536
max_ws_frame_bytes = 65536
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=Archipelago Private Nostr Relay
After=network-online.target
Wants=network-online.target
Before=nostr-vpn.service
# An ISO built without the relay binary (registry unreachable at build time)
# must not crash-loop every 3s forever — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nostr-rs-relay
[Service]
Type=simple
User=archipelago
ExecStartPre=/bin/bash -c 'mkdir -p /var/lib/archipelago/nostr-relay'
ExecStart=/usr/local/bin/nostr-rs-relay --config /var/lib/archipelago/nostr-relay/config.toml
Restart=always
RestartSec=3
TimeoutStopSec=10
# Resource limits — relay is lightweight (in-memory mode)
MemoryMax=512M
LimitNOFILE=4096
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+35
View File
@@ -0,0 +1,35 @@
[Unit]
Description=Nostr VPN - Mesh VPN with Nostr identity
After=network-online.target tor.service archipelago.service
Wants=network-online.target
StartLimitIntervalSec=300
StartLimitBurst=10
# An ISO built without the nvpn binary (registry unreachable at build time)
# must not restart-loop — skip cleanly instead.
ConditionPathExists=/usr/local/bin/nvpn
[Service]
Type=simple
User=root
Environment=HOME=/var/lib/archipelago/nostr-vpn
EnvironmentFile=-/var/lib/archipelago/nostr-vpn/env
ExecStartPre=+/bin/bash -c 'mkdir -p /run/nostr-vpn /var/lib/archipelago/nostr-vpn/.config/nvpn'
ExecStartPre=/bin/bash -c 'test -f /var/lib/archipelago/nostr-vpn/env || { echo "NostrVPN not configured — waiting for onboarding"; exit 1; }'
ExecStart=/usr/local/bin/nvpn daemon
Restart=on-failure
RestartSec=30
TimeoutStartSec=30
TimeoutStopSec=10
# No sandbox — runs as root for TUN/WireGuard, needs unrestricted filesystem
# Resource limits
MemoryMax=256M
TasksMax=64
# Logging
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,372 @@
# App proxies for HTTPS - avoids mixed content when embedding apps from HTTPS page
# Complete list for all apps that may be launched from the UI
location /app/grafana/ {
proxy_pass http://127.0.0.1:3000/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location = /app/uptime-kuma/ {
return 302 /app/uptime-kuma/dashboard;
}
location /app/uptime-kuma/ {
proxy_pass http://127.0.0.1:3002/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Prefix /app/uptime-kuma;
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_redirect / /app/uptime-kuma/;
proxy_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/gitea/ {
proxy_pass http://127.0.0.1:3001/;
proxy_http_version 1.1;
proxy_set_header Host $http_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_hide_header X-Frame-Options;
proxy_hide_header Content-Security-Policy;
}
location /app/searxng/ {
proxy_pass http://127.0.0.1:8888/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/portainer/ {
proxy_pass http://127.0.0.1:9000/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/filebrowser/ {
client_max_body_size 10G;
proxy_pass http://127.0.0.1:8083/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_request_buffering off;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/endurain/ {
proxy_pass http://127.0.0.1:8080/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/lnd/ {
proxy_pass http://127.0.0.1:18083/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/jellyfin/ {
proxy_pass http://127.0.0.1:8096/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/photoprism/ {
proxy_pass http://127.0.0.1:2342/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/mempool/ {
proxy_pass http://127.0.0.1:4080/;
proxy_http_version 1.1;
# mempool's UI is websocket-driven (/api/v1/ws). Without forwarding the
# upgrade, the page loads but never connects — the backend can be fully
# healthy and every REST probe green while the user sees a dead UI
# (2026-08-09). 101 through this proxy is the only honest health signal.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/fedimint/ {
proxy_pass http://127.0.0.1:8175/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
sub_filter_types text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/fedimint/';
sub_filter 'src="/' 'src="/app/fedimint/';
sub_filter "href='/" "href='/app/fedimint/";
sub_filter "src='/" "src='/app/fedimint/";
sub_filter 'url("/' 'url("/app/fedimint/';
sub_filter "url('/" "url('/app/fedimint/";
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/fedimint-gateway/ {
proxy_pass http://127.0.0.1:8176/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/tailscale/ {
# Tailscale has no web UI — managed via CLI/Tailscale app
default_type application/json;
return 503 '{"error":{"code":"NO_WEB_UI","message":"Tailscale is managed via CLI"}}';
}
location /app/routstr/ {
proxy_pass http://127.0.0.1:8200/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nostr-vpn/ {
proxy_pass http://127.0.0.1:8201/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
}
location /app/fips/ {
proxy_pass http://127.0.0.1:8202/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
}
location /app/ollama/ {
proxy_pass http://127.0.0.1:11434/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/bitcoin-ui/ {
proxy_pass http://127.0.0.1:8334/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/botfights/api/ {
proxy_pass http://127.0.0.1:9100/api/;
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_read_timeout 300s;
proxy_send_timeout 300s;
}
location /app/botfights/ {
proxy_pass http://127.0.0.1:9100/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_hide_header Cross-Origin-Embedder-Policy;
proxy_hide_header Cross-Origin-Opener-Policy;
proxy_hide_header Cross-Origin-Resource-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_types text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/botfights/';
sub_filter 'src="/' 'src="/app/botfights/';
sub_filter "href='/" "href='/app/botfights/";
sub_filter "src='/" "src='/app/botfights/";
sub_filter '</head>' '<script src="/nostr-provider.js"></script><script>window.addEventListener("message",function(e){var d=e.data;if(d&&d.type==="arcade-input"&&d.key){var t=d.action==="up"?"keyup":"keydown";document.dispatchEvent(new KeyboardEvent(t,{key:d.key,bubbles:true}))}})</script></head>';
}
location /app/electrumx/ {
proxy_pass http://127.0.0.1:50002/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/indeedhub/_next/ {
proxy_pass http://127.0.0.1:7777/_next/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache_valid 200 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
location /app/indeedhub/ws/ {
proxy_pass http://127.0.0.1:7777/ws/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 86400s;
}
location /app/indeedhub/ {
proxy_pass http://127.0.0.1:7777/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_types text/css application/javascript application/json;
sub_filter_once off;
sub_filter 'href="/' 'href="/app/indeedhub/';
sub_filter 'src="/' 'src="/app/indeedhub/';
sub_filter "href='/" "href='/app/indeedhub/";
sub_filter "src='/" "src='/app/indeedhub/";
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
location /app/nginx-proxy-manager/ {
proxy_pass http://127.0.0.1:8081/;
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_hide_header X-Frame-Options;
add_header X-Frame-Options "SAMEORIGIN" always;
proxy_hide_header Content-Security-Policy;
proxy_set_header Accept-Encoding "";
sub_filter_once on;
sub_filter '</head>' '<script src="/nostr-provider.js"></script></head>';
}
@@ -0,0 +1,16 @@
# PWA installability - required for Install (not just Add to Home Screen) on Android
# Manifest MUST be served with application/manifest+json - Chrome rejects otherwise
location = /manifest.webmanifest {
default_type application/manifest+json;
add_header Cache-Control "public, max-age=0, must-revalidate";
}
# Service worker - no cache so updates apply
location ~ ^/(sw\.js|workbox-.*\.js|registerSW\.js)$ {
add_header Content-Type application/javascript;
add_header Service-Worker-Allowed /;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# index.html - avoid aggressive cache for PWA updates
location = /index.html {
add_header Cache-Control "public, max-age=0, must-revalidate";
}