diff --git a/README.md b/README.md index 6101ec6..be5d8b5 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,102 @@ # mediabox-bootstrap -First-boot bootstrap for the media box (10.0.1.20) — CIFS mounts, NVIDIA 580, native shell-mcp, Plex. Paired with projects/mediabox-linux in claude-workspace. \ No newline at end of file +Everything the media box (`10.0.1.20`, hostname `mediabox`) needs after the +Ubuntu 24.04 autoinstall has laid down the base system. + +The autoinstall USB is deliberately thin: sshd, a key, DHCP, Docker CE, `/srv`, +and a first-boot unit that clones **this** repo and runs `bootstrap.sh`. That +split exists so the interesting parts stay in git, reviewable and re-runnable, +instead of frozen onto a USB stick that nobody can diff. + +Full context, decisions and the phase-by-phase runbook live in the workspace repo +at `projects/mediabox-linux/`. + +--- + +## Layout + +``` +bootstrap.sh orchestrator — runs the stages, fails soft +scripts/ + 00-preflight.sh asserts hardware/BIOS state. Changes nothing. + 10-secrets.sh idempotent ADD-ONLY seeder for /srv/secrets/stacks.env + 20-cifs.sh the 8 NAS mounts (--verify mode included) + 30-nvidia.sh NVIDIA 580 + container toolkit + 40-shell-mcp.sh builds and starts the native shell-mcp + 50-plex.sh Plex. Run BY HAND — needs a live claim token. +shell-mcp/ amd64 build of the MCP server (server.py, Dockerfile, + docker-compose.yml, pinned requirements.txt) +plex/docker-compose.yml Plex service definition +secrets/stacks.env.example placeholder master env +``` + +## Running it + +```bash +sudo /srv/mediabox-bootstrap/bootstrap.sh # all stages +sudo /srv/mediabox-bootstrap/scripts/20-cifs.sh # one stage +sudo /srv/mediabox-bootstrap/scripts/20-cifs.sh --verify +``` + +Every stage is idempotent. Re-running is the normal way to use this, not an +emergency measure. + +--- + +## Design rules + +**Nothing may leave the box unreachable.** `sshd` is up before any of this runs +and no stage may compromise that. `bootstrap.sh` catches stage failures and +continues; the systemd unit declares `SuccessExitStatus=0 1` so a bad stage can +never wedge boot. There is no keyboard attached to this machine. + +**Secrets never ride on removable media.** The CIFS credentials file is created +*empty* by the autoinstall and filled in post-boot over the MCP. `PLEX_CLAIM` +tokens expire in four minutes and are passed as a one-shot environment variable, +never written to disk or committed. + +**One secrets file per host.** This box has its own `/srv/secrets/stacks.env`, +using the same `[OPERATOR]` / `[VALUES]` / `[MANIFEST]` tiering as arrsstack, but +it is not shared or mounted from anywhere. Canonical copies live in Vaultwarden. + +**The seeder only ever adds.** `10-secrets.sh` will never overwrite an existing +key, change its value, or move it. If `KEY=` is present it is skipped entirely. +Verified against a pre-populated file: existing values, unrelated keys, the +operator tier and existing manifest lines all survive byte-for-byte, and a second +run is a no-op. + +--- + +## Notes that will save you an evening + +**shell-mcp was originally built on arm64.** It builds clean on amd64 — the whole +Python dependency tree resolves to prebuilt manylinux x86_64 wheels, so no +compiler is needed. Two real changes were required: + +- **Dependencies are now pinned.** The original installed `mcp starlette uvicorn` + unpinned. `starlette` has since gone 1.x. Unpinned installs are tolerable in a + hand-run build and dangerous inside a first-boot script. +- **Port and description.** It listens on 8103 (not 8085) so NPM proxy host 42 can + simply be repointed, and the tool description says *media box*, not *arrsstack* — + otherwise every session starts with the wrong idea of which host it is touching. + +**The GTX 1070 is Pascal, and NVIDIA branch 580 is the last one that supports it.** +There will be no 590 for this card. `30-nvidia.sh` installs 580 explicitly and pins +against newer branches. Never use `ubuntu-drivers autoinstall` here — it will +cheerfully install a branch that drops the card. And it must be the proprietary +module, not `-open`: the open kernel modules need Turing or newer. + +**Secure Boot must stay off.** It is off today. A BIOS update resets ASUS defaults +and turns it back on, at which point the DKMS module needs interactive MOK +enrollment at a console this box does not have. `00-preflight.sh` checks for this. + +**`nofail` on every CIFS mount is not optional.** Without it, an unreachable NAS +drops a headless box to an emergency shell waiting for a root password on a +keyboard that is not plugged in. + +**`autoEmptyTrash` is the most dangerous default here.** The NAS account is +read-write. If a mount is missing when Plex scans, Plex sees an empty library and +will act on that. `50-plex.sh` disables it before any library is added. + +**Plex's PUID/PGID must equal the CIFS `uid=`/`gid=`.** Both are `3000` (`media`). +If they ever drift, every file is permission-denied. diff --git a/bootstrap.sh b/bootstrap.sh old mode 100644 new mode 100755 index 3ed4aa4..a5c417a --- a/bootstrap.sh +++ b/bootstrap.sh @@ -62,15 +62,15 @@ else warn "NOT marking first-boot done — fix and re-run $0" fi -cat <<'NEXT' +cat <<'NEXT' -------------------------------------------------------------------------- REMAINING STEPS — deliberately NOT automated -------------------------------------------------------------------------- 1. Write the NAS password into the credentials file (over the MCP, never onto the USB): - printf 'username=<nas-user>\npassword=<nas-pass>\ndomain=WORKGROUP\n' \ - > /etc/cifs/korval.cred + printf 'username=\npassword=\ndomain=WORKGROUP\n' \ + > /etc/cifs/korval.cred chmod 600 /etc/cifs/korval.cred systemctl daemon-reload /srv/mediabox-bootstrap/scripts/20-cifs.sh --verify diff --git a/plex/docker-compose.yml b/plex/docker-compose.yml new file mode 100644 index 0000000..43b4fb9 --- /dev/null +++ b/plex/docker-compose.yml @@ -0,0 +1,58 @@ +services: + plex: + image: lscr.io/linuxserver/plex:latest + container_name: plex + restart: unless-stopped + + # NOT OPTIONAL. Plex's local discovery (GDM), DLNA and the Plex-for-client + # "found a local server" path all rely on broadcast traffic that a bridge + # network silently eats. This is why Plex does not sit behind NPM the way + # every other service here does — NPM proxy host 17 already points + # plex.thewichersfamily.com at 10.0.1.20:32400 and needs no change. + network_mode: host + + environment: + - PUID=3000 # MUST match uid= on the CIFS mounts + - PGID=3000 # MUST match gid= on the CIFS mounts + - TZ=${TZ:-America/New_York} + - VERSION=docker + # PLEX_CLAIM is intentionally absent from this file. Claim tokens expire + # four minutes after they are issued, so one can never be committed to a + # repo, written to a USB, or baked into an image. It is injected at first + # start by 50-plex.sh and never persisted. + - PLEX_CLAIM=${PLEX_CLAIM:-} + - ADVERTISE_IP=${PLEX_ADVERTISE_URL:-http://10.0.1.20:32400} + + devices: + # Quick Sync. The i7-8700K's UHD 630 is the transcode target: no session + # cap, and better HDR tone mapping than the Pascal-era NVENC on the 1070. + - /dev/dri:/dev/dri + + group_add: + # The render node is root:render, and PGID 3000 is not in that group. + # Resolved from the live host by 50-plex.sh — do not hardcode, the gid + # differs between distro releases. + - "${RENDER_GID:-993}" + + volumes: + # Config lives on the SSD, deliberately. The Plex SQLite database is the + # most latency-sensitive thing on this box; it does not belong on the + # 3TB spinner. + - /srv/plex/config:/config + + # NAS media, read-write per the account scope. Mounted read-only INTO the + # container for every library that Plex has no business writing to. + - /mnt/nas/audiobooks:/media/audiobooks:ro + - /mnt/nas/education-videos:/media/education-videos:ro + - /mnt/nas/health:/media/health:ro + - /mnt/nas/home-movies:/media/home-movies:ro + - /mnt/nas/media:/media/media:ro + - /mnt/nas/music-organized:/media/music-organized:ro + - /mnt/nas/pictures:/media/pictures:ro + - /mnt/nas/radio-shows:/media/radio-shows:ro + + tmpfs: + # Transcodes are throwaway. Keeping them in RAM saves a great deal of + # SSD write wear. 4G of the box's 16G, capped so a pathological transcode + # cannot pressure the rest of the system. + - /transcode:rw,size=4g,mode=1777 diff --git a/scripts/00-preflight.sh b/scripts/00-preflight.sh new file mode 100755 index 0000000..fd488eb --- /dev/null +++ b/scripts/00-preflight.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# ============================================================================= +# 00-preflight — assert the box is what we think it is before changing it. +# +# This stage NEVER modifies anything. It only reports. Its job is to catch +# "the BIOS update reset your settings" before you spend an hour wondering +# why Plex will not transcode. +# ============================================================================= +set -uo pipefail + +RC=0 +note() { printf ' %-34s %s\n' "$1" "$2"; } +bad() { printf ' \033[1;31m%-34s %s\033[0m\n' "$1" "$2"; RC=1; } +warn() { printf ' \033[1;33m%-34s %s\033[0m\n' "$1" "$2"; } + +echo "--- identity ---" +note "hostname" "$(hostname)" +note "kernel" "$(uname -r)" +note "ip" "$(hostname -I | tr -s ' ')" + +echo "--- firmware ---" +if [ -d /sys/firmware/efi ]; then note "boot mode" "UEFI"; else bad "boot mode" "LEGACY/BIOS — expected UEFI"; fi + +# Secure Boot must stay OFF. A BIOS update commonly restores defaults, and the +# ASUS default for this board turns Secure Boot back on. With it on, the DKMS +# NVIDIA module will not load and there is no keyboard attached to enroll a MOK. +if command -v mokutil >/dev/null 2>&1; then + SB="$(mokutil --sb-state 2>/dev/null || echo unknown)" + case "$SB" in + *disabled*) note "secure boot" "disabled (correct)" ;; + *enabled*) bad "secure boot" "ENABLED — NVIDIA DKMS will not load. Disable it in BIOS." ;; + *) warn "secure boot" "$SB" ;; + esac +else + warn "secure boot" "mokutil not installed; check BIOS manually" +fi + +echo "--- disks ---" +# Disk 0 must be the system disk. Disk 1 must still be NTFS and untouched. +SYS_SERIAL="$(lsblk -dno SERIAL "$(findmnt -no SOURCE / | sed -E 's/p?[0-9]+$//')" 2>/dev/null | tr -d ' ')" +if [ "$SYS_SERIAL" = "1808AE802176" ]; then + note "root disk serial" "1808AE802176 (correct)" +else + bad "root disk serial" "${SYS_SERIAL:-unknown} — expected 1808AE802176" +fi + +DATA_DEV="$(lsblk -dno NAME,SERIAL | awk '$2=="WD-WCC7K3JAEDR3"{print $1}')" +if [ -n "$DATA_DEV" ]; then + FSTYPES="$(lsblk -no FSTYPE "/dev/$DATA_DEV" | tr -s '\n' ' ')" + note "data disk (D:)" "/dev/$DATA_DEV present, fstypes: ${FSTYPES:-none}" + if echo "$FSTYPES" | grep -q ntfs; then + note "data disk state" "still NTFS — correct, leave it until after soak" + else + warn "data disk state" "no NTFS found — has it already been converted?" + fi + if findmnt -rno TARGET -S "/dev/${DATA_DEV}1" >/dev/null 2>&1; then + warn "data disk mounted" "D: is mounted; it should not be during the soak" + fi +else + bad "data disk (D:)" "WD-WCC7K3JAEDR3 NOT FOUND" +fi + +echo "--- gpu ---" +if lspci -nn | grep -qi 'VGA.*Intel'; then + note "iGPU (UHD 630)" "present" +else + bad "iGPU (UHD 630)" "NOT enumerated — set BIOS: Advanced > System Agent (SA) Configuration > Graphics Configuration > iGPU Multi-Monitor = Enabled" +fi +if lspci -nn | grep -qi 'NVIDIA'; then + note "GTX 1070" "$(lspci -nn | grep -i nvidia | head -1 | cut -c1-70)" +else + warn "GTX 1070" "not seen on PCI bus" +fi +if [ -e /dev/dri/renderD128 ]; then + note "/dev/dri/renderD128" "present" + note "render group gid" "$(stat -c '%g (%G)' /dev/dri/renderD128)" +else + warn "/dev/dri/renderD128" "missing — Quick Sync unavailable until iGPU is enabled in BIOS" +fi + +echo "--- runtime ---" +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + note "docker" "$(docker --version | cut -d, -f1)" +else + bad "docker" "not running" +fi +note "media uid/gid" "$(id -u media 2>/dev/null || echo '?'):$(getent group media | cut -d: -f3 2>/dev/null || echo '?')" +note "swap" "$(free -h | awk '/Swap:/{print $2}')" + +echo +[ $RC -eq 0 ] && echo " preflight clean" || echo " preflight found problems above" +# Preflight never blocks the rest of the bootstrap — it reports. +exit 0 diff --git a/scripts/10-secrets.sh b/scripts/10-secrets.sh new file mode 100755 index 0000000..504b2c2 --- /dev/null +++ b/scripts/10-secrets.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# ============================================================================= +# 10-secrets — idempotent, ADD-ONLY seeder for /srv/secrets/stacks.env +# +# Mirrors the arrsstack convention: one master file, three tiers. +# [OPERATOR] secrets for operating the host; never emitted to a container +# [VALUES] flat, deduplicated KEY=value +# [MANIFEST] "#@stack = VAR VAR" lines consumed by genenv.sh +# +# GUARANTEES: +# * An existing key is NEVER touched. Not its value, not its position, +# not its comment. If KEY= exists, this script skips it entirely. +# * A missing key is appended with either a generated value (for tokens we +# can safely generate) or the literal FILL_ME (for anything a human must +# supply). genenv.sh already fails closed on FILL_ME. +# * Values are never printed. Only key names and add/skip decisions. +# * Running this twice in a row produces zero changes the second time. +# +# This is a SEPARATE secrets file from arrsstack's. Per the project decision: +# no shared or mounted secrets between hosts. Canonical copy is Vaultwarden. +# ============================================================================= +set -euo pipefail + +MASTER="/srv/secrets/stacks.env" +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +umask 077 +install -d -m 0700 /srv/secrets + +added=0; skipped=0 + +# --- create the skeleton only if the file does not exist at all ------------- +if [ ! -f "$MASTER" ]; then + echo " creating $MASTER from template" + install -m 0600 "$REPO_DIR/secrets/stacks.env.example" "$MASTER" +fi + +# Ensure the tier markers exist, so insertion has something to anchor to. +grep -q '^# ----- \[VALUES\]' "$MASTER" || printf '\n# ----- [VALUES] -----\n' >> "$MASTER" +grep -q '^# ----- \[MANIFEST\]' "$MASTER" || printf '\n# ----- [MANIFEST] -----\n' >> "$MASTER" + +# --------------------------------------------------------------------------- +# add_value +# generator: "hex32" -> openssl rand -hex 32 +# "fill" -> literal FILL_ME (human must supply) +# anything else -> used as the literal default value +# Inserts immediately BEFORE the [MANIFEST] marker so it lands inside [VALUES]. +# --------------------------------------------------------------------------- +add_value() { + local key="$1" gen="$2" val + + if grep -qE "^${key}=" "$MASTER"; then + printf ' skip %-32s (already present)\n' "$key" + skipped=$((skipped+1)) + return 0 + fi + + case "$gen" in + hex32) val="$(openssl rand -hex 32)" ;; + fill) val="FILL_ME" ;; + *) val="$gen" ;; + esac + + # Insert before the [MANIFEST] header, preserving everything else byte-for-byte. + local tmp; tmp="$(mktemp)" + awk -v line="${key}=${val}" ' + /^# ----- \[MANIFEST\]/ && !done { print line; print ""; done=1 } + { print } + ' "$MASTER" > "$tmp" + install -m 0600 "$tmp" "$MASTER"; rm -f "$tmp" + + printf ' ADD %-32s (%s)\n' "$key" "$([ "$gen" = fill ] && echo 'needs a human' || echo generated)" + added=$((added+1)) +} + +# --------------------------------------------------------------------------- +# add_manifest +# Appends a "#@stack" line only if one does not already exist for that stack. +# --------------------------------------------------------------------------- +add_manifest() { + local stack="$1"; shift + if grep -qE "^#@stack[[:space:]]+${stack}[[:space:]]*=" "$MASTER"; then + printf ' skip %-32s (manifest present)\n' "#@stack ${stack}" + skipped=$((skipped+1)) + return 0 + fi + printf '#@stack %s = %s\n' "$stack" "$*" >> "$MASTER" + printf ' ADD %-32s\n' "#@stack ${stack}" + added=$((added+1)) +} + +echo " seeding $MASTER (add-only)" + +# --- [VALUES] --------------------------------------------------------------- +add_value TZ "America/New_York" +# Reuses the SAME token value that arrsstack's mediabox-mcp already serves, so +# the Claude connector entry does not change when NPM host 42 is repointed. +# Left as FILL_ME: copy it across by hand rather than minting a new one. +add_value MEDIABOX_MCP_BEARER_TOKEN fill +add_value PLEX_ADVERTISE_URL "http://10.0.1.20:32400" + +# --- [MANIFEST] ------------------------------------------------------------- +add_manifest shell-mcp MEDIABOX_MCP_BEARER_TOKEN TZ +add_manifest plex TZ PLEX_ADVERTISE_URL + +chmod 600 "$MASTER" +chown root:root "$MASTER" + +echo " result: ${added} added, ${skipped} left untouched" + +# Report unfilled keys by NAME only — never values. +if grep -qE '^[A-Za-z_][A-Za-z0-9_]*=FILL_ME$' "$MASTER"; then + echo + echo " keys still needing a value (fill by hand, then re-run):" + grep -E '^[A-Za-z_][A-Za-z0-9_]*=FILL_ME$' "$MASTER" | cut -d= -f1 | sed 's/^/ - /' +fi + +exit 0 diff --git a/scripts/20-cifs.sh b/scripts/20-cifs.sh new file mode 100755 index 0000000..63e8e8b --- /dev/null +++ b/scripts/20-cifs.sh @@ -0,0 +1,156 @@ +#!/usr/bin/env bash +# ============================================================================= +# 20-cifs — NAS mounts for the 8 shares Plex actually uses. +# +# Shares confirmed live from the Windows box 2026-07-27 (exact names/casing): +# Audio Books · Education Videos · Health · Home Movies · media +# Music Organized · Pictures · Radio Shows (+ "Share" — NOT mounted) +# +# "Share" is mounted on Windows today but is not a Plex library root, so it is +# deliberately left out. Every entry below maps to a real library section. +# +# 11 Plex library roots resolve onto these 8 mounts: +# Audio Books -> /mnt/nas/audiobooks +# Music Organized -> /mnt/nas/music-organized +# Radio Shows -> /mnt/nas/radio-shows +# Pictures -> /mnt/nas/pictures/Plex Pictures +# Health -> /mnt/nas/health +# Home Movies -> /mnt/nas/home-movies +# Education Videos -> /mnt/nas/education-videos +# media -> /mnt/nas/media/{movies,tvshows,music,audiobooks} +# +# WHY EACH OPTION IS THERE — none of these are decoration: +# nofail an unreachable NAS must NOT drop a headless +# box into an emergency shell. There is no +# keyboard attached to type the root password. +# _netdev tells systemd this needs the network up. +# x-systemd.automount mount on first access rather than at boot, so +# a slow NAS never stretches boot time. +# x-systemd.mount-timeout=30 bounded failure instead of an indefinite hang. +# x-systemd.idle-timeout=600 unmount when idle; keeps stale handles rare. +# vers=3.1.1 dialect confirmed from the Windows box. +# uid/gid=3000 MUST match Plex's PUID/PGID or every file is +# permission-denied. +# \040 fstab field separator is whitespace; share +# names with spaces MUST escape them. +# ============================================================================= +set -uo pipefail + +CRED="/etc/cifs/korval.cred" +NAS="10.0.1.254" +MEDIA_UID=3000 +MEDIA_GID=3000 +MARK_BEGIN="# >>> mediabox NAS mounts (managed by 20-cifs.sh) >>>" +MARK_END="# <<< mediabox NAS mounts <<<" + +OPTS="credentials=${CRED},vers=3.1.1,uid=${MEDIA_UID},gid=${MEDIA_GID},file_mode=0664,dir_mode=0775,iocharset=utf8,nofail,_netdev,x-systemd.automount,x-systemd.mount-timeout=30,x-systemd.idle-timeout=600" + +# share-name-on-nas | local mount point +SHARES=( + "Audio Books|/mnt/nas/audiobooks" + "Education Videos|/mnt/nas/education-videos" + "Health|/mnt/nas/health" + "Home Movies|/mnt/nas/home-movies" + "media|/mnt/nas/media" + "Music Organized|/mnt/nas/music-organized" + "Pictures|/mnt/nas/pictures" + "Radio Shows|/mnt/nas/radio-shows" +) + +# --- --verify mode: check mounts, change nothing ---------------------------- +if [ "${1:-}" = "--verify" ]; then + echo " verifying NAS mounts" + rc=0 + if [ ! -s "$CRED" ]; then + echo " [FAIL] $CRED is empty — write the NAS credentials first" + exit 1 + fi + for entry in "${SHARES[@]}"; do + mp="${entry#*|}" + if ls "$mp" >/dev/null 2>&1 && mountpoint -q "$mp"; then + printf ' [ok] %-28s %s\n' "$(basename "$mp")" "$(df -h --output=size "$mp" 2>/dev/null | tail -1 | tr -d ' ')" + else + printf ' [FAIL] %-28s not mounted\n' "$(basename "$mp")" + rc=1 + fi + done + exit $rc +fi + +echo " writing fstab entries for ${#SHARES[@]} shares" + +install -d -m 0700 /etc/cifs +[ -f "$CRED" ] || install -m 0600 /dev/null "$CRED" +chmod 600 "$CRED" + +# The credentials file is created EMPTY by autoinstall on purpose. The real +# password is written post-boot over the MCP so it never rides on the USB. +if [ ! -s "$CRED" ]; then + cat <<'CREDNOTE' + NOTE: /etc/cifs/korval.cred is empty. That is expected at this stage. + The mounts will fail (harmlessly, thanks to nofail) until you write: + printf 'username=\npassword=\ndomain=WORKGROUP\n' \ + > /etc/cifs/korval.cred + chmod 600 /etc/cifs/korval.cred + No quotes. No spaces around '='. Trailing newline required. +CREDNOTE +fi + +for entry in "${SHARES[@]}"; do + mp="${entry#*|}" + install -d -m 0755 "$mp" + chown ${MEDIA_UID}:${MEDIA_GID} "$mp" +done + +# Rebuild only our managed block; never touch the rest of fstab. +tmp="$(mktemp)" +awk -v b="$MARK_BEGIN" -v e="$MARK_END" ' + $0 == b { skip=1 } + !skip { print } + $0 == e { skip=0 } +' /etc/fstab > "$tmp" + +{ + printf '%s\n' "$MARK_BEGIN" + for entry in "${SHARES[@]}"; do + share="${entry%%|*}" + mp="${entry#*|}" + # escape spaces as \040 in BOTH fields (mount points here have none, but + # the escaping is applied uniformly so a future renamed mount is safe) + esc_share="${share// /\\040}" + esc_mp="${mp// /\\040}" + printf '//%s/%s %s cifs %s 0 0\n' "$NAS" "$esc_share" "$esc_mp" "$OPTS" + done + printf '%s\n' "$MARK_END" +} >> "$tmp" + +# Sanity: never install an fstab that lost the root entry. +if ! awk '$2=="/" && $1 !~ /^#/' "$tmp" | grep -q .; then + echo " [FAIL] refusing to write fstab — root entry missing from generated file" + rm -f "$tmp"; exit 1 +fi + +cp -a /etc/fstab "/etc/fstab.bak.$(date +%Y%m%d%H%M%S)" +install -m 0644 "$tmp" /etc/fstab +rm -f "$tmp" + +systemctl daemon-reload + +echo " fstab updated (backup written alongside). Managed block:" +sed -n "/${MARK_BEGIN//\//\\/}/,/${MARK_END//\//\\/}/p" /etc/fstab | sed 's/^/ /' + +if [ -s "$CRED" ]; then + echo " credentials present — attempting mounts" + for entry in "${SHARES[@]}"; do + mp="${entry#*|}" + if timeout 40 mount "$mp" 2>/dev/null || mountpoint -q "$mp"; then + printf ' [ok] %s\n' "$mp" + else + printf ' [warn] %s did not mount (check credentials / share name)\n' "$mp" + fi + done +else + echo " skipping mount attempts until credentials are written" +fi + +exit 0 diff --git a/scripts/30-nvidia.sh b/scripts/30-nvidia.sh new file mode 100755 index 0000000..01d7320 --- /dev/null +++ b/scripts/30-nvidia.sh @@ -0,0 +1,91 @@ +#!/usr/bin/env bash +# ============================================================================= +# 30-nvidia — NVIDIA driver + container toolkit for the GTX 1070 +# +# WHY THIS IS NOT IN late-commands: +# The driver is a DKMS module. It must build against the kernel that is +# actually running on the installed system, with that kernel's headers, on a +# real boot. Building it inside the installer environment produces a module +# for the installer's kernel, which is not the kernel that boots. +# +# DRIVER BRANCH — this is the part that will bite later: +# The GTX 1070 is Pascal. NVIDIA's 580 branch is the LAST branch that supports +# Maxwell, Pascal and Volta; it is now a frozen legacy branch receiving +# security fixes only. There will be no 590 for this card. Two consequences: +# 1. We install nvidia-driver-580 explicitly. Never `ubuntu-drivers autoinstall`, +# which will happily pick a newer branch that does not support the card. +# 2. We install the PROPRIETARY module, not `-open`. The open kernel modules +# require Turing or newer. On Pascal they will not load at all. +# +# Secure Boot must be OFF (verified 2026-07-27). With it on, the DKMS module is +# unsigned as far as the firmware is concerned and requires interactive MOK +# enrollment at a physical console — on a box with no keyboard. +# +# Plex is targeted at Quick Sync, not NVENC, so this stage failing does NOT +# block Plex. It fails soft. +# ============================================================================= +set -uo pipefail + +DRIVER_BRANCH=580 + +if ! lspci -nn | grep -qi nvidia; then + echo " no NVIDIA device on the PCI bus — skipping" + exit 0 +fi + +if command -v nvidia-smi >/dev/null 2>&1 && nvidia-smi >/dev/null 2>&1; then + echo " driver already working:" + nvidia-smi --query-gpu=name,driver_version --format=csv,noheader | sed 's/^/ /' +else + echo " installing nvidia-driver-${DRIVER_BRANCH} (proprietary; Pascal cannot use -open)" + + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + + # Explicit branch, explicit proprietary flavour. No ubuntu-drivers autoinstall. + if ! apt-get install -y \ + "nvidia-driver-${DRIVER_BRANCH}" \ + "nvidia-utils-${DRIVER_BRANCH}" \ + "linux-headers-$(uname -r)" \ + dkms; then + echo " [FAIL] driver install failed — Plex/Quick Sync is unaffected, fix later" + exit 1 + fi + + # Persistence mode avoids a multi-second GPU init on every container start. + systemctl enable --now nvidia-persistenced 2>/dev/null || true + echo " driver installed — a REBOOT is required before nvidia-smi will work" +fi + +# --- NVIDIA Container Toolkit ------------------------------------------------ +if [ -f /etc/apt/sources.list.d/nvidia-container-toolkit.list ] \ + && command -v nvidia-ctk >/dev/null 2>&1; then + echo " container toolkit already present" +else + echo " installing NVIDIA container toolkit" + install -m 0755 -d /usr/share/keyrings + curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \ + | gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg + curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \ + | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \ + > /etc/apt/sources.list.d/nvidia-container-toolkit.list + apt-get update -qq + apt-get install -y nvidia-container-toolkit || { + echo " [FAIL] container toolkit install failed"; exit 1; } + + nvidia-ctk runtime configure --runtime=docker + systemctl restart docker +fi + +# --- guard rail -------------------------------------------------------------- +# If a newer driver branch is ever pulled in, it will silently drop this card. +cat > /etc/apt/preferences.d/nvidia-pascal.pref </dev/null 2>&1 || { echo " [FAIL] docker not installed"; exit 1; } + +# --- resolve the bearer token ------------------------------------------------ +# Reuses the SAME token arrsstack's mediabox-mcp serves today, so the Claude +# connector entry survives the cutover untouched. +TOKEN="$(grep -E '^MEDIABOX_MCP_BEARER_TOKEN=' "$MASTER" 2>/dev/null | head -1 | cut -d= -f2-)" +if [ -z "${TOKEN:-}" ] || [ "$TOKEN" = "FILL_ME" ]; then + cat <<'MSG' + [SKIP] MEDIABOX_MCP_BEARER_TOKEN is not set in /srv/secrets/stacks.env. + + Copy the existing value from arrsstack so the connector keeps working: + # on arrsstack + grep '^MEDIABOX_MCP_BEARER_TOKEN=' /srv/secrets/stacks.env + then put it in this host's /srv/secrets/stacks.env and re-run: + sudo /srv/mediabox-bootstrap/scripts/40-shell-mcp.sh +MSG + exit 0 +fi + +# --- stage source ------------------------------------------------------------ +install -d -m 0755 "$DEST" +install -m 0644 "$SRC/server.py" "$DEST/server.py" +install -m 0644 "$SRC/requirements.txt" "$DEST/requirements.txt" +install -m 0644 "$SRC/Dockerfile" "$DEST/Dockerfile" +install -m 0644 "$SRC/docker-compose.yml" "$DEST/docker-compose.yml" + +# --- per-stack env slice (same convention as arrsstack) ---------------------- +install -d -m 0700 /srv/secrets/stacks +umask 077 +{ + printf 'MEDIABOX_MCP_BEARER_TOKEN=%s\n' "$TOKEN" + printf 'TZ=%s\n' "$(grep -E '^TZ=' "$MASTER" | head -1 | cut -d= -f2- || echo America/New_York)" +} > "$ENVFILE" +chmod 600 "$ENVFILE" + +# --- build & start ----------------------------------------------------------- +echo " building shell-mcp for amd64 (local build, no pull)" +cd "$DEST" || exit 1 + +set -a +# shellcheck disable=SC1090 +. "$ENVFILE" +set +a + +if ! docker compose up -d --build; then + echo " [FAIL] build/start failed" + exit 1 +fi + +# --- verify ------------------------------------------------------------------ +echo " waiting for health endpoint" +for i in $(seq 1 30); do + if curl -fsS -m 3 "http://127.0.0.1:8103/health" >/dev/null 2>&1; then + echo " [ok] /health responding: $(curl -fsS -m 3 http://127.0.0.1:8103/health)" + + # An unauthenticated /sse MUST be rejected. If this ever returns 200 the + # box is publicly shell-able through NPM. + code="$(curl -s -o /dev/null -w '%{http_code}' -m 5 "http://127.0.0.1:8103/sse" || true)" + if [ "$code" = "401" ]; then + echo " [ok] /sse rejects unauthenticated requests (401)" + else + echo " [FAIL] /sse returned $code without a token — DO NOT repoint NPM until fixed" + exit 1 + fi + exit 0 + fi + sleep 2 +done + +echo " [FAIL] health endpoint never came up" +docker compose logs --tail 40 shell-mcp 2>&1 | sed 's/^/ /' +exit 1 diff --git a/scripts/50-plex.sh b/scripts/50-plex.sh new file mode 100755 index 0000000..5283ef1 --- /dev/null +++ b/scripts/50-plex.sh @@ -0,0 +1,135 @@ +#!/usr/bin/env bash +# ============================================================================= +# 50-plex — deploy Plex. HUMAN-IN-THE-LOOP, run by hand, not from bootstrap. +# +# Usage: +# # 1. open https://plex.tv/claim and copy the token +# # 2. within four minutes: +# sudo PLEX_CLAIM=claim-xxxxxxxxxxxx /srv/mediabox-bootstrap/scripts/50-plex.sh +# +# A claim token expires four minutes after it is issued. That is the entire +# reason this stage is not automated: there is no way to bake one into a USB, +# a repo, or a first-boot script and have it still be valid. +# +# Claiming is only required on the FIRST start. Re-running later without +# PLEX_CLAIM is fine and will not re-claim. +# ============================================================================= +set -uo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DEST="/srv/plex" +MASTER="/srv/secrets/stacks.env" + +# --- gate 1: mounts must be live BEFORE Plex ever scans ---------------------- +# If Plex scans a library whose mount is missing, it sees zero files. With a +# read-write NAS account, "empty trash after scan" would then delete the +# library's records — and Plex has the permission to act on that. Never let +# Plex start against absent mounts. +echo " checking NAS mounts before starting Plex" +missing=0 +for mp in /mnt/nas/audiobooks /mnt/nas/education-videos /mnt/nas/health \ + /mnt/nas/home-movies /mnt/nas/media /mnt/nas/music-organized \ + /mnt/nas/pictures /mnt/nas/radio-shows; do + if ls "$mp" >/dev/null 2>&1 && mountpoint -q "$mp"; then + printf ' [ok] %s\n' "$mp" + else + printf ' [FAIL] %s not mounted\n' "$mp" + missing=1 + fi +done +if [ "$missing" -ne 0 ]; then + echo + echo " REFUSING to start Plex with missing mounts." + echo " Fix with: sudo /srv/mediabox-bootstrap/scripts/20-cifs.sh --verify" + exit 1 +fi + +# --- gate 2: Quick Sync ----------------------------------------------------- +if [ ! -e /dev/dri/renderD128 ]; then + echo " [WARN] /dev/dri/renderD128 missing — iGPU not enabled in BIOS." + echo " Plex will still run, but every transcode will be software." + RENDER_GID=993 +else + RENDER_GID="$(stat -c '%g' /dev/dri/renderD128)" + echo " render node gid: $RENDER_GID" +fi +export RENDER_GID + +install -d -m 0755 "$DEST" +install -d -m 0755 "$DEST/config" +chown -R 3000:3000 "$DEST" +install -m 0644 "$REPO_DIR/plex/docker-compose.yml" "$DEST/docker-compose.yml" + +# --- env -------------------------------------------------------------------- +set -a +TZ="$(grep -E '^TZ=' "$MASTER" 2>/dev/null | head -1 | cut -d= -f2- || echo America/New_York)" +PLEX_ADVERTISE_URL="$(grep -E '^PLEX_ADVERTISE_URL=' "$MASTER" 2>/dev/null | head -1 | cut -d= -f2- || echo http://10.0.1.20:32400)" +PLEX_CLAIM="${PLEX_CLAIM:-}" +set +a + +if [ -n "$PLEX_CLAIM" ]; then + echo " claim token supplied (expires 4 min from issue — moving now)" +else + echo " no PLEX_CLAIM given; assuming this server is already claimed" +fi + +cd "$DEST" || exit 1 +docker compose up -d || { echo " [FAIL] compose up failed"; exit 1; } + +# The claim token must not linger anywhere on disk. +unset PLEX_CLAIM + +echo " waiting for Plex to answer on 32400" +for i in $(seq 1 60); do + if curl -fsS -m 3 "http://127.0.0.1:32400/identity" >/dev/null 2>&1; then + echo " [ok] Plex is up" + break + fi + sleep 3 +done + +# --- gate 3: disable auto-empty-trash, permanently --------------------------- +# This is the single most dangerous default on this box. If a CIFS mount is +# missing at scan time Plex sees an empty library; with autoEmptyTrash on it +# begins trimming, and the NAS account is read-write. Turn it off in the +# config rather than trusting a UI checkbox to stay ticked. +PREFS="$DEST/config/Library/Application Support/Plex Media Server/Preferences.xml" +if [ -f "$PREFS" ]; then + if grep -q 'autoEmptyTrash="0"' "$PREFS"; then + echo " [ok] autoEmptyTrash already disabled" + else + echo " disabling autoEmptyTrash (requires a Plex restart)" + docker compose stop plex >/dev/null 2>&1 + cp -a "$PREFS" "${PREFS}.bak.$(date +%Y%m%d%H%M%S)" + if grep -q 'autoEmptyTrash=' "$PREFS"; then + sed -i 's/autoEmptyTrash="[^"]*"/autoEmptyTrash="0"/' "$PREFS" + else + sed -i 's//dev/null 2>&1 + echo " [ok] autoEmptyTrash=0 written" + fi +else + cat <<'MSG' + [WARN] Preferences.xml not written yet (first start is still initialising). + Re-run this script once Plex has fully started to disable + autoEmptyTrash, OR untick it by hand: + Settings > Library > "Empty trash automatically after every scan" + Do this BEFORE adding any library. With read-write NAS credentials a + missing mount plus this setting deletes media records. +MSG +fi + +cat <<'NEXT' + + Next: + * Open http://10.0.1.20:32400/web and confirm the server is claimed. + * Add libraries pointing at /media/... (the container paths), not /mnt/nas. + * Verify a transcode is using Quick Sync: + docker exec plex ls -l /dev/dri + # start a transcode, then: + intel_gpu_top # Video/VideoEnhance rows should be busy +NEXT + +exit 0 diff --git a/secrets/stacks.env.example b/secrets/stacks.env.example new file mode 100644 index 0000000..26da94b --- /dev/null +++ b/secrets/stacks.env.example @@ -0,0 +1,43 @@ +# ============================================================================= +# stacks.env — mediabox master operator env +# host path: /srv/secrets/stacks.env (root:root, 0600) +# +# THIS IS THE MEDIA BOX'S OWN FILE. It is deliberately NOT shared with, or +# mounted from, arrsstack. One host, one secrets file. The canonical copy of +# every value lives in Vaultwarden; this is the runtime copy. +# +# Three tiers, same convention as arrsstack: +# [OPERATOR] secrets Claude loads to operate the host; never emitted into +# a container env slice +# [VALUES] flat, DEDUPED KEY=value — define each variable exactly once +# [MANIFEST] #@stack lines mapping a stack -> the vars it needs +# +# Load with: set -a; . /srv/secrets/stacks.env; set +a +# +# NOTE: NAS credentials are NOT in this file. fstab needs them at mount time, +# which happens long before Docker exists, so they live in /etc/cifs/*.cred +# (0600) instead. That file is created EMPTY by the autoinstall and filled in +# post-boot over the MCP, so the password never touches removable media. +# +# This example file carries placeholders only. 10-secrets.sh seeds the real +# file from it and thereafter only ever ADDS missing keys. +# ============================================================================= + +# ----- [OPERATOR] — loaded for Claude's use; NEVER written into a stack slice ----- +# (none yet on this host) + +# ----- [VALUES] — flat, deduplicated. Define each variable exactly once. ----- +TZ=America/New_York + +# shell-mcp (native, replaces the mediabox-mcp SSH proxy on arrsstack). +# Use the SAME value that arrsstack's stacks.env already holds — the Claude +# connector entry and the NPM cert do not change during the cutover, so the +# token should not either. Copy it across; do not mint a new one. +MEDIABOX_MCP_BEARER_TOKEN=FILL_ME + +# plex +PLEX_ADVERTISE_URL=http://10.0.1.20:32400 + +# ----- [MANIFEST] — stack -> vars it needs (comments; genenv.sh parses these) ----- +#@stack shell-mcp = MEDIABOX_MCP_BEARER_TOKEN TZ +#@stack plex = TZ PLEX_ADVERTISE_URL diff --git a/shell-mcp/Dockerfile b/shell-mcp/Dockerfile new file mode 100644 index 0000000..52cec5c --- /dev/null +++ b/shell-mcp/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +# docker.io provides the Docker CLI; util-linux provides nsenter for host +# namespace access. Both exist for amd64 — verified 2026-07-27. +RUN apt-get update && \ + apt-get install -y --no-install-recommends docker.io util-linux && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Pinned install. Every dependency in this tree resolves to a prebuilt +# manylinux x86_64 wheel (pydantic-core, rpds-py, cffi, cryptography), so no +# compiler is needed and the build is fast on amd64. +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY server.py . + +EXPOSE 8103 + +# Fails fast and visibly if the container is up but the app is wedged. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request,os,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:'+os.environ.get('PORT','8103')+'/health',timeout=3).status==200 else 1)" + +CMD ["python", "server.py"] diff --git a/shell-mcp/docker-compose.yml b/shell-mcp/docker-compose.yml new file mode 100644 index 0000000..138df4b --- /dev/null +++ b/shell-mcp/docker-compose.yml @@ -0,0 +1,22 @@ +services: + shell-mcp: + build: + context: . + image: shell-mcp:mediabox + container_name: shell-mcp + restart: unless-stopped + ports: + # Published on the LAN so NPM on arrsstack can reach 10.0.1.20:8103. + # No arrstack_arr_net here — that network lives on the Pi and does not + # exist on this host. The bearer token is the only gate on this port. + - "8103:8103" + environment: + - BEARER_TOKEN=${MEDIABOX_MCP_BEARER_TOKEN} + - PORT=8103 + - TZ=${TZ:-America/New_York} + volumes: + - /var/run/docker.sock:/var/run/docker.sock + # pid: host + privileged + nsenter --target 1 is what gives this container + # full root on the media box host, exactly as on arrsstack. + pid: host + privileged: true diff --git a/shell-mcp/requirements.txt b/shell-mcp/requirements.txt new file mode 100644 index 0000000..db97d38 --- /dev/null +++ b/shell-mcp/requirements.txt @@ -0,0 +1,14 @@ +# Pinned deliberately. +# +# The arrsstack build installed `mcp starlette uvicorn` unpinned. That was +# survivable for a hand-run build; it is not survivable inside a first-boot +# script, where an upstream release between two boots silently changes what +# gets installed. starlette has already gone 1.x since the original build. +# +# These exact versions were resolved and smoke-tested on amd64 / cp312 +# (import, /health 200, /sse endpoint event) on 2026-07-27. +# +# To bump: change here, rebuild, hit /health and /sse, then commit. +mcp==1.28.1 +starlette==1.3.1 +uvicorn==0.51.0 diff --git a/shell-mcp/server.py b/shell-mcp/server.py new file mode 100644 index 0000000..8797d97 --- /dev/null +++ b/shell-mcp/server.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +shell-mcp (mediabox) — run shell commands on the media box host via MCP/SSE. + +Adapted from thethreemagi/shell-mcp, which was written for and built on the +arrsstack Pi 5 (arm64). Differences that matter: + + * Runs on 10.0.1.20 (amd64), NOT on arrsstack. The tool description below is + rewritten accordingly — if it still claimed to be the Pi, every session + would start with the wrong mental model of which host it is touching. + * Listens on 8103 by default, not 8085, so NPM proxy host 42 can simply be + repointed from mediabox-mcp:8103 to 10.0.1.20:8103. Same URL, same cert, + same connector entry. + * PORT is read from the environment instead of being hardcoded. + * Dependencies are pinned (see requirements.txt). The original installed + `mcp starlette uvicorn` unpinned; starlette has since gone 1.x. An + unpinned install inside a first-boot script is a time bomb. + +Commands run in the host namespaces via nsenter — full root on the media box. +""" +import os +import shlex +import subprocess + +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from mcp.types import Tool, TextContent +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Mount, Route +import uvicorn + +BEARER_TOKEN = os.environ["BEARER_TOKEN"] +PORT = int(os.environ.get("PORT", "8103")) + +# ── MCP server ────────────────────────────────────────────────────────────── + +mcp = Server("shell-mcp") + + +@mcp.list_tools() +async def list_tools() -> list[Tool]: + return [ + Tool( + name="run_command", + description=( + "Run a shell command on the MEDIA BOX host (10.0.1.20, hostname " + "'mediabox') with full root access. This is the Plex / Docker / " + "GPU host — an amd64 machine with an Intel i7-8700K, UHD 630 " + "Quick Sync, and an NVIDIA GTX 1070. It is NOT arrsstack; that " + "is a separate Raspberry Pi connector. Commands run in host " + "namespaces via nsenter, so filesystem, network, processes and " + "systemd services are all the real host. Docker CLI available. " + "The NAS is mounted under /mnt/nas/. Use for container " + "management, service control, log inspection, file read/write, " + "and general system administration." + ), + inputSchema={ + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Shell command to execute on the media box host (bash -c)", + }, + "working_directory": { + "type": "string", + "description": "Directory on the host to run the command in (optional)", + }, + "timeout": { + "type": "integer", + "description": "Timeout in seconds, default 30, max 300", + "default": 30, + }, + }, + "required": ["command"], + }, + ) + ] + + +@mcp.call_tool() +async def call_tool(name: str, arguments: dict) -> list[TextContent]: + if name != "run_command": + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + command = arguments["command"] + working_dir = arguments.get("working_directory") + timeout = min(int(arguments.get("timeout", 30)), 300) + + inner = f"cd {shlex.quote(working_dir)} && {command}" if working_dir else command + + host_cmd = ( + "nsenter --target 1 --mount --uts --ipc --net --pid -- " + f"bash -c {shlex.quote(inner)}" + ) + + try: + result = subprocess.run( + host_cmd, + shell=True, + executable="/bin/bash", + capture_output=True, + text=True, + timeout=timeout, + ) + parts = [] + if result.stdout: + parts.append(result.stdout.rstrip()) + if result.stderr: + parts.append(f"[stderr]\n{result.stderr.rstrip()}") + if result.returncode != 0: + parts.append(f"[exit code: {result.returncode}]") + return [TextContent(type="text", text="\n".join(parts) or "(no output)")] + + except subprocess.TimeoutExpired: + return [TextContent(type="text", text=f"[timed out after {timeout}s]")] + except Exception as e: + return [TextContent(type="text", text=f"[error: {e}]")] + + +# ── SSE transport ─────────────────────────────────────────────────────────── + +sse = SseServerTransport("/messages/") + + +async def health_endpoint(request: Request): + return JSONResponse({"status": "ok", "host": "mediabox"}) + + +starlette_app = Starlette( + routes=[ + Route("/health", endpoint=health_endpoint), + Mount("/messages/", app=sse.handle_post_message), + ], +) + + +async def app(scope, receive, send): + if scope.get("type") != "http": + await starlette_app(scope, receive, send) + return + + path = scope.get("path", "") + method = scope.get("method", "").upper() + + if path == "/sse": + # Only GET establishes an SSE stream; reject everything else + if method != "GET": + await send({"type": "http.response.start", "status": 405, + "headers": [(b"content-type", b"text/plain"), + (b"allow", b"GET")]}) + await send({"type": "http.response.body", "body": b"Method Not Allowed", + "more_body": False}) + return + + # Auth gate. Claude.ai's connector UI has no bearer-token field, so the + # token may arrive as ?token=; the Authorization header is also accepted. + query_string = scope.get("query_string", b"").decode() + token = None + for part in query_string.split("&"): + if part.startswith("token="): + token = part[6:] + break + + auth_header = "" + for name, value in scope.get("headers", []): + if name.lower() == b"authorization": + auth_header = value.decode() + break + + if token != BEARER_TOKEN and auth_header != f"Bearer {BEARER_TOKEN}": + await send({"type": "http.response.start", "status": 401, + "headers": [(b"content-type", b"text/plain")]}) + await send({"type": "http.response.body", "body": b"Unauthorized", + "more_body": False}) + return + + # Handle SSE directly — bypasses Starlette Route, no NoneType crash on close + async with sse.connect_sse(scope, receive, send) as (read_stream, write_stream): + await mcp.run(read_stream, write_stream, mcp.create_initialization_options()) + return + + await starlette_app(scope, receive, send) + + +if __name__ == "__main__": + uvicorn.run(app, host="0.0.0.0", port=PORT, log_level="info")