commit 3645d1314c2b3daec971f72c60a9e09933795bc0 Author: tim Date: Wed Jul 22 06:28:49 2026 -0700 Initial commit: arr-stack homelab + local AI Docker Compose media stack (gluetun, *arr, Jellyfin), Ternary-Bonsai AI (Open WebUI, Forgejo, optional OpenHands), and Ansible bootstrap for the SER5 Ubuntu host. diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bbb1885 --- /dev/null +++ b/.env.example @@ -0,0 +1,74 @@ +# Copy to .env and adjust. The compose file reads these values. + +# --- user / group that owns your media (run `id` on the host) --- +PUID=1000 +PGID=1000 + +# --- your timezone (https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) --- +TZ=Europe/Berlin + +# --- host paths (relative to this folder is fine) --- +CONFIG_DIR=./config +DATA_DIR=./data + +# --- your LAN subnet, so the qBittorrent WebUI stays reachable through +# gluetun's firewall. If your machine is 192.168.1.50, use 192.168.1.0/24 +LAN_SUBNET=192.168.1.0/24 + +# ============================================================================= +# Local AI (Ternary-Bonsai-27B on SER5 PRO / Ryzen 7735HS) +# ============================================================================= + +# GGUF weights directory (populated by scripts/download-bonsai-model.sh) +BONSAI_MODELS_DIR=./models/bonsai +BONSAI_MODEL_PATH=/models/Ternary-Bonsai-27B-Q2_0.gguf + +# cpu (default) or vulkan — rebuild image after changing: +# BONSAI_BACKEND=vulkan docker compose build --no-cache bonsai +BONSAI_BACKEND=cpu + +# GPU layers: 0 = CPU only. With vulkan image + /dev/dri, try 99. +BONSAI_NGL=0 + +# Context size. 16384 is a solid default on 32–64 GB RAM with the Arr stack. +# Raise for long docs / repo work; set BONSAI_KV4=1 if you run OOM. +BONSAI_CTX=16384 +BONSAI_KV4=0 + +# Leave 0 for auto threads, or e.g. 12 on 16-thread 7735HS to leave headroom +BONSAI_THREADS=0 + +# Sampling (Prism 27B thinking-mode defaults) +BONSAI_TEMP=0.7 +BONSAI_TOP_P=0.95 +BONSAI_TOP_K=20 + +# Host port for llama-server API (OpenAI-compatible at /v1) +BONSAI_PORT=8081 + +# Open WebUI +OPEN_WEBUI_PORT=3000 +OPENAI_API_KEY=sk-local-bonsai +# true = login required (recommended); false = open LAN access +WEBUI_AUTH=true + +# Folder mounted read-only into Open WebUI as /workspace (RAG + file Q&A) +# Point this at your git projects, notes, docs, etc. +AI_WORKSPACE_DIR=./workspace + +# SearXNG (web search for RAG) — published on localhost only +SEARXNG_PORT=8888 +SEARXNG_SECRET=change-me-to-a-long-random-string + +# OpenHands coding agent (docker compose --profile coding up -d) +OPENHANDS_PORT=3001 +# LiteLLM-style id; after first start check http://host:8081/v1/models +OPENHANDS_MODEL=openai/local + +# Forgejo — self-hosted Git (web + SSH clone) +FORGEJO_HTTP_PORT=3002 +FORGEJO_SSH_PORT=2222 +FORGEJO_DOMAIN=192.168.8.123 +FORGEJO_ROOT_URL=http://192.168.8.123:3002/ +# true after you create the admin account (blocks random signups) +FORGEJO_DISABLE_REGISTRATION=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9505473 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ + +ansible/*.retry +ansible/.secrets/ +config/ +config/wg0.conf +data/ +.env +.env.* +!.env.example +*.gguf +models/bonsai/*.gguf +models/bonsai/*.partial +.openhands-state/ +# secrets / local runtime +**/wg0.conf +wireguard/wg0.conf +workspace/* +!workspace/README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..4f077f2 --- /dev/null +++ b/README.md @@ -0,0 +1,323 @@ +# Arr Stack + Local AI + +A dockerized media automation stack (VPN-locked torrents + Jellyfin) **plus** a +local AI stack built around +[Ternary-Bonsai-27B](https://huggingface.co/prism-ml/Ternary-Bonsai-27B-gguf) +for the **Beelink SER5 PRO** (Ryzen 7 7735HS + Radeon 680M). + +## Services + +| Service | URL | Purpose | +|-------------|------------------------|--------------------------------------| +| Jellyfin | http://host:8096 | Media server / playback | +| qBittorrent | http://host:8080 | Torrent client (VPN-only) | +| Prowlarr | http://host:9696 | Indexer manager | +| Sonarr | http://host:8989 | TV shows | +| Radarr | http://host:7878 | Movies | +| Bazarr | http://host:6767 | Subtitles | +| Lidarr | http://host:8686 | Music | +| **Forgejo** | http://host:3002 | Self-hosted Git (SSH :2222) | +| **Open WebUI** | http://host:3000 | Chat UI, RAG, tools, code interpreter | +| **Bonsai API** | http://host:8081/v1 | OpenAI-compatible llama-server | +| SearXNG | http://127.0.0.1:8888 | Private web search (for RAG) | +| OpenHands | http://host:3001 | Coding agent (`--profile coding`) | + +--- + +## Media stack + +### 1. Prepare + +```bash +cd arr-stack +cp .env.example .env # then edit .env (PUID/PGID, TZ, LAN_SUBNET) +``` + +Drop your provider's WireGuard config into `wireguard/wg0.conf` and make the +two edits described in `wireguard/wg0.conf.example`: + +1. **Remove IPv6 addresses** from `Address` / `DNS` lines. +2. **Endpoint must be an IP**, not a hostname (`dig +short your.endpoint.host`). + +Create the data tree (already scaffolded here; on a fresh host run): + +```bash +mkdir -p config data/media/{movies,tv,music} data/torrents/{movies,tv,music} +``` + +The layout uses a **single `/data` share** — this lets the Arrs hardlink +finished downloads into the media library instantly (no copy, no double disk +usage, torrent keeps seeding): + +``` +data/ +├── media/ <- Jellyfin libraries +│ ├── movies/ +│ ├── tv/ +│ └── music/ +└── torrents/ <- qBittorrent downloads here + ├── movies/ + ├── tv/ + └── music/ +``` + +### 2. Start media + +```bash +docker compose up -d +docker compose ps # gluetun must show (healthy) +docker logs -f gluetun # wait for "You are running on the VPN" style line +``` + +qBittorrent waits for gluetun to be healthy before starting. + +### 3. Verify the lockdown (do this once) + +```bash +# IP seen from inside qBittorrent — must be your VPN's IP, NOT your home IP: +docker exec qbittorrent curl -s ifconfig.me && echo + +# Your real IP for comparison: +curl -s ifconfig.me && echo + +# Killswitch test — kill the tunnel, traffic must die with it: +docker exec gluetun sh -c "ip link del tun0" 2>/dev/null +docker exec qbittorrent curl -s --max-time 8 ifconfig.me || echo "NO CONNECTIVITY (good)" +docker restart gluetun # bring the tunnel back +``` + +Belt-and-suspenders extra: in qBittorrent → Tools → Options → Advanced, set +**Network interface** to `tun0`. Even in a hypothetical gluetun failure, +qBittorrent itself would then refuse to use any other interface. + +### 4. Wire it together + +**qBittorrent** (http://host:8080 — default user `admin`, temporary password +is printed in `docker logs qbittorrent` on first start; change it): + +- Options → Downloads → Default Save Path: `/data/torrents` +- Create categories `movies` → `/data/torrents/movies` and `tv` → `/data/torrents/tv` + +**Prowlarr** (http://host:9696): + +- Add your indexers under *Indexers*. +- *Settings → Apps*: add Sonarr (`http://sonarr:8989`), Radarr + (`http://radarr:7878`), each with its API key (found in the Arr under + *Settings → General*). Indexers sync automatically from then on. + +**Sonarr & Radarr** — *Settings → Download Clients* → add qBittorrent: + +- Host: `gluetun`, Port: `8080`, your qBittorrent credentials +- Category: `tv` (Sonarr) / `movies` (Radarr) +- *Settings → Media Management*: root folder `/data/media/tv` or + `/data/media/movies`; **enable hardlinks** (on by default with this layout) + +**Bazarr**: point it at Sonarr/Radarr (`http://sonarr:8989` / +`http://radarr:7878` + API keys); paths `/data/media/tv`, `/data/media/movies`. + +**Lidarr** (http://host:8686) — music, same patterns as Sonarr/Radarr: + +- *Settings → Download Clients* → qBittorrent: Host `gluetun`, Port `8080` +- Category: `music` → qBittorrent save path `/data/torrents/music` +- *Settings → Media Management*: root folder `/data/media/music`; hardlinks on +- In **Prowlarr** → *Settings → Apps*: add Lidarr (`http://lidarr:8686` + API key) + +**Jellyfin** (http://host:8096): add libraries → Movies: `/data/media/movies`, +Shows: `/data/media/tv`, Music: `/data/media/music`. Its media mount is +read-only; enable trickplay/metadata into `/config` (the default) — it never +writes to your media. + +### 5. Optional: provider port forwarding + +If your VPN provider gives you a forwarded port (AirVPN, ProtonVPN, …), +inbound peers can reach you and swarm connectivity improves: + +1. Uncomment `FIREWALL_VPN_INPUT_PORTS` in the compose file and set the port. +2. For providers with a NAT-PMP API also uncomment `VPN_PORT_FORWARDING` and + `VPN_PORT_FORWARDING_PROVIDER`. +3. Set the same port as qBittorrent's listening port (Options → Connection) + and disable UPnP/NAT-PMP inside qBittorrent. + +Never publish qBittorrent's BitTorrent port on the host — inbound connections +must arrive through the tunnel only. + +--- + +## Local AI stack + +### Why this design (and not plain Ollama) + +| Piece | Choice | Reason | +|-------|--------|--------| +| Model | **Ternary-Bonsai-27B** (~7.2 GB Q2_0) | ~95% of FP16 quality at laptop footprint; strong coding/math | +| Runtime | **PrismML llama.cpp** (`llama-server`) | Custom Q2_0 g128 kernels — stock Ollama / mainline won't load the default GGUF | +| Hardware | **SER5 PRO** Ryzen 7 7735HS + Radeon 680M | No NVIDIA → default is **CPU**; optional Vulkan for the iGPU | +| Web UI | **Open WebUI** | Chat, document RAG, web search, tools, code interpreter | +| Search | **SearXNG** | Keyless private search for RAG | +| Coding | **OpenHands** (optional profile) + `/workspace` mount | Agent that edits files / runs commands in a sandbox | + +**Memory ballpark** (model alone, from Prism docs): ~8 GB at 4–10k context, ~14 GB at 100k with FP16 KV. With Arr + Jellyfin running, **32 GB system RAM is the practical minimum**; **64 GB** is comfortable for long context + media. + +### 1. Download the model (~7.2 GB) + +```bash +# Recommended (resume-friendly): +pip install -U "huggingface_hub[cli]" + +./scripts/download-bonsai-model.sh +# files land in ./models/bonsai/ +``` + +Optional: + +```bash +DOWNLOAD_DRAFTER=1 ./scripts/download-bonsai-model.sh # speculative decoding pack (CUDA-oriented) +``` + +### 2. Start AI services + +```bash +# Build Prism llama-server image + start UI + search +docker compose up -d --build bonsai open-webui searxng + +# Health +curl -s http://localhost:8081/health +curl -s http://localhost:8081/v1/models +``` + +Open **http://host:3000** — create the admin account on first visit. +Open WebUI is pre-pointed at `http://bonsai:8080/v1` (see compose env). + +### 3. RAG (documents & local files) + +**Upload / Knowledge collections (best for docs)** + +1. Open WebUI → **Workspace → Knowledge** → create a collection. +2. Upload PDFs, markdown, code, notes. +3. In a chat, attach the collection (or enable it on the model). + +Embeddings download into the Open WebUI volume on first use (local, no cloud). + +**Whole folders via bind-mount** + +`AI_WORKSPACE_DIR` (default `./workspace`) is mounted **read-only** at +`/workspace` inside Open WebUI. Put projects/notes there, or point +`.env` at your real code tree: + +```bash +# .env +AI_WORKSPACE_DIR=/home/you/src +``` + +Then ask e.g. “Summarize the layout of `/workspace/my-app`” after adding those +paths to a Knowledge collection, or use tools/OpenHands for active editing. + +**Web RAG** + +SearXNG is wired as the search engine (`ENABLE_RAG_WEB_SEARCH=true`). In chat +settings enable web search when you want live results. + +### 4. Coding helpers + +| Tool | How | Best for | +|------|-----|----------| +| **Open WebUI + Knowledge** | Upload repo docs / mount `/workspace` | Q&A over code, design docs | +| **Open WebUI code interpreter** | Enabled in compose | Python plots, quick analysis | +| **OpenHands** | `docker compose --profile coding up -d openhands` | Multi-step “change the code / run tests” agents | +| **Continue / Aider / Cursor** (host apps) | Base URL `http://host:8081/v1`, any API key | IDE-native completion & edits | + +**OpenHands** (http://host:3001): + +1. Settings → enable Advanced. +2. Custom model: `openai/` (or keep the pre-set env). +3. Base URL: `http://bonsai:8080/v1` (from inside Docker) or `http://host.docker.internal:8081/v1`. +4. API key: same as `OPENAI_API_KEY` in `.env`. +5. Workspace is `${AI_WORKSPACE_DIR}` — agent sandboxes use Docker socket. + +> Note: Prism lists long-horizon multi-file agentic coding as a weaker area of +> this 27B release; it is still very usable for review, refactors, and smaller +> tasks. Prefer shorter goals and verify with tests. + +### 5. Performance knobs (SER5 PRO) + +```bash +# .env examples + +# Leave headroom for Jellyfin/Arrs on a 16-thread 7735HS +BONSAI_THREADS=12 + +# Longer context for big repos / PDFs (needs RAM) +BONSAI_CTX=32768 +BONSAI_KV4=1 # 4-bit KV cache if you approach OOM + +# Optional: Vulkan offload on Radeon 680M +BONSAI_BACKEND=vulkan +BONSAI_NGL=99 +# then uncomment devices/group_add under bonsai in docker-compose.yml +docker compose build --no-cache bonsai && docker compose up -d bonsai +``` + +Start with **CPU (`BONSAI_NGL=0`)**. iGPU Vulkan can help but shares system +RAM and is more fiddly in Docker; measure both with a short prompt if you care. + +Recommended generation defaults (already set): `temp=0.7`, `top_p=0.95`, +`top_k=20`. The 27B is a **thinking** model — answers may include a reasoning +block; that is expected and improves quality. + +### 6. Quick API smoke test + +```bash +curl -s http://localhost:8081/v1/chat/completions \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer sk-local-bonsai" \ + -d '{ + "model": "local", + "messages": [{"role":"user","content":"Say hello in one short sentence."}], + "temperature": 0.7, + "max_tokens": 128 + }' +``` + +(`model` can be any string or the id returned by `/v1/models` — llama-server is +usually lenient.) + +### 7. What not to do + +- Do **not** route bonsai/open-webui through gluetun — inference is local and + the VPN killswitch would only add pain. +- Do **not** expect stock Ollama to load `Ternary-Bonsai-27B-Q2_0.gguf` without + Prism’s kernels (use our `bonsai` service). +- Keep Open WebUI / OpenHands off the public internet; use LAN + auth (`WEBUI_AUTH=true`). + +--- + +## Layout after AI setup + +``` +arr-stack/ +├── docker-compose.yml +├── .env / .env.example +├── ai/ # Prism llama-server image +│ ├── Dockerfile +│ └── entrypoint.sh +├── models/bonsai/ # GGUF weights (gitignored content) +├── workspace/ # files for RAG / OpenHands +├── searxng/settings.yml +├── scripts/download-bonsai-model.sh +├── config/ # app configs (open-webui, sonarr, …) +├── data/ # media + torrents +└── wireguard/wg0.conf +``` + +## Notes + +- All app-to-app traffic uses container DNS names (`gluetun`, `sonarr`, + `bonsai`, …) on the internal `arr-net` bridge; only WebUIs are exposed to + your LAN. +- Update media stack with `docker compose pull && docker compose up -d`. + Gluetun is pinned to major version `v3` — read its release notes before + jumping majors. +- Rebuild AI runtime after Prism release bumps: edit `PRISM_RELEASE` in + `ai/Dockerfile`, then `docker compose build --no-cache bonsai`. +- Optional media extras (commented in compose): Jellyseerr. +- Optional coding profile: `docker compose --profile coding up -d openhands`. diff --git a/ai/Dockerfile b/ai/Dockerfile new file mode 100644 index 0000000..27dc949 --- /dev/null +++ b/ai/Dockerfile @@ -0,0 +1,67 @@ +# PrismML Ternary-Bonsai 27B server (llama-server OpenAI-compatible API) +# Uses prebuilt binaries from PrismML-Eng/llama.cpp — required for Q2_0 g128 GGUFs. +# +# Build args: +# BONSAI_BACKEND = cpu (default) | vulkan +# cpu — safe default for any host (SER5 PRO works well here) +# vulkan — AMD Radeon 680M offload; needs /dev/dri on the host + Mesa + +ARG BONSAI_BACKEND=cpu + +FROM ubuntu:24.04 AS base + +ARG BONSAI_BACKEND +ENV DEBIAN_FRONTEND=noninteractive \ + BONSAI_BACKEND=${BONSAI_BACKEND} \ + PRISM_RELEASE=prism-b9596-9fcaed7 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl tar libgomp1 \ + && if [ "$BONSAI_BACKEND" = "vulkan" ]; then \ + apt-get install -y --no-install-recommends \ + libvulkan1 mesa-vulkan-drivers vulkan-tools; \ + fi \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /opt/bonsai + +# Prebuilt Prism llama.cpp (group-128 Q2_0 kernels) +RUN set -eux; \ + BASE="https://github.com/PrismML-Eng/llama.cpp/releases/download/${PRISM_RELEASE}"; \ + case "$BONSAI_BACKEND" in \ + vulkan) ASSET="llama-${PRISM_RELEASE}-bin-ubuntu-vulkan-x64.tar.gz"; DEST=bin/vulkan ;; \ + cpu|*) ASSET="llama-${PRISM_RELEASE}-bin-ubuntu-x64.tar.gz"; DEST=bin/cpu ;; \ + esac; \ + mkdir -p "$DEST"; \ + curl -fL --retry 3 "$BASE/$ASSET" -o /tmp/llama.tgz; \ + tar -xzf /tmp/llama.tgz -C "$DEST" --strip-components=1 2>/dev/null \ + || tar -xzf /tmp/llama.tgz -C "$DEST"; \ + rm -f /tmp/llama.tgz; \ + test -x "$DEST/llama-server"; \ + ln -sf "/opt/bonsai/$DEST/llama-server" /usr/local/bin/llama-server; \ + printf '%s\n' "$PRISM_RELEASE" > "$DEST/.llama_release" + +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Models mounted at runtime: /models +VOLUME ["/models"] +EXPOSE 8080 + +ENV LD_LIBRARY_PATH=/opt/bonsai/bin/cpu:/opt/bonsai/bin/vulkan \ + MODEL_PATH=/models/Ternary-Bonsai-27B-Q2_0.gguf \ + HOST=0.0.0.0 \ + PORT=8080 \ + NGL=0 \ + CTX_SIZE=16384 \ + TEMP=0.7 \ + TOP_P=0.95 \ + TOP_K=20 \ + THREADS=0 \ + KV4=0 \ + EXTRA_ARGS= + +HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=5 \ + CMD curl -fsS "http://127.0.0.1:${PORT}/health" || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/ai/entrypoint.sh b/ai/entrypoint.sh new file mode 100755 index 0000000..bba00db --- /dev/null +++ b/ai/entrypoint.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# Start Prism llama-server with Ternary-Bonsai defaults. +set -euo pipefail + +MODEL_PATH="${MODEL_PATH:-/models/Ternary-Bonsai-27B-Q2_0.gguf}" +HOST="${HOST:-0.0.0.0}" +PORT="${PORT:-8080}" +CTX_SIZE="${CTX_SIZE:-16384}" +TEMP="${TEMP:-0.7}" +TOP_P="${TOP_P:-0.95}" +TOP_K="${TOP_K:-20}" +NGL="${NGL:-0}" +THREADS="${THREADS:-0}" +KV4="${KV4:-0}" +EXTRA_ARGS="${EXTRA_ARGS:-}" + +# Prefer vulkan binary tree if present and NGL > 0 +BIN=/usr/local/bin/llama-server +if [ -x /opt/bonsai/bin/vulkan/llama-server ] && [ "${NGL}" != "0" ]; then + export LD_LIBRARY_PATH="/opt/bonsai/bin/vulkan${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + BIN=/opt/bonsai/bin/vulkan/llama-server +elif [ -x /opt/bonsai/bin/cpu/llama-server ]; then + export LD_LIBRARY_PATH="/opt/bonsai/bin/cpu${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" + BIN=/opt/bonsai/bin/cpu/llama-server +fi + +if [ ! -f "$MODEL_PATH" ]; then + echo "ERROR: model not found at $MODEL_PATH" + echo "Run: ./scripts/download-bonsai-model.sh" + echo "Or set MODEL_PATH to your GGUF file." + exit 1 +fi + +# Optional vision projector (27B VLM) +MMPROJ_ARGS=() +if [ -n "${MMPROJ_PATH:-}" ] && [ -f "$MMPROJ_PATH" ]; then + MMPROJ_ARGS=(--mmproj "$MMPROJ_PATH") +else + # Auto-detect mmproj next to the model + _dir="$(dirname "$MODEL_PATH")" + for _mp in "$_dir"/*mmproj*.gguf; do + if [ -f "$_mp" ]; then + MMPROJ_ARGS=(--mmproj "$_mp") + break + fi + done +fi + +# Optional 4-bit KV cache (long context on limited RAM) +KV_ARGS=() +if [ "$KV4" = "1" ]; then + KV_ARGS=(--cache-type-k q4_0 --cache-type-v q4_0) +fi + +# Thread count: 0 = leave llama.cpp default (usually all physical cores) +THREAD_ARGS=() +if [ "$THREADS" != "0" ] && [ -n "$THREADS" ]; then + THREAD_ARGS=(-t "$THREADS") +fi + +echo "=== Ternary-Bonsai llama-server ===" +echo " model: $MODEL_PATH" +echo " binary: $BIN" +echo " ngl: $NGL (0=CPU; for Vulkan image try 99 with /dev/dri)" +echo " context: $CTX_SIZE" +echo " api: http://${HOST}:${PORT}/v1/chat/completions" +[ "${#MMPROJ_ARGS[@]}" -gt 0 ] && echo " vision: ${MMPROJ_ARGS[1]}" +[ "$KV4" = "1" ] && echo " kv: q4_0" +echo "" + +# shellcheck disable=SC2086 +exec "$BIN" \ + -m "$MODEL_PATH" \ + --host "$HOST" \ + --port "$PORT" \ + -ngl "$NGL" \ + -fa on \ + -c "$CTX_SIZE" \ + --temp "$TEMP" \ + --top-p "$TOP_P" \ + --top-k "$TOP_K" \ + --min-p 0 \ + --jinja \ + "${THREAD_ARGS[@]}" \ + "${MMPROJ_ARGS[@]}" \ + "${KV_ARGS[@]}" \ + $EXTRA_ARGS \ + "$@" diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..503d6a9 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,132 @@ +# Homelab Ansible — Ubuntu 24.04 (SER5 PRO) + +Bootstraps the headless mini PC for **arr-stack + Ternary-Bonsai (Vulkan)**. + +| Item | Value | +|------|--------| +| Host | `192.168.8.123:22` | +| User | `tim` | +| SSH key | `~/.ssh/id_ed25519` | +| Stack | `/opt/stack` | +| Media | `/storage` | + +## What it installs + +1. **common** — apt upgrade, essentials (`curl` `wget` `nano` `build-essential` `net-tools`), [Microsoft Edit](https://github.com/microsoft/edit), tools, unattended-upgrades, fail2ban +2. **docker** — Docker CE + Compose plugin, enabled on boot, user in `docker` +3. **amd_vulkan** — Mesa Vulkan, VA-API, firmware, `video`/`render` groups, udev rules +4. **firewall** — UFW: deny inbound, allow stack ports **from LAN only** (`192.168.8.0/24`) +5. **stack** — `/opt/stack` + `/storage`, rsync project, `.env`, Vulkan compose override, **`arr-stack.service`** so compose comes up after reboot (`restart: unless-stopped` on every service) + +## Prerequisites (control machine) + +```bash +# Debian/Ubuntu control node +sudo apt install -y ansible git rsync + +cd /path/to/arr-stack/ansible +ansible-galaxy collection install -r requirements.yml +``` + +SSH must already work: + +```bash +ssh -i ~/.ssh/id_ed25519 tim@192.168.8.123 +``` + +On the **server**, either enable passwordless sudo (recommended for automation): + +```bash +echo 'tim ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/tim +sudo chmod 440 /etc/sudoers.d/tim +``` + +…or pass the sudo password each run with `-K` / `--ask-become-pass`. + +## Run + +```bash +cd ansible + +# Full bootstrap (prompt for sudo if not NOPASSWD) +ansible-playbook site.yml -K + +# Or limit / tags +ansible-playbook site.yml -K --tags docker,amd,firewall +ansible-playbook site.yml -K --tags stack,deploy +ansible-playbook site.yml -K --check # dry-run (partial) +``` + +First connection may prompt for host key (`accept-new` is set in `ansible.cfg`). + +## After the playbook + +On the **server** (or via SSH): + +```bash +ssh tim@192.168.8.123 +# new shell so docker/video/render groups apply +cd /opt/stack + +# WireGuard (if not copied): edit wireguard/wg0.conf +# Model (~7.2G): +./scripts/download-bonsai-model.sh + +# Vulkan image + stack +docker compose build bonsai +docker compose up -d +docker compose ps +``` + +Verify GPU / tools inside the host: + +```bash +vulkaninfo --summary +ls -l /dev/dri +groups # should include docker, video, render +edit --version # Microsoft terminal editor +curl --version && wget --version && nano --version +``` + +Survive reboots: + +```bash +systemctl is-enabled docker arr-stack +systemctl status arr-stack +# containers use restart: unless-stopped; arr-stack.service runs compose up -d on boot +``` + +## Layout on the server + +``` +/opt/stack/ # compose, config, models, workspace, wireguard + docker-compose.yml + docker-compose.override.yml # /dev/dri for bonsai + jellyfin + .env + config/ + models/bonsai/ + workspace/ + wireguard/wg0.conf + +/storage/ # 1TB media disk + media/{movies,tv,music} + torrents/{movies,tv,music} +``` + +`.env` points `DATA_DIR=/storage` and `CONFIG_DIR=/opt/stack/config`. + +## Variables + +Edit `group_vars/all.yml` (or host_vars) for: + +- `lan_subnet`, `timezone` +- `bonsai_backend` / `bonsai_ngl` (default `vulkan` / `99`) +- `ufw_allow_ssh_from_anywhere` (default `false` — SSH only from LAN) +- `deploy_stack_files` — set `false` to skip rsync + +## Security notes + +- Do not commit `wireguard/wg0.conf` or production `.env`. +- UFW allows service ports only from `lan_subnet`. +- Fail2ban protects SSH. +- Re-login after first run so group membership (`docker`, `render`) is active. diff --git a/ansible/ansible.cfg b/ansible/ansible.cfg new file mode 100644 index 0000000..08d1bf1 --- /dev/null +++ b/ansible/ansible.cfg @@ -0,0 +1,25 @@ +[defaults] +inventory = inventory/hosts.ini +roles_path = roles +remote_user = tim +private_key_file = ~/.ssh/id_ed25519 +host_key_checking = True +retry_files_enabled = False +# community.general.yaml was removed; use the built-in default callback's YAML format +stdout_callback = ansible.builtin.default +# (ansible-core 2.13+; ignored on older cores that don't know this key) +callback_result_format = yaml +interpreter_python = auto_silent +deprecation_warnings = False +# Faster on a single host +forks = 5 +timeout = 30 + +[privilege_escalation] +# become is set per-play in site.yml (not global) so ad-hoc ping works without sudo +become_method = sudo +become_user = root + +[ssh_connection] +pipelining = True +ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=accept-new diff --git a/ansible/group_vars/all.yml b/ansible/group_vars/all.yml new file mode 100644 index 0000000..e18c735 --- /dev/null +++ b/ansible/group_vars/all.yml @@ -0,0 +1,221 @@ +--- +# --------------------------------------------------------------------------- +# Host layout +# --------------------------------------------------------------------------- +stack_root: /opt/stack +storage_root: /storage +stack_owner: tim +stack_group: tim + +# Media tree lives on the 1TB disk +data_dir: "{{ storage_root }}" +config_dir: "{{ stack_root }}/config" +models_dir: "{{ stack_root }}/models/bonsai" +workspace_dir: "{{ stack_root }}/workspace" +wireguard_dir: "{{ stack_root }}/wireguard" + +# LAN (SER5 is 192.168.8.123) +lan_subnet: 192.168.8.0/24 +timezone: America/Los_Angeles + +# --------------------------------------------------------------------------- +# Packages +# --------------------------------------------------------------------------- +# Always-on essentials (explicit — also listed in common_packages) +essential_packages: + - curl + - wget + - nano + - build-essential + - net-tools + +# Microsoft Edit (https://github.com/microsoft/edit) — terminal editor +ms_edit_version: "2.0.0" +ms_edit_install: true + +common_packages: + # essentials + - curl + - wget + - nano + - build-essential + - net-tools + # system / packaging + - ca-certificates + - gnupg + - apt-transport-https + - software-properties-common + - git + - python3 + - python3-pip + - python3-venv + - cmake + - ninja-build + - pkg-config + # editors / viewers + - vim + - less + # monitoring / disk + - htop + - btop + - iotop + - iftop + - ncdu + - duf + - sysstat + - smartmontools + - lm-sensors + - nvme-cli + # network + - iproute2 + - dnsutils + - traceroute + - mtr-tiny + - nmap + - tcpdump + # utils + - tree + - jq + - ripgrep + - fd-find + - fzf + - tmux + - rsync + - unzip + - zip + - p7zip-full + - lsof + - strace + - pciutils + - usbutils + - plocate + - bat + - aria2 + # security / updates + - fail2ban + - unattended-upgrades + - update-notifier-common + +# Start stack on boot (docker compose up -d); pairs with restart: unless-stopped +stack_systemd_enable: true +stack_compose_profiles: [] # e.g. ["coding"] to also start openhands + +# AMD / Vulkan / media accel (Radeon 680M on SER5 PRO) +# Note: Ubuntu 24.04 uses mesa-va-drivers (not the older libva-mesa-driver name). +# Many of these live in the "universe" component — role enables it first. +amd_vulkan_packages: + - linux-firmware + - mesa-vulkan-drivers + - mesa-utils + - libvulkan1 + - vulkan-tools + - libgl1-mesa-dri + - libegl1 + - libgles2 + - vainfo + - libva2 + - libva-drm2 + - mesa-va-drivers + - mesa-vdpau-drivers + - clinfo + +# Groups for operator account (tim) — local trusted host, convenience over lockdown +extra_user_groups: + - sudo + - docker + - video + - render + - adm + - disk + - plugdev + - dialout + - cdrom + - users + - systemd-journal + # present on many Ubuntu images; harmless if already primary + - tim + +# Passwordless full sudo for stack_owner (local monitored network) +stack_passwordless_sudo: true + +# --------------------------------------------------------------------------- +# Docker +# --------------------------------------------------------------------------- +docker_packages: + - docker-ce + - docker-ce-cli + - containerd.io + - docker-buildx-plugin + - docker-compose-plugin + +docker_daemon_json: + log-driver: json-file + log-opts: + max-size: "50m" + max-file: "5" + # Leave room for media + inference + default-address-pools: + - base: 172.30.0.0/16 + size: 24 + # Enable cgroup device access patterns used by /dev/dri + features: + buildkit: true + +# --------------------------------------------------------------------------- +# Firewall (UFW) — default deny in, allow out; open stack ports to LAN only +# --------------------------------------------------------------------------- +ufw_enabled: true +ufw_default_incoming: deny +ufw_default_outgoing: allow + +# SSH from anywhere on LAN (and optionally world — keep LAN-only by default) +ufw_allow_ssh_from_anywhere: false + +ufw_lan_tcp_ports: + - { port: 22, comment: "SSH" } + - { port: 8080, comment: "qBittorrent" } + - { port: 8096, comment: "Jellyfin" } + - { port: 8989, comment: "Sonarr" } + - { port: 7878, comment: "Radarr" } + - { port: 9696, comment: "Prowlarr" } + - { port: 6767, comment: "Bazarr" } + - { port: 8686, comment: "Lidarr" } + - { port: 3000, comment: "Open WebUI" } + - { port: 8081, comment: "Bonsai llama-server" } + - { port: 3001, comment: "OpenHands (optional)" } + - { port: 3002, comment: "Forgejo HTTP" } + - { port: 2222, comment: "Forgejo Git SSH" } + +# Forgejo (self-hosted Git) +forgejo_domain: "192.168.8.123" +forgejo_http_port: 3002 +forgejo_ssh_port: 2222 +forgejo_root_url: "http://{{ forgejo_domain }}:{{ forgejo_http_port }}/" + +# --------------------------------------------------------------------------- +# Stack deployment +# --------------------------------------------------------------------------- +# Sync this repo (parent of ansible/) to stack_root on the server. +# Set false if you manage /opt/stack yourself (git clone, etc.). +deploy_stack_files: true + +# Paths on the control machine (the machine running ansible-playbook) +# Default: the arr-stack repo that contains this ansible/ directory +stack_src: "{{ playbook_dir | dirname }}" + +# AI defaults for SER5 + Vulkan +bonsai_backend: vulkan +bonsai_ngl: "99" +bonsai_ctx: "16384" +bonsai_threads: "12" +bonsai_kv4: "0" +open_webui_auth: "true" + +# Media subdirs under storage_root +media_subdirs: + - media/movies + - media/tv + - media/music + - torrents/movies + - torrents/tv + - torrents/music diff --git a/ansible/inventory/hosts.ini b/ansible/inventory/hosts.ini new file mode 100644 index 0000000..064bd6a --- /dev/null +++ b/ansible/inventory/hosts.ini @@ -0,0 +1,7 @@ +# Homelab mini PC (Beelink SER5 PRO / Ubuntu Server 24.04) +[homelab] +ser5 ansible_host=192.168.8.123 ansible_port=22 ansible_user=tim + +[homelab:vars] +ansible_ssh_private_key_file=~/.ssh/id_ed25519 +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..3834074 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,6 @@ +--- +collections: + - name: community.general + version: ">=8.0.0" + - name: ansible.posix + version: ">=1.5.0" diff --git a/ansible/roles/amd_vulkan/handlers/main.yml b/ansible/roles/amd_vulkan/handlers/main.yml new file mode 100644 index 0000000..f39fd42 --- /dev/null +++ b/ansible/roles/amd_vulkan/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Reload udev + ansible.builtin.shell: | + udevadm control --reload-rules + udevadm trigger + changed_when: true diff --git a/ansible/roles/amd_vulkan/tasks/main.yml b/ansible/roles/amd_vulkan/tasks/main.yml new file mode 100644 index 0000000..44271a6 --- /dev/null +++ b/ansible/roles/amd_vulkan/tasks/main.yml @@ -0,0 +1,102 @@ +--- +# Radeon 680M (RDNA2 iGPU) on SER5 PRO — Mesa Vulkan for llama.cpp / Jellyfin VAAPI + +- name: Install software-properties-common (for add-apt-repository) + ansible.builtin.apt: + name: software-properties-common + state: present + tags: [amd, vulkan, gpu] + +- name: Enable Ubuntu universe and multiverse components + ansible.builtin.command: add-apt-repository -y {{ item }} + loop: + - universe + - multiverse + register: apt_components + # With loop, changed_when runs per item — use item stdout, not .results + changed_when: "'Adding component' in (apt_components.stdout | default(''))" + failed_when: apt_components.rc is defined and apt_components.rc != 0 + tags: [amd, vulkan, gpu] + +- name: Update apt cache after enabling universe/multiverse + ansible.builtin.apt: + update_cache: true + tags: [amd, vulkan, gpu] + +- name: Install AMD / Mesa / Vulkan packages + ansible.builtin.apt: + name: "{{ amd_vulkan_packages }}" + state: present + update_cache: false + tags: [amd, vulkan, gpu] + +- name: Ensure video and render groups exist + ansible.builtin.group: + name: "{{ item }}" + state: present + loop: + - video + - render + tags: [amd, vulkan, gpu] + +- name: Add stack user to video and render (GPU device access) + ansible.builtin.user: + name: "{{ stack_owner }}" + groups: + - video + - render + append: true + tags: [amd, vulkan, gpu] + +- name: Check for DRM render nodes + ansible.builtin.find: + paths: /dev/dri + patterns: "renderD*,card*" + file_type: any + register: dri_nodes + tags: [amd, vulkan, gpu] + failed_when: false + +- name: Report /dev/dri devices + ansible.builtin.debug: + msg: >- + DRM nodes: + {{ dri_nodes.files | map(attribute='path') | list | default(['(none found — check kernel / firmware)'], true) }} + tags: [amd, vulkan, gpu] + +- name: Probe Vulkan (as stack user when possible) + become: true + become_user: "{{ stack_owner }}" + ansible.builtin.command: vulkaninfo --summary + register: vulkan_summary + changed_when: false + failed_when: false + tags: [amd, vulkan, gpu] + +- name: Show Vulkan summary (first lines) + ansible.builtin.debug: + msg: "{{ (vulkan_summary.stdout_lines | default(['vulkaninfo failed or not usable yet']))[:40] }}" + tags: [amd, vulkan, gpu] + +- name: Probe VA-API + ansible.builtin.command: vainfo + register: vainfo_out + changed_when: false + failed_when: false + tags: [amd, vulkan, gpu] + +- name: Show vainfo snippet + ansible.builtin.debug: + msg: "{{ (vainfo_out.stdout_lines | default(vainfo_out.stderr_lines | default(['vainfo unavailable'])))[:25] }}" + tags: [amd, vulkan, gpu] + +- name: udev rule — ensure render nodes are accessible by render group + ansible.builtin.copy: + dest: /etc/udev/rules.d/99-amd-render.rules + mode: "0644" + content: | + # Homelab: AMD GPU / APU render nodes for Docker + Vulkan + KERNEL=="renderD*", GROUP="render", MODE="0660" + KERNEL=="card*", GROUP="video", MODE="0660" + notify: Reload udev + tags: [amd, vulkan, gpu] diff --git a/ansible/roles/common/handlers/main.yml b/ansible/roles/common/handlers/main.yml new file mode 100644 index 0000000..c86a3ee --- /dev/null +++ b/ansible/roles/common/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart fail2ban + ansible.builtin.service: + name: fail2ban + state: restarted diff --git a/ansible/roles/common/tasks/main.yml b/ansible/roles/common/tasks/main.yml new file mode 100644 index 0000000..e1ba63f --- /dev/null +++ b/ansible/roles/common/tasks/main.yml @@ -0,0 +1,179 @@ +--- +- name: Set system timezone + community.general.timezone: + name: "{{ timezone }}" + tags: [common, timezone] + +- name: Wait for any existing unattended-upgrade / dpkg lock + ansible.builtin.shell: | + while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \ + || fuser /var/lib/apt/lists/lock >/dev/null 2>&1; do + sleep 5 + done + changed_when: false + tags: [common, apt] + +- name: Update apt cache + ansible.builtin.apt: + update_cache: true + cache_valid_time: 3600 + tags: [common, apt] + +- name: Upgrade all packages + ansible.builtin.apt: + upgrade: dist + autoremove: true + autoclean: true + tags: [common, apt, upgrade] + +- name: Install essential packages (curl wget nano build-essential net-tools) + ansible.builtin.apt: + name: "{{ essential_packages }}" + state: present + tags: [common, packages, essentials] + +- name: Install common packages + ansible.builtin.apt: + name: "{{ common_packages }}" + state: present + tags: [common, packages] + +# --------------------------------------------------------------------------- +# Microsoft Edit — terminal-based editor (https://github.com/microsoft/edit) +# --------------------------------------------------------------------------- +- name: Resolve Microsoft Edit archive name for this arch + ansible.builtin.set_fact: + ms_edit_arch_triple: >- + {{ + 'x86_64-linux-gnu' if ansible_architecture in ['x86_64', 'amd64'] + else ( + 'aarch64-linux-gnu' if ansible_architecture in ['aarch64', 'arm64'] + else 'UNSUPPORTED' + ) + }} + when: ms_edit_install | bool + tags: [common, packages, edit] + +- name: Fail if Microsoft Edit has no binary for this architecture + ansible.builtin.fail: + msg: "No Microsoft Edit release for architecture {{ ansible_architecture }}" + when: + - ms_edit_install | bool + - ms_edit_arch_triple == 'UNSUPPORTED' + tags: [common, packages, edit] + +- name: Check whether Microsoft Edit is already installed at the desired version + ansible.builtin.command: /usr/local/bin/edit --version + register: ms_edit_installed + changed_when: false + failed_when: false + when: ms_edit_install | bool + tags: [common, packages, edit] + +- name: Download Microsoft Edit release tarball + ansible.builtin.get_url: + url: >- + https://github.com/microsoft/edit/releases/download/v{{ ms_edit_version }}/edit-{{ ms_edit_version }}-{{ ms_edit_arch_triple | trim }}.tar.gz + dest: "/tmp/edit-{{ ms_edit_version }}.tar.gz" + mode: "0644" + when: + - ms_edit_install | bool + - ms_edit_installed.rc is not defined or ms_edit_installed.rc != 0 + or (ms_edit_version not in (ms_edit_installed.stdout | default(''))) + tags: [common, packages, edit] + +- name: Install Microsoft Edit binary to /usr/local/bin/edit + ansible.builtin.unarchive: + src: "/tmp/edit-{{ ms_edit_version }}.tar.gz" + dest: /usr/local/bin + remote_src: true + mode: "0755" + owner: root + group: root + when: + - ms_edit_install | bool + - ms_edit_installed.rc is not defined or ms_edit_installed.rc != 0 + or (ms_edit_version not in (ms_edit_installed.stdout | default(''))) + tags: [common, packages, edit] + +- name: Verify Microsoft Edit is on PATH + ansible.builtin.command: /usr/local/bin/edit --version + register: ms_edit_verify + changed_when: false + failed_when: ms_edit_verify.rc != 0 + when: ms_edit_install | bool + tags: [common, packages, edit] + +- name: Show Microsoft Edit version + ansible.builtin.debug: + msg: "{{ ms_edit_verify.stdout | default(ms_edit_installed.stdout) | default('edit installed') }}" + when: ms_edit_install | bool + tags: [common, packages, edit] + +- name: Enable unattended-upgrades + ansible.builtin.debconf: + name: unattended-upgrades + question: unattended-upgrades/enable_auto_updates + value: "true" + vtype: boolean + tags: [common, unattended] + +- name: Configure unattended-upgrades (security only) + ansible.builtin.copy: + dest: /etc/apt/apt.conf.d/20auto-upgrades + mode: "0644" + content: | + APT::Periodic::Update-Package-Lists "1"; + APT::Periodic::Unattended-Upgrade "1"; + APT::Periodic::AutocleanInterval "7"; + tags: [common, unattended] + +- name: Prefer security updates in unattended-upgrades + ansible.builtin.lineinfile: + path: /etc/apt/apt.conf.d/50unattended-upgrades + regexp: '{{ item.regexp }}' + line: '{{ item.line }}' + state: present + loop: + - regexp: '^\s*//\s*"\$\{distro_id\}:\$\{distro_codename\}-security";' + line: ' "${distro_id}:${distro_codename}-security";' + - regexp: 'Unattended-Upgrade::Remove-Unused-Dependencies' + line: 'Unattended-Upgrade::Remove-Unused-Dependencies "true";' + failed_when: false + tags: [common, unattended] + +- name: Enable and start fail2ban + ansible.builtin.service: + name: fail2ban + state: started + enabled: true + tags: [common, fail2ban] + +- name: Fail2ban SSH jail (sshd) + ansible.builtin.copy: + dest: /etc/fail2ban/jail.d/sshd.local + mode: "0644" + content: | + [sshd] + enabled = true + port = ssh + filter = sshd + maxretry = 5 + bantime = 1h + findtime = 10m + notify: Restart fail2ban + tags: [common, fail2ban] + +- name: Ensure plocate database is built + ansible.builtin.command: updatedb + changed_when: false + failed_when: false + tags: [common] + +- name: Detect sensors (lm-sensors) + ansible.builtin.command: sensors-detect --auto + args: + creates: /etc/sensors3.conf + failed_when: false + changed_when: false + tags: [common] diff --git a/ansible/roles/docker/handlers/main.yml b/ansible/roles/docker/handlers/main.yml new file mode 100644 index 0000000..07aa0eb --- /dev/null +++ b/ansible/roles/docker/handlers/main.yml @@ -0,0 +1,5 @@ +--- +- name: Restart docker + ansible.builtin.service: + name: docker + state: restarted diff --git a/ansible/roles/docker/tasks/main.yml b/ansible/roles/docker/tasks/main.yml new file mode 100644 index 0000000..06ee700 --- /dev/null +++ b/ansible/roles/docker/tasks/main.yml @@ -0,0 +1,91 @@ +--- +- name: Install Docker apt prerequisites + ansible.builtin.apt: + name: + - ca-certificates + - curl + - gnupg + state: present + tags: [docker] + +- name: Create keyrings directory + ansible.builtin.file: + path: /etc/apt/keyrings + state: directory + mode: "0755" + tags: [docker] + +- name: Add Docker official GPG key + ansible.builtin.get_url: + url: https://download.docker.com/linux/ubuntu/gpg + dest: /etc/apt/keyrings/docker.asc + mode: "0644" + force: true + tags: [docker] + +- name: Add Docker apt repository (deb822) + ansible.builtin.deb822_repository: + name: docker + types: [deb] + uris: https://download.docker.com/linux/ubuntu + suites: ["{{ ansible_distribution_release }}"] + components: [stable] + architectures: "{{ [ansible_architecture | replace('x86_64', 'amd64') | replace('aarch64', 'arm64')] }}" + signed_by: /etc/apt/keyrings/docker.asc + state: present + enabled: true + tags: [docker] + +- name: Install Docker Engine + Compose plugin + ansible.builtin.apt: + name: "{{ docker_packages }}" + state: present + update_cache: true + tags: [docker] + +- name: Ensure docker group exists + ansible.builtin.group: + name: docker + state: present + tags: [docker] + +- name: Configure Docker daemon.json + ansible.builtin.copy: + dest: /etc/docker/daemon.json + mode: "0644" + content: "{{ docker_daemon_json | to_nice_json }}\n" + notify: Restart docker + tags: [docker] + +- name: Enable and start Docker (survives reboots) + ansible.builtin.systemd: + name: docker + state: started + enabled: true + tags: [docker] + +- name: Enable containerd (Docker dependency) + ansible.builtin.systemd: + name: containerd + state: started + enabled: true + tags: [docker] + +- name: Add stack user to docker / video / render groups + ansible.builtin.user: + name: "{{ stack_owner }}" + groups: "{{ extra_user_groups }}" + append: true + tags: [docker, users] + # Full permission setup also runs in the users role + +- name: Verify docker works + ansible.builtin.command: docker version --format '{% raw %}{{.Server.Version}}{% endraw %}' + register: docker_ver + changed_when: false + tags: [docker] + +- name: Show docker version + ansible.builtin.debug: + msg: "Docker server {{ docker_ver.stdout }}" + tags: [docker] diff --git a/ansible/roles/firewall/tasks/main.yml b/ansible/roles/firewall/tasks/main.yml new file mode 100644 index 0000000..b40d55b --- /dev/null +++ b/ansible/roles/firewall/tasks/main.yml @@ -0,0 +1,71 @@ +--- +- name: Install UFW + ansible.builtin.apt: + name: ufw + state: present + tags: [firewall, ufw] + +- name: Set UFW default policies + community.general.ufw: + direction: "{{ item.direction }}" + policy: "{{ item.policy }}" + loop: + - { direction: incoming, policy: "{{ ufw_default_incoming }}" } + - { direction: outgoing, policy: "{{ ufw_default_outgoing }}" } + tags: [firewall, ufw] + +- name: Allow OpenSSH (rate-limited) from LAN + community.general.ufw: + rule: limit + port: "22" + proto: tcp + src: "{{ lan_subnet }}" + comment: "SSH from LAN" + tags: [firewall, ufw] + +- name: Allow OpenSSH from anywhere (optional) + community.general.ufw: + rule: limit + port: "22" + proto: tcp + comment: "SSH world" + when: ufw_allow_ssh_from_anywhere | bool + tags: [firewall, ufw] + +- name: Allow stack service ports from LAN + community.general.ufw: + rule: allow + port: "{{ item.port | string }}" + proto: tcp + src: "{{ lan_subnet }}" + comment: "{{ item.comment }}" + loop: "{{ ufw_lan_tcp_ports }}" + # SSH already handled above with limit; skip duplicate 22 if listed + when: item.port | int != 22 + tags: [firewall, ufw] + +- name: Allow Docker published ports on loopback freely (implicit) + ansible.builtin.debug: + msg: "UFW will leave local/docker internal traffic alone; LAN rules cover published WebUIs." + tags: [firewall, ufw] + +# Docker + UFW: without this, published ports can bypass UFW on some setups. +# Route forwarded packets through ufw-before-forward is complex; we rely on +# binding services to LAN + ufw allow from lan_subnet. Documented in README. + +- name: Enable UFW + community.general.ufw: + state: enabled + when: ufw_enabled | bool + tags: [firewall, ufw] + +- name: Show UFW status + ansible.builtin.command: ufw status verbose + register: ufw_status + changed_when: false + tags: [firewall, ufw] + +- name: Print UFW status + ansible.builtin.debug: + msg: "{{ ufw_status.stdout_lines }}" + tags: [firewall, ufw] diff --git a/ansible/roles/stack/handlers/main.yml b/ansible/roles/stack/handlers/main.yml new file mode 100644 index 0000000..c28484f --- /dev/null +++ b/ansible/roles/stack/handlers/main.yml @@ -0,0 +1,4 @@ +--- +- name: Reload systemd + ansible.builtin.systemd: + daemon_reload: true diff --git a/ansible/roles/stack/tasks/main.yml b/ansible/roles/stack/tasks/main.yml new file mode 100644 index 0000000..5145dc3 --- /dev/null +++ b/ansible/roles/stack/tasks/main.yml @@ -0,0 +1,207 @@ +--- +# Layout: compose + config under /opt/stack, media under /storage + +- name: Ensure stack owner exists + ansible.builtin.user: + name: "{{ stack_owner }}" + state: present + shell: /bin/bash + create_home: true + tags: [stack] + +- name: Create /opt/stack hierarchy + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + mode: "0755" + loop: + - "{{ stack_root }}" + - "{{ config_dir }}" + - "{{ models_dir }}" + - "{{ workspace_dir }}" + - "{{ wireguard_dir }}" + - "{{ stack_root }}/ai" + - "{{ stack_root }}/scripts" + - "{{ stack_root }}/searxng" + - "{{ stack_root }}/ansible" + tags: [stack] + +- name: Ensure storage root exists + ansible.builtin.file: + path: "{{ storage_root }}" + state: directory + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + mode: "0755" + tags: [stack, storage] + +- name: Create media / torrent directories on /storage + ansible.builtin.file: + path: "{{ storage_root }}/{{ item }}" + state: directory + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + mode: "0755" + loop: "{{ media_subdirs }}" + tags: [stack, storage] + +- name: Deploy stack files from control machine + ansible.posix.synchronize: + src: "{{ stack_src }}/" + dest: "{{ stack_root }}/" + delete: false + recursive: true + rsync_opts: + - "--exclude=.git" + - "--exclude=config/" + - "--exclude=data/" + - "--exclude=models/bonsai/*.gguf" + - "--exclude=*.partial" + - "--exclude=.env" + - "--exclude=wireguard/wg0.conf" + - "--exclude=ansible/.vault_pass" + when: deploy_stack_files | bool + become: false + tags: [stack, deploy] + +- name: Fix ownership after rsync + ansible.builtin.file: + path: "{{ stack_root }}" + state: directory + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + recurse: true + when: deploy_stack_files | bool + tags: [stack, deploy] + +- name: Deploy production .env for this host + ansible.builtin.template: + src: env.j2 + dest: "{{ stack_root }}/.env" + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + mode: "0600" + tags: [stack, env] + +- name: Deploy WireGuard config if present on control machine + ansible.builtin.copy: + src: "{{ stack_src }}/wireguard/wg0.conf" + dest: "{{ wireguard_dir }}/wg0.conf" + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + mode: "0600" + when: + - deploy_stack_files | bool + ignore_errors: true + tags: [stack, wireguard] + +- name: Note missing local wg0.conf + ansible.builtin.debug: + msg: >- + Place wireguard/wg0.conf on the control machine (or copy to + {{ wireguard_dir }}/wg0.conf on the server) before starting gluetun. + tags: [stack, wireguard] + +- name: Resolve host video group GID (for Docker group_add) + ansible.builtin.command: getent group video + register: getent_video + changed_when: false + failed_when: false + tags: [stack, compose, vulkan] + +- name: Resolve host render group GID (for Docker group_add) + ansible.builtin.command: getent group render + register: getent_render + changed_when: false + failed_when: false + tags: [stack, compose, vulkan] + +- name: Set video/render GID facts + ansible.builtin.set_fact: + video_gid: "{{ (getent_video.stdout.split(':')[2]) if getent_video.rc == 0 else '' }}" + render_gid: "{{ (getent_render.stdout.split(':')[2]) if getent_render.rc == 0 else '' }}" + tags: [stack, compose, vulkan] + +- name: Write docker compose override for Vulkan / paths + ansible.builtin.template: + src: docker-compose.override.yml.j2 + dest: "{{ stack_root }}/docker-compose.override.yml" + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + mode: "0644" + tags: [stack, compose, vulkan] + +- name: Loginctl linger for stack user (optional user services later) + ansible.builtin.command: loginctl enable-linger {{ stack_owner }} + changed_when: false + failed_when: false + tags: [stack] + +# --------------------------------------------------------------------------- +# Persist stack across reboots +# - Every service in docker-compose.yml uses restart: unless-stopped +# - Docker itself is enabled on boot (docker role) +# - This unit runs `docker compose up -d` after docker is ready +# --------------------------------------------------------------------------- +- name: "Assert all active compose services use restart: unless-stopped" + ansible.builtin.shell: | + set -euo pipefail + # Extract service-level restart policies (ignore comments) + bad=$(grep -E '^\s+restart:' "{{ stack_root }}/docker-compose.yml" \ + | grep -v 'unless-stopped' \ + | grep -v '^\s*#' || true) + if [ -n "$bad" ]; then + echo "Services missing restart: unless-stopped:" + echo "$bad" + exit 1 + fi + count=$(grep -cE '^\s+restart:\s*unless-stopped' "{{ stack_root }}/docker-compose.yml" || true) + echo "OK: $count service(s) with restart: unless-stopped" + args: + executable: /bin/bash + register: restart_policy_check + changed_when: false + tags: [stack, restart] + +- name: Show restart policy check + ansible.builtin.debug: + msg: "{{ restart_policy_check.stdout }}" + tags: [stack, restart] + +- name: Install systemd unit for arr-stack compose + ansible.builtin.template: + src: arr-stack.service.j2 + dest: /etc/systemd/system/arr-stack.service + mode: "0644" + when: stack_systemd_enable | bool + notify: Reload systemd + tags: [stack, restart, systemd] + +- name: Enable arr-stack systemd unit (start on boot) + ansible.builtin.systemd: + name: arr-stack.service + enabled: true + daemon_reload: true + when: stack_systemd_enable | bool + tags: [stack, restart, systemd] + +- name: Start arr-stack now (docker compose up -d) + ansible.builtin.systemd: + name: arr-stack.service + state: started + when: stack_systemd_enable | bool + # May fail on first run if images/model not ready — do not hard-fail bootstrap + failed_when: false + register: arr_stack_start + tags: [stack, restart, systemd] + +- name: Report arr-stack service start result + ansible.builtin.debug: + msg: >- + arr-stack.service: + {{ 'started/active' if (arr_stack_start is succeeded and not arr_stack_start.failed | default(false)) + else 'enabled for boot; start deferred (build images / download model first, then: sudo systemctl start arr-stack)' }} + when: stack_systemd_enable | bool + tags: [stack, restart, systemd] diff --git a/ansible/roles/stack/templates/arr-stack.service.j2 b/ansible/roles/stack/templates/arr-stack.service.j2 new file mode 100644 index 0000000..cdbb854 --- /dev/null +++ b/ansible/roles/stack/templates/arr-stack.service.j2 @@ -0,0 +1,23 @@ +[Unit] +Description=arr-stack Docker Compose services +Documentation=file://{{ stack_root }}/README.md +Requires=docker.service +After=docker.service network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +User={{ stack_owner }} +Group={{ stack_group }} +WorkingDirectory={{ stack_root }} +Environment=HOME=/home/{{ stack_owner }} +# All compose services use restart: unless-stopped; this unit brings the stack +# up after boot / docker restarts. +ExecStart=/usr/bin/docker compose {% if stack_compose_profiles | length > 0 %}--profile {{ stack_compose_profiles | join(' --profile ') }} {% endif %}up -d --remove-orphans +ExecStop=/usr/bin/docker compose stop +TimeoutStartSec=0 +TimeoutStopSec=120 + +[Install] +WantedBy=multi-user.target diff --git a/ansible/roles/stack/templates/docker-compose.override.yml.j2 b/ansible/roles/stack/templates/docker-compose.override.yml.j2 new file mode 100644 index 0000000..50fb6d2 --- /dev/null +++ b/ansible/roles/stack/templates/docker-compose.override.yml.j2 @@ -0,0 +1,29 @@ +# Generated by Ansible — merges with docker-compose.yml +# group_add uses numeric host GIDs (names fail inside Ubuntu base images). + +services: +{% if bonsai_backend == "vulkan" %} + bonsai: + devices: + - /dev/dri:/dev/dri +{% if video_gid is defined and video_gid | length > 0 %} + group_add: + - "{{ video_gid }}" +{% if render_gid is defined and render_gid | length > 0 %} + - "{{ render_gid }}" +{% endif %} +{% endif %} + environment: + NGL: "{{ bonsai_ngl }}" +{% endif %} + + jellyfin: + devices: + - /dev/dri:/dev/dri +{% if video_gid is defined and video_gid | length > 0 %} + group_add: + - "{{ video_gid }}" +{% if render_gid is defined and render_gid | length > 0 %} + - "{{ render_gid }}" +{% endif %} +{% endif %} diff --git a/ansible/roles/stack/templates/env.j2 b/ansible/roles/stack/templates/env.j2 new file mode 100644 index 0000000..d62a2aa --- /dev/null +++ b/ansible/roles/stack/templates/env.j2 @@ -0,0 +1,41 @@ +# Generated by Ansible for {{ inventory_hostname }} — do not commit +# Host: {{ ansible_host | default(inventory_hostname) }} + +PUID={{ ansible_user_uid | default(1000) }} +PGID={{ ansible_user_gid | default(1000) }} +TZ={{ timezone }} + +CONFIG_DIR={{ config_dir }} +DATA_DIR={{ data_dir }} +LAN_SUBNET={{ lan_subnet }} + +# --- AI (Ternary-Bonsai + Vulkan on SER5) --- +BONSAI_MODELS_DIR={{ models_dir }} +BONSAI_MODEL_PATH=/models/Ternary-Bonsai-27B-Q2_0.gguf +BONSAI_BACKEND={{ bonsai_backend }} +BONSAI_NGL={{ bonsai_ngl }} +BONSAI_CTX={{ bonsai_ctx }} +BONSAI_KV4={{ bonsai_kv4 }} +BONSAI_THREADS={{ bonsai_threads }} +BONSAI_TEMP=0.7 +BONSAI_TOP_P=0.95 +BONSAI_TOP_K=20 +BONSAI_PORT=8081 + +OPEN_WEBUI_PORT=3000 +OPENAI_API_KEY=sk-local-bonsai +WEBUI_AUTH={{ open_webui_auth }} +AI_WORKSPACE_DIR={{ workspace_dir }} + +SEARXNG_PORT=8888 +SEARXNG_SECRET={{ lookup('password', playbook_dir + '/.secrets/searxng_secret chars=ascii_letters,digits length=48') }} + +OPENHANDS_PORT=3001 +OPENHANDS_MODEL=openai/local + +# Forgejo (self-hosted Git) +FORGEJO_HTTP_PORT={{ forgejo_http_port }} +FORGEJO_SSH_PORT={{ forgejo_ssh_port }} +FORGEJO_DOMAIN={{ forgejo_domain }} +FORGEJO_ROOT_URL={{ forgejo_root_url }} +FORGEJO_DISABLE_REGISTRATION=false diff --git a/ansible/roles/users/handlers/main.yml b/ansible/roles/users/handlers/main.yml new file mode 100644 index 0000000..f39fd42 --- /dev/null +++ b/ansible/roles/users/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Reload udev + ansible.builtin.shell: | + udevadm control --reload-rules + udevadm trigger + changed_when: true diff --git a/ansible/roles/users/tasks/main.yml b/ansible/roles/users/tasks/main.yml new file mode 100644 index 0000000..743b00a --- /dev/null +++ b/ansible/roles/users/tasks/main.yml @@ -0,0 +1,171 @@ +--- +# Local homelab operator account — broad permissions (LAN-trusted host) + +- name: Ensure stack user exists with bash shell + ansible.builtin.user: + name: "{{ stack_owner }}" + state: present + shell: /bin/bash + create_home: true + groups: "{{ extra_user_groups }}" + append: true + tags: [users, permissions] + +- name: Ensure useful system groups exist (create if missing) + ansible.builtin.group: + name: "{{ item }}" + state: present + loop: "{{ extra_user_groups }}" + failed_when: false + tags: [users, permissions] + +- name: Add stack user to all useful groups + ansible.builtin.user: + name: "{{ stack_owner }}" + groups: "{{ extra_user_groups }}" + append: true + tags: [users, permissions] + +- name: Passwordless sudo for stack user (full admin) + ansible.builtin.copy: + dest: "/etc/sudoers.d/{{ stack_owner }}" + mode: "0440" + owner: root + group: root + validate: "visudo -cf %s" + content: | + # Homelab operator — passwordless sudo (local trusted host) + {{ stack_owner }} ALL=(ALL) NOPASSWD:ALL + Defaults:{{ stack_owner }} !env_reset + Defaults:{{ stack_owner }} secure_path="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/snap/bin" + tags: [users, permissions, sudo] + +- name: Allow stack user to manage Docker and arr-stack via systemctl without password + ansible.builtin.copy: + dest: "/etc/sudoers.d/{{ stack_owner }}-services" + mode: "0440" + owner: root + group: root + validate: "visudo -cf %s" + content: | + # Redundant with NOPASSWD:ALL; kept explicit for clarity if sudo is tightened later + {{ stack_owner }} ALL=(root) NOPASSWD: /bin/systemctl * docker*, /bin/systemctl * containerd*, /bin/systemctl * arr-stack*, /usr/bin/systemctl * docker*, /usr/bin/systemctl * containerd*, /usr/bin/systemctl * arr-stack* + tags: [users, permissions, sudo] + +- name: Install ACL tools + ansible.builtin.apt: + name: acl + state: present + tags: [users, permissions] + +- name: Own stack and storage roots + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + mode: "0775" + loop: + - "{{ stack_root }}" + - "{{ storage_root }}" + tags: [users, permissions] + +- name: Recursive ownership of stack tree + ansible.builtin.file: + path: "{{ stack_root }}" + state: directory + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + recurse: true + tags: [users, permissions] + +- name: Recursive ownership of storage tree + ansible.builtin.file: + path: "{{ storage_root }}" + state: directory + owner: "{{ stack_owner }}" + group: "{{ stack_group }}" + recurse: true + # Large trees: may take a moment; safe to re-run + tags: [users, permissions] + +- name: Default ACLs so new files under stack stay group-writable + ansible.builtin.command: > + setfacl -R -d -m u:{{ stack_owner }}:rwx,g:{{ stack_group }}:rwx,o::r-x {{ item }} + loop: + - "{{ stack_root }}" + - "{{ storage_root }}" + changed_when: true + failed_when: false + tags: [users, permissions] + +- name: Current ACLs for stack owner full access + ansible.builtin.command: > + setfacl -R -m u:{{ stack_owner }}:rwx,g:{{ stack_group }}:rwx {{ item }} + loop: + - "{{ stack_root }}" + - "{{ storage_root }}" + changed_when: true + failed_when: false + tags: [users, permissions] + +- name: Discover DRM nodes for permission tweak + ansible.builtin.find: + paths: /dev/dri + patterns: "*" + file_type: any + register: dri_nodes + failed_when: false + tags: [users, permissions, gpu] + +- name: Make /dev/dri nodes world-accessible (trusted local host) + ansible.builtin.shell: | + set -e + if [ -d /dev/dri ]; then + chmod a+rw /dev/dri/* 2>/dev/null || true + ls -la /dev/dri + fi + register: dri_chmod + changed_when: "'rw' in dri_chmod.stdout" + failed_when: false + tags: [users, permissions, gpu] + +- name: Persist permissive DRM udev rule for trusted host + ansible.builtin.copy: + dest: /etc/udev/rules.d/99-homelab-dri-permissive.rules + mode: "0644" + content: | + # Trusted LAN homelab — allow any user/container access to GPU nodes + KERNEL=="renderD*", MODE="0666" + KERNEL=="card*", MODE="0666" + KERNEL=="controlD*", MODE="0666" + notify: Reload udev + tags: [users, permissions, gpu] + +- name: Ensure docker socket is group-writable for docker group + ansible.builtin.file: + path: /var/run/docker.sock + group: docker + mode: "0660" + failed_when: false + tags: [users, permissions, docker] + +- name: Verify stack user can sudo without password + become: true + become_user: "{{ stack_owner }}" + ansible.builtin.command: sudo -n true + changed_when: false + tags: [users, permissions] + +- name: Show stack user identity and groups + become: true + become_user: "{{ stack_owner }}" + ansible.builtin.command: id + register: stack_user_id + changed_when: false + tags: [users, permissions] + +- name: Report stack user permissions + ansible.builtin.debug: + msg: "{{ stack_user_id.stdout }}" + tags: [users, permissions] diff --git a/ansible/site.yml b/ansible/site.yml new file mode 100644 index 0000000..466c1da --- /dev/null +++ b/ansible/site.yml @@ -0,0 +1,69 @@ +--- +# Bootstrap Ubuntu Server 24.04 headless host for arr-stack + local AI (Vulkan) +# +# Usage (from this directory): +# ansible-galaxy collection install -r requirements.yml +# ansible-playbook site.yml -l ser5 +# +# Tags: common, docker, amd, vulkan, firewall, stack, upgrade, deploy + +- name: Prepare homelab host + hosts: homelab + gather_facts: true + become: true + + pre_tasks: + - name: Verify target is Ubuntu + ansible.builtin.assert: + that: + - ansible_distribution == "Ubuntu" + - ansible_distribution_major_version is version('24', '>=') + fail_msg: "This playbook targets Ubuntu 24.04+. Found {{ ansible_distribution }} {{ ansible_distribution_version }}" + tags: [always] + + - name: Resolve stack user uid/gid + ansible.builtin.getent: + database: passwd + key: "{{ stack_owner }}" + tags: [always] + + - name: Set PUID/PGID facts from passwd + ansible.builtin.set_fact: + ansible_user_uid: "{{ getent_passwd[stack_owner][1] }}" + ansible_user_gid: "{{ getent_passwd[stack_owner][2] }}" + tags: [always] + + roles: + - role: common + tags: [common] + - role: docker + tags: [docker] + - role: amd_vulkan + tags: [amd, vulkan, gpu] + - role: firewall + tags: [firewall] + - role: stack + tags: [stack] + # After stack paths exist — ownership, sudo, groups, GPU nodes + - role: users + tags: [users, permissions] + + post_tasks: + - name: Summary + ansible.builtin.debug: + msg: | + Host ready: {{ inventory_hostname }} ({{ ansible_host }}) + Stack root: {{ stack_root }} (owned by {{ stack_owner }}) + Media root: {{ storage_root }} (owned by {{ stack_owner }}) + User: {{ stack_owner }} — passwordless sudo, docker/video/render/… + Docker: enabled on boot + Vulkan: Mesa packages + permissive /dev/dri udev (trusted host) + Firewall: UFW LAN {{ lan_subnet }} + + Next on the server (re-login once for groups): + cd {{ stack_root }} + docker compose build bonsai && docker compose up -d + + Open WebUI: http://{{ ansible_host }}:3000 + Jellyfin: http://{{ ansible_host }}:8096 + tags: [always] diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..ee85f66 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,22 @@ +# GPU access for Vulkan (bonsai) + VA-API (jellyfin). +# +# Do NOT use group names like "video"/"render" here — Docker resolves them +# inside the container image, which often has no "render" group and fails with: +# unable to find group render: no matching entries in group file +# +# Options: +# A) Rely on host /dev/dri being 0666 (Ansible users role udev rule) — no group_add. +# B) Use numeric host GIDs: getent group video render +# +# Ansible regenerates this file with host GIDs when you run the stack role. + +services: + bonsai: + devices: + - /dev/dri:/dev/dri + environment: + NGL: "99" + + jellyfin: + devices: + - /dev/dri:/dev/dri diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..6be6f8e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,372 @@ +name: arr-stack + +################################################################################ +# Arr stack +# gluetun WireGuard VPN client + firewall killswitch +# qbittorrent torrent client, lives INSIDE gluetun's network namespace +# prowlarr indexer manager +# sonarr TV radarr movies bazarr subtitles +# jellyfin media server +# +# AI / coding stack (local, no VPN) +# bonsai Prism Ternary-Bonsai-27B via llama-server (OpenAI-compatible API) +# open-webui Chat UI + RAG + tools + code interpreter +# searxng Private metasearch for web RAG +# forgejo Self-hosted Git (repos for coding + OpenHands) +# openhands Optional autonomous coding agent (profile: coding) +# +# Lockdown model: +# * qbittorrent uses `network_mode: service:gluetun` -> it has NO network +# interface of its own. Every packet it sends/receives goes through +# gluetun, and gluetun's built-in firewall only lets traffic out via the +# WireGuard tunnel. Tunnel down = zero connectivity. No leaks possible. +# * Do NOT publish qBittorrent's BitTorrent listening port on the host - +# inbound peer connections must come through the tunnel (port forwarding), +# never via your real IP. +# * AI services stay on arr-net (LAN only). They do NOT route through gluetun. +################################################################################ + +networks: + arr-net: + driver: bridge + +services: + + # ------------------------------------------------------------- VPN gateway # + gluetun: + image: qmcgaw/gluetun:v3 + container_name: gluetun + restart: unless-stopped + cap_add: + - NET_ADMIN + devices: + - /dev/net/tun:/dev/net/tun + networks: + - arr-net + ports: + # Ports for anything running inside gluetun's namespace are published + # HERE, on gluetun - not on the inner container. + - 8080:8080/tcp # qBittorrent WebUI + volumes: + # Your provider's WireGuard config, renamed to wg0.conf + # (see wireguard/wg0.conf.example and the README for required edits) + - ./wireguard/wg0.conf:/gluetun/wireguard/wg0.conf:ro + environment: + VPN_SERVICE_PROVIDER: custom + VPN_TYPE: wireguard + TZ: ${TZ} + # Lets your LAN reach the qBittorrent WebUI through gluetun's firewall + FIREWALL_OUTBOUND_SUBNETS: ${LAN_SUBNET} + # --- optional: provider port forwarding (better swarm connectivity) --- + # FIREWALL_VPN_INPUT_PORTS: "12345" # port your provider assigned you + # VPN_PORT_FORWARDING: "on" # for providers with NAT-PMP API + # VPN_PORT_FORWARDING_PROVIDER: protonvpn + + # --------------------------------------------------------- torrent client # + qbittorrent: + image: lscr.io/linuxserver/qbittorrent:latest + container_name: qbittorrent + restart: unless-stopped + network_mode: service:gluetun # <-- the entire isolation trick + depends_on: + gluetun: + condition: service_healthy # start only once the tunnel is up + environment: + PUID: ${PUID} + PGID: ${PGID} + TZ: ${TZ} + WEBUI_PORT: "8080" + volumes: + - ${CONFIG_DIR}/qbittorrent:/config + - ${DATA_DIR}:/data # single shared tree -> hardlinks + + # -------------------------------------------------------------- indexers # + prowlarr: + image: lscr.io/linuxserver/prowlarr:latest + container_name: prowlarr + restart: unless-stopped + networks: [arr-net] + ports: + - 9696:9696 + environment: &arr-env + PUID: ${PUID} + PGID: ${PGID} + TZ: ${TZ} + volumes: + - ${CONFIG_DIR}/prowlarr:/config + + # -------------------------------------------------------------------- TV # + sonarr: + image: lscr.io/linuxserver/sonarr:latest + container_name: sonarr + restart: unless-stopped + networks: [arr-net] + ports: + - 8989:8989 + environment: *arr-env + volumes: + - ${CONFIG_DIR}/sonarr:/config + - ${DATA_DIR}:/data + + # ----------------------------------------------------------------- movies # + radarr: + image: lscr.io/linuxserver/radarr:latest + container_name: radarr + restart: unless-stopped + networks: [arr-net] + ports: + - 7878:7878 + environment: *arr-env + volumes: + - ${CONFIG_DIR}/radarr:/config + - ${DATA_DIR}:/data + + # -------------------------------------------------------------- subtitles # + bazarr: + image: lscr.io/linuxserver/bazarr:latest + container_name: bazarr + restart: unless-stopped + networks: [arr-net] + ports: + - 6767:6767 + environment: *arr-env + volumes: + - ${CONFIG_DIR}/bazarr:/config + - ${DATA_DIR}:/data + + # ----------------------------------------------------------- media server # + jellyfin: + image: lscr.io/linuxserver/jellyfin:latest + container_name: jellyfin + restart: unless-stopped + networks: [arr-net] + ports: + - 8096:8096/tcp # web UI / clients + # - 8920:8920/tcp # optional HTTPS + # - 7359:7359/udp # optional client autodiscovery + environment: *arr-env + volumes: + - ${CONFIG_DIR}/jellyfin:/config + - ${DATA_DIR}/media:/data/media:ro # read-only: Jellyfin never touches files + # --- optional: Intel QuickSync hardware transcoding --- (Unused on this AMD machine) + # devices: + # - /dev/dri:/dev/dri + + # ------------------------------------------------------------------- music # + lidarr: + image: lscr.io/linuxserver/lidarr:latest + container_name: lidarr + restart: unless-stopped + networks: [arr-net] + ports: + - 8686:8686 + environment: *arr-env + volumes: + - ${CONFIG_DIR}/lidarr:/config + - ${DATA_DIR}:/data # same /data tree as Sonarr/Radarr + + # ========================================================================== + # Optional media extras - uncomment as needed + # ========================================================================== + # + # jellyseerr: # request UI for Jellyfin + # image: fallenbagel/jellyseerr:latest + # container_name: jellyseerr + # restart: unless-stopped + # networks: [arr-net] + # ports: ["5055:5055"] + # environment: *arr-env + # volumes: + # - ${CONFIG_DIR}/jellyseerr:/app/config + + # ========================================================================== + # Local AI — Ternary-Bonsai-27B + Open WebUI + RAG + coding helpers + # Target: Beelink SER5 PRO (Ryzen 7 7735HS + Radeon 680M, no NVIDIA) + # ========================================================================== + + # OpenAI-compatible API for Ternary-Bonsai-27B (PrismML Q2_0 GGUF) + # First: ./scripts/download-bonsai-model.sh + # Then: docker compose up -d --build bonsai open-webui searxng + bonsai: + build: + context: ./ai + dockerfile: Dockerfile + args: + # cpu = reliable default on SER5 PRO + # vulkan = try Radeon 680M offload (also set NGL=99 and uncomment devices) + BONSAI_BACKEND: ${BONSAI_BACKEND:-vulkan} + image: arr-stack/bonsai:local + container_name: bonsai + restart: unless-stopped + networks: [arr-net] + ports: + - "${BONSAI_PORT:-8081}:8080" # avoid clash with qBittorrent :8080 + environment: + TZ: ${TZ} + MODEL_PATH: ${BONSAI_MODEL_PATH:-/models/Ternary-Bonsai-27B-Q2_0.gguf} + # 0 = CPU (recommended start). Vulkan image: try 99 with /dev/dri mounted. + NGL: ${BONSAI_NGL:-99} + # 16k is a good default on 32–64 GB systems with media stack co-resident. + # Long docs / full-repo: try 32768 or 65536; enable KV4=1 if RAM is tight. + CTX_SIZE: ${BONSAI_CTX:-16384} + TEMP: ${BONSAI_TEMP:-0.7} + TOP_P: ${BONSAI_TOP_P:-0.95} + TOP_K: ${BONSAI_TOP_K:-20} + # 0 = auto; or set to physical cores (e.g. 8 on 7735HS) leaving some for Arrs + THREADS: ${BONSAI_THREADS:-0} + KV4: ${BONSAI_KV4:-0} + EXTRA_ARGS: ${BONSAI_EXTRA_ARGS:-} + volumes: + - ${BONSAI_MODELS_DIR:-./models/bonsai}:/models:ro + # Shared memory helps large context / mmap + shm_size: "4gb" + # Vulkan / AMD iGPU: devices + group_add live ONLY in + # docker-compose.override.yml (Compose merges lists and rejects duplicates). + healthcheck: + test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8080/health"] + interval: 30s + timeout: 10s + start_period: 180s + retries: 5 + + # ChatGPT-style UI with built-in RAG, tools, and code interpreter + open-webui: + image: ghcr.io/open-webui/open-webui:main + container_name: open-webui + restart: unless-stopped + networks: [arr-net] + ports: + - "${OPEN_WEBUI_PORT:-3000}:8080" + depends_on: + bonsai: + condition: service_healthy + searxng: + condition: service_started + environment: + TZ: ${TZ} + # Point at local llama-server (OpenAI-compatible) + OPENAI_API_BASE_URL: http://bonsai:8080/v1 + OPENAI_API_KEY: ${OPENAI_API_KEY:-sk-local-bonsai} + # Disable Ollama unless you add it later + ENABLE_OLLAMA_API: "false" + # First account becomes admin; set WEBUI_AUTH=false only on trusted LAN + WEBUI_AUTH: ${WEBUI_AUTH:-true} + # RAG: local embeddings run inside open-webui (downloads on first use) + RAG_EMBEDDING_ENGINE: ${RAG_EMBEDDING_ENGINE:-} + CHUNK_SIZE: ${RAG_CHUNK_SIZE:-1000} + CHUNK_OVERLAP: ${RAG_CHUNK_OVERLAP:-100} + # Web search via local SearXNG + ENABLE_RAG_WEB_SEARCH: "true" + ENABLE_SEARCH_QUERY: "true" + RAG_WEB_SEARCH_ENGINE: searxng + SEARXNG_QUERY_URL: http://searxng:8080/search?q= + # Allow fetching internal / LAN URLs for RAG (homelab docs) + ENABLE_RAG_LOCAL_WEB_FETCH: "true" + # Code interpreter (runs in open-webui sandbox) + ENABLE_CODE_INTERPRETER: "true" + # Reduce background LLM spam on a single local model + ENABLE_TITLE_GENERATION: "false" + ENABLE_FOLLOW_UP_GENERATION: "false" + ENABLE_TAGS_GENERATION: "false" + # Default model id as reported by llama-server /v1/models + DEFAULT_MODELS: ${OPENWEBUI_DEFAULT_MODEL:-} + volumes: + - ${CONFIG_DIR}/open-webui:/app/backend/data + # Your local files for RAG / "read my project" workflows + - ${AI_WORKSPACE_DIR:-./workspace}:/workspace:ro + # Optional: expose media library read-only for Q&A about your library tree + # - ${DATA_DIR}/media:/media:ro + extra_hosts: + - "host.docker.internal:host-gateway" + + # Private metasearch (no API keys) for Open WebUI web RAG + searxng: + image: docker.io/searxng/searxng:latest + container_name: searxng + restart: unless-stopped + networks: [arr-net] + # Bind to localhost only — Open WebUI reaches it on arr-net + ports: + - "127.0.0.1:${SEARXNG_PORT:-8888}:8080" + environment: + SEARXNG_BASE_URL: http://localhost:${SEARXNG_PORT:-8888}/ + SEARXNG_SECRET: ${SEARXNG_SECRET:-change-me-to-a-long-random-string} + volumes: + - ./searxng:/etc/searxng:rw + cap_drop: + - ALL + cap_add: + - CHOWN + - SETGID + - SETUID + + # ------------------------------------------------------------------------- + # Forgejo — self-hosted Git (Gitea soft-fork). SQLite is fine for a homelab. + # Web: http://host:3002 Git SSH: host:2222 (git@host:user/repo.git with port 2222) + # First visit: create admin account, then create repos / push code. + # ------------------------------------------------------------------------- + forgejo: + image: codeberg.org/forgejo/forgejo:11 + container_name: forgejo + restart: unless-stopped + networks: [arr-net] + ports: + - "${FORGEJO_HTTP_PORT:-3002}:3000" + - "${FORGEJO_SSH_PORT:-2222}:22" + environment: + USER_UID: ${PUID:-1000} + USER_GID: ${PGID:-1000} + TZ: ${TZ} + # Image already runs OpenSSH on :22 (mapped to host FORGEJO_SSH_PORT). + # Do NOT enable Forgejo's built-in SSH too — both bind :22 and the + # process fatals with "address already in use" (connection refused on :3002). + FORGEJO__server__START_SSH_SERVER: "false" + FORGEJO__server__DISABLE_SSH: "false" + FORGEJO__server__SSH_PORT: ${FORGEJO_SSH_PORT:-2222} + FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN:-192.168.8.123} + FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN:-192.168.8.123} + FORGEJO__server__ROOT_URL: ${FORGEJO_ROOT_URL:-http://192.168.8.123:3002/} + FORGEJO__server__HTTP_PORT: "3000" + # SQLite by default (no extra DB container) + FORGEJO__database__DB_TYPE: sqlite3 + FORGEJO__service__DISABLE_REGISTRATION: ${FORGEJO_DISABLE_REGISTRATION:-false} + FORGEJO__actions__ENABLED: "true" + volumes: + - ${CONFIG_DIR}/forgejo:/data + - /etc/localtime:/etc/localtime:ro + + # ------------------------------------------------------------------------- + # OpenHands — autonomous coding agent (opt-in) + # Start with: docker compose --profile coding up -d openhands + # UI: http://host:3001 → Settings → custom model openai/ + # Base URL: http://bonsai:8080/v1 API key: sk-local-bonsai + # Needs Docker socket to spawn sandboxed runtimes. + # ------------------------------------------------------------------------- + openhands: + profiles: ["coding"] + # Prefer GHCR — docker.all-hands.dev often fails DNS / is decommissioned + image: ghcr.io/all-hands-ai/openhands:0.54 + container_name: openhands + restart: unless-stopped + networks: [arr-net] + ports: + - "${OPENHANDS_PORT:-3001}:3000" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + TZ: ${TZ} + SANDBOX_RUNTIME_CONTAINER_IMAGE: ghcr.io/all-hands-ai/runtime:0.54-nikolaik + LOG_ALL_EVENTS: "true" + # Pre-wire local Bonsai (override in UI if model id differs) + LLM_BASE_URL: http://bonsai:8080/v1 + LLM_API_KEY: ${OPENAI_API_KEY:-sk-local-bonsai} + LLM_MODEL: ${OPENHANDS_MODEL:-openai/local} + WORKSPACE_MOUNT_PATH: ${AI_WORKSPACE_DIR:-./workspace} + SANDBOX_VOLUMES: ${AI_WORKSPACE_DIR:-./workspace}:/workspace:rw + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - ${CONFIG_DIR}/openhands:/.openhands-state + - ${AI_WORKSPACE_DIR:-./workspace}:/opt/workspace_base + depends_on: + bonsai: + condition: service_healthy diff --git a/models/bonsai/.gitkeep b/models/bonsai/.gitkeep new file mode 100644 index 0000000..fad60b7 --- /dev/null +++ b/models/bonsai/.gitkeep @@ -0,0 +1 @@ +# Place Ternary-Bonsai-27B GGUF files here (see scripts/download-bonsai-model.sh) diff --git a/quickstart.md b/quickstart.md new file mode 100644 index 0000000..18df7ab --- /dev/null +++ b/quickstart.md @@ -0,0 +1,138 @@ +# Quickstart + +Homelab host: **`192.168.8.123`** (SER5, Ubuntu 24.04) +Stack: **`/opt/stack`** · Media: **`/storage`** · User: **`tim`** + +--- + +## Ansible + +From a machine with this repo and SSH key `~/.ssh/id_ed25519`: + +```bash +cd ansible +ansible-galaxy collection install -r requirements.yml + +ansible-playbook site.yml -K # -K only if sudo still asks once; playbook installs NOPASSWD for tim +``` + +What it does: OS updates, tools (incl. Microsoft `edit`), Docker, AMD/Vulkan, UFW (LAN only), `/opt/stack` + `/storage`, `.env`, `arr-stack.service`, and **tim** as local admin (passwordless sudo, docker/video/render/…, owns stack + storage, permissive GPU nodes). + +**Before first full stack start** (on ser5): + +```bash +ssh tim@192.168.8.123 +# new login so docker/video/render groups apply +cd /opt/stack +# wireguard/wg0.conf must exist (no IPv6; Endpoint = IP not hostname) +./scripts/download-bonsai-model.sh # ~7.2 GB GGUF +docker compose build bonsai +docker compose up -d +# or: sudo systemctl enable --now arr-stack +docker compose ps +``` + +**Compose GPU notes** +- Keep `devices`/`group_add` only in `docker-compose.override.yml` (not also in the main file). +- Use **numeric GIDs** for `group_add`, not names (`video`/`render` fail inside the image). Or omit `group_add` if `/dev/dri` is mode `0666`. + + +Re-run playbook anytime: `ansible-playbook site.yml -K` +Tags: `--tags common,docker,amd,firewall,stack` + +--- + +## Remotes + +LAN URLs (UFW allows **`192.168.8.0/24`**). Replace host if needed. + +| Service | URL | +|---------|-----| +| Jellyfin | http://192.168.8.123:8096 | +| qBittorrent | http://192.168.8.123:8080 | +| Prowlarr | http://192.168.8.123:9696 | +| Sonarr | http://192.168.8.123:8989 | +| Radarr | http://192.168.8.123:7878 | +| Bazarr | http://192.168.8.123:6767 | +| Lidarr | http://192.168.8.123:8686 | +| Open WebUI | http://192.168.8.123:3000 | +| Bonsai API | http://192.168.8.123:8081/v1 | +| OpenHands | http://192.168.8.123:3001 | +| **Forgejo** | http://192.168.8.123:3002 | +| Forgejo Git SSH | `ssh://git@192.168.8.123:2222/user/repo.git` | + +OpenHands only runs with: `docker compose --profile coding up -d` (or `stack_compose_profiles: [coding]` in Ansible). +Images are from **GHCR** (`ghcr.io/all-hands-ai/...`); the old `docker.all-hands.dev` host often does not resolve. + +### Manual config (once) + +**qBittorrent** — temp password in `docker logs qbittorrent`; user `admin`. +Advanced → Network interface: `tun0`. + +Default save path + categories (WebUI): + +1. **Tools → Options → Downloads** + - Default Save Path: `/data/torrents` + - Keep “Keep incomplete torrents in” off (or a subfolder under `/data/torrents` if you prefer). +2. **Categories** (same Options → Downloads page, or right‑click empty area in the left “Categories” panel → **Add category**): + + | Category name | Save path | + |---------------|-----------| + | `movies` | `/data/torrents/movies` | + | `tv` | `/data/torrents/tv` | + | `music` | `/data/torrents/music` | + + For each: **Add category** → name exactly `movies` / `tv` / `music` (lowercase, matches Sonarr/Radarr/Lidarr) → set **Save path** to the path above → OK. + +3. Sonarr/Radarr/Lidarr download-client “Category” fields must match those names so torrents land in the right folder for hardlinks. + +**Sonarr / Radarr / Lidarr** — Download client qBittorrent: host **`gluetun`**, port **`8080`**, categories `tv` / `movies` / `music`. +Root folders: `/data/media/tv`, `/data/media/movies`, `/data/media/music` (hardlinks on). + +**Prowlarr** — Add indexers. Apps: Sonarr `http://sonarr:8989`, Radarr `http://radarr:7878`, Lidarr `http://lidarr:8686` + each API key (*Settings → General*). + +**Bazarr** — Sonarr/Radarr URLs + API keys; paths `/data/media/tv`, `/data/media/movies`. + +**Jellyfin** — Libraries: Movies `/data/media/movies`, Shows `/data/media/tv`, Music `/data/media/music`. + +**Open WebUI** — Create admin on first visit. Model connection is pre-set to Bonsai (`http://bonsai:8080/v1`). Enable web search in chat if desired. RAG: Workspace → Knowledge (uploads) or files under `/workspace`. + +**Bonsai** — No UI login. Smoke test: `curl -s http://192.168.8.123:8081/v1/models`. Needs GGUF in `models/bonsai/` and `docker compose build bonsai` once. + +**OpenHands** — On the server (`/opt/stack`): `docker compose --profile coding up -d openhands`. +Settings → Advanced: base URL `http://bonsai:8080/v1`, API key `sk-local-bonsai` (or `.env` value), model `openai/`. + +**Forgejo** — First visit http://192.168.8.123:3002 → install wizard (SQLite is pre-selected via env) → create **admin**. +If connection refused: container crash-looped (built-in SSH + image OpenSSH both on :22). Compose sets `START_SSH_SERVER=false`; recreate with `docker compose up -d forgejo --force-recreate`. +Then: **+ → New Repository** → clone into workspace: + +```bash +# HTTPS +git clone http://192.168.8.123:3002/YOU/repo.git /opt/stack/workspace/repo + +# SSH (port 2222 — host :22 stays for machine login) +git clone ssh://git@192.168.8.123:2222/YOU/repo.git /opt/stack/workspace/repo +# or: git remote add origin ssh://git@192.168.8.123:2222/YOU/repo.git +``` + +Point OpenHands / Open WebUI `AI_WORKSPACE_DIR` at those clones. After admin exists, set `FORGEJO_DISABLE_REGISTRATION=true` in `.env` and recreate: `docker compose up -d forgejo`. + +--- + +## Locals + +Bound to loopback on the host (not LAN-reachable as published): + +| Service | URL / path | +|---------|------------| +| SearXNG | http://127.0.0.1:8888 | + +Open WebUI talks to SearXNG on the Docker network (`http://searxng:8080`); you only need the loopback URL for direct browser checks on the server. + +### Manual config + +**SearXNG** — None for normal use. Settings: `/opt/stack/searxng/settings.yml`. Secret comes from `.env` (`SEARXNG_SECRET`). + +**WireGuard** — File only: `/opt/stack/wireguard/wg0.conf` (not a web UI). Must be ready before gluetun is healthy. + +**Host shell** — `ssh tim@192.168.8.123` · stack logs: `docker compose -f /opt/stack/docker-compose.yml logs -f` · unit: `systemctl status arr-stack`. diff --git a/scripts/download-bonsai-model.sh b/scripts/download-bonsai-model.sh new file mode 100755 index 0000000..ea86c8d --- /dev/null +++ b/scripts/download-bonsai-model.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Download Ternary-Bonsai-27B GGUF (and optional vision / drafter packs) +# into ./models/bonsai for the docker-compose bonsai service. +# +# Requires: ~8–10 GB free disk for the base model. +# Prefer: pip install -U "huggingface_hub[cli]" # provides `hf` +# Optional: HF_TOKEN / BONSAI_TOKEN if the repo ever requires auth. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="${MODEL_DIR:-$ROOT/models/bonsai}" +REPO="${BONSAI_HF_REPO:-prism-ml/Ternary-Bonsai-27B-gguf}" +# Group-128 Q2_0 — needs PrismML llama.cpp (what our Docker image ships) +MAIN_FILE="${BONSAI_MAIN_FILE:-Ternary-Bonsai-27B-Q2_0.gguf}" +DOWNLOAD_VISION="${DOWNLOAD_VISION:-1}" +DOWNLOAD_DRAFTER="${DOWNLOAD_DRAFTER:-0}" + +mkdir -p "$OUT" +cd "$OUT" + +export HF_TOKEN="${HF_TOKEN:-${BONSAI_TOKEN:-}}" + +have_hf() { + command -v hf >/dev/null 2>&1 || command -v huggingface-cli >/dev/null 2>&1 +} + +hf_get() { + local file="$1" + if command -v hf >/dev/null 2>&1; then + hf download "$REPO" "$file" --local-dir . + else + huggingface-cli download "$REPO" "$file" --local-dir . + fi +} + +curl_get() { + local file="$1" + local url="https://huggingface.co/${REPO}/resolve/main/${file}" + local auth=() + if [ -n "${HF_TOKEN:-}" ]; then + auth=(-H "Authorization: Bearer ${HF_TOKEN}") + fi + curl -fL --retry 5 --continue-at - "${auth[@]}" "$url" -o "${file}.partial" + mv "${file}.partial" "$file" +} + +download() { + local file="$1" + if [ -f "$file" ] && [ -s "$file" ]; then + echo "Already present: $file ($(du -h "$file" | cut -f1))" + return 0 + fi + echo "Downloading $file ..." + if have_hf; then + hf_get "$file" + else + echo " (tip: install huggingface_hub for more reliable downloads: pip install -U 'huggingface_hub[cli]')" + curl_get "$file" + fi + echo " OK: $file ($(du -h "$file" | cut -f1))" +} + +echo "Target directory: $OUT" +echo "Repo: $REPO" +echo "" + +download "$MAIN_FILE" + +if [ "$DOWNLOAD_VISION" = "1" ]; then + echo "" + echo "Optional vision projector (mmproj) — attempting known filenames..." + _got=0 + for f in \ + Ternary-Bonsai-27B-mmproj-HQQ4.gguf \ + Ternary-Bonsai-27B-mmproj-f16.gguf \ + mmproj-Ternary-Bonsai-27B.gguf \ + ; do + if [ -f "$f" ]; then + echo "Already present: $f" + _got=1 + break + fi + if have_hf; then + if hf_get "$f" 2>/dev/null; then + _got=1 + break + fi + fi + done + if [ "$_got" != "1" ]; then + echo "Vision mmproj not found automatically (optional for text-only)." + echo " List files: https://huggingface.co/${REPO}/tree/main" + echo " Place any *mmproj*.gguf into $OUT and restart bonsai." + fi +fi + +if [ "$DOWNLOAD_DRAFTER" = "1" ]; then + echo "" + echo "Optional DSpark drafter (speculative decoding; mainly helps CUDA)..." + for f in Ternary-Bonsai-27B-dspark-Q4_1.gguf; do + download "$f" || echo " skipped: $f" + done +fi + +echo "" +echo "Done. GGUF files in $OUT:" +ls -lh "$OUT"/*.gguf 2>/dev/null || ls -lh "$OUT" +echo "" +echo "Next:" +echo " docker compose up -d --build bonsai open-webui searxng" +echo " Open WebUI → http://localhost:3000" +echo " Raw API → http://localhost:8081/v1/models" diff --git a/searxng/settings.yml b/searxng/settings.yml new file mode 100644 index 0000000..887db94 --- /dev/null +++ b/searxng/settings.yml @@ -0,0 +1,29 @@ +# Minimal SearXNG config for Open WebUI web-search RAG. +# See https://docs.searxng.org/admin/settings/settings.html + +use_default_settings: true + +general: + instance_name: "arr-stack searxng" + enable_metrics: false + +search: + safe_search: 0 + autocomplete: "" + default_lang: "en" + formats: + - html + - json + +server: + # Shared secret for limiter / sessions; override via SEARXNG_SECRET in compose if you like + secret_key: "change-me-in-production-use-env" + limiter: false + image_proxy: true + +ui: + static_use_hash: true + +# Disable outbound image proxy noise; keep JSON API for Open WebUI +outgoing: + request_timeout: 10.0 diff --git a/wireguard/wg0.conf.example b/wireguard/wg0.conf.example new file mode 100644 index 0000000..dbaab99 --- /dev/null +++ b/wireguard/wg0.conf.example @@ -0,0 +1,27 @@ +# Rename YOUR provider's config file to `wg0.conf` and put it in this folder. +# It looks roughly like this: +# +# [Interface] +# PrivateKey = yAnz..................................= +# Address = 10.2.0.2/32 +# DNS = 10.2.0.1 +# +# [Peer] +# PublicKey = xTIBA5rboUvnH4htodjb6e697QjLERt1NAB4mZqp8Dg= +# PresharedKey = ......................................= # only if present +# AllowedIPs = 0.0.0.0/0 +# Endpoint = 185.159.157.12:51820 +# +# Two required edits before gluetun accepts it: +# +# 1. Remove any IPv6 addresses from the Address and DNS lines, e.g. +# Address = 10.182.160.190/32, fd7d:76ee:...::/128 +# becomes +# Address = 10.182.160.190/32 +# +# 2. The Endpoint must be an IP address, not a hostname. If yours says +# something like `Endpoint = se3.vpn.example.org:51820`, resolve it first: +# dig +short se3.vpn.example.org +# and paste the IP back into the file. +# +# Keep AllowedIPs = 0.0.0.0/0 as-is; gluetun handles the routing itself. diff --git a/workspace/README.md b/workspace/README.md new file mode 100644 index 0000000..ddb2e7f --- /dev/null +++ b/workspace/README.md @@ -0,0 +1,2 @@ +# AI workspace — mount point for Open WebUI (/workspace) and OpenHands +# Drop project folders, notes, and docs here for local file Q&A and coding tasks.