Archipelago — open-source initial import
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,366 @@
|
||||
# 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;
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user