diff --git a/.gitignore b/.gitignore index 67cb259..d9a028f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ linux-server/syncthing/config/ linux-server/uptime-kuma/data/ linux-server/forgejo/data/ + +# Tailscale sidecar node state (HTTPS.md) — holds node keys, never commit +linux-server/*/ts-state/ diff --git a/CLAUDE.md b/CLAUDE.md index dca419b..16ec245 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,14 +29,15 @@ tracked in TODO.md.) invocations). - `dotfiles/` — configs shared across platforms (`tmux.conf`, `ghostty.config`, `zshrc.example`, `zsh_plugins.txt`); the engine deploys - them from here. The zshrc base is cross-platform — macOS-specific bits are - guarded on `/opt/homebrew` or `$OSTYPE` — and has an override system - (`deploy_zshrc`): a platform folder shipping its own `zshrc.example` wins - over the dotfiles base. Only `linux-server/` does (headless: no - Ghostty/fastfetch/notification hooks). -- `macOS/`, `linux-desktop/`, `linux-server/` — platform-specific configs - (server zshrc override, p10k), docs, and thin shim scripts that exec the - root entrypoints. + them from here. One zshrc base serves every platform including the headless + server — macOS-specific bits guard on `/opt/homebrew` or `$OSTYPE`, and the + desktop-only bits self-disable headless (notify hook no-ops without + `$DISPLAY`/`$WAYLAND_DISPLAY`, fastfetch keys off Ghostty or `$SSH_CONNECTION`, + version managers/zoxide are command-guarded). The override system + (`deploy_zshrc`) still lets a platform folder ship its own `zshrc.example` to + win over the base, but no platform currently does. +- `macOS/`, `linux-desktop/`, `linux-server/` — platform-specific configs, + docs, and thin shim scripts that exec the root entrypoints. - `scripts/dryrun-smoke.sh` — runs `setup.sh --dry-run` for every platform and asserts it exits clean with install actions; also run in CI. diff --git a/HANDOFF.md b/HANDOFF.md new file mode 100644 index 0000000..038e4c0 --- /dev/null +++ b/HANDOFF.md @@ -0,0 +1,44 @@ +# Session Handoff + +Scratchpad for in-progress work that spans more than one session — live +infrastructure state, decisions made outside of code, and what's next. +Branch-specific; delete or trim entries once they're fully landed and the +branch merges. + +## 2026-06-17 — Forgejo HTTPS-over-Tailscale, live on ollie-server + +Branch: `refactor/server-streamline-divergence` (PR [#47](https://github.com/ulises-c/Computer-Setup/pull/47)) + +`linux-server/HTTPS.md` (added in `8ff8a2c`, on a different machine) documents +per-service HTTPS via a Tailscale sidecar per service. This session executed +that plan for Forgejo on the actual server (`ollie-server`) and documented two +gotchas hit along the way. + +**Code changes (committed, this session):** +- `linux-server/HTTPS.md` — resolved the auth-method decision (OAuth client + + `tag:container`, not a reusable key); documented the ACL-tag gotcha and a + network-namespace gotcha that will recur for every other service conversion +- `TODO.md` — added a tracked checklist for the 11 remaining services + 2 open + decisions (NPM retire/keep, Homepage sidecar vs. main-node) +- `SSH_and_GPG/create_ssh_key.sh` + `README.md` — the self-hosted-server SSH + port was hardcoded to `2222` (Forgejo's old host-published mapping); now + prompts with default `22`, matching the sidecar setup. Verified live with + `ssh-keyscan -p 22 forgejo.tail01d63b.ts.net`. + +**Live state on `ollie-server` (not in git):** +- `linux-server/forgejo/.env`: `TS_AUTHKEY` set to the (now read+write-scoped) + `tailscale-proxy` OAuth client secret; `FORGEJO_DOMAIN=forgejo.tail01d63b.ts.net` +- `forgejo-ts` + `forgejo` containers running under the new sidecar + `docker-compose.yml`; old host-published ports (`3300`, `2222`) are gone +- Verified: `https://forgejo.tail01d63b.ts.net/` → `200`; SSH banner on `:22` +- Tailscale admin console changes (made by the user, not visible in this repo): + existing `tailscale-proxy` OAuth client elevated from read-only to + read+write scope; `tag:container` added to the tailnet ACL's `tagOwners` + (owner: `autogroup:admin`) + +**Not done yet:** +- Existing git remotes pointing at the old `http://...:3300` / `ssh://...:2222` + Forgejo address still need updating to `git@forgejo.tail01d63b.ts.net:user/repo.git` + (on every machine that had a clone, not just this one) +- Forgejo Site Administration → confirm the app URL picked up the new `ROOT_URL` +- Continue the rollout: Portainer is next per the `TODO.md` table diff --git a/SSH_and_GPG/README.md b/SSH_and_GPG/README.md index 526eeda..24b671c 100644 --- a/SSH_and_GPG/README.md +++ b/SSH_and_GPG/README.md @@ -34,7 +34,7 @@ Creates an Ed25519 SSH key for authenticating to a Git host (GitHub, GitLab, Bit - Prompts for an email (used as the key comment), Git host, key filename, and optional passphrase - Generates the key in `~/.ssh/` (skips generation if the key already exists, with an overwrite prompt) - Adds the key to `ssh-agent` and updates `~/.ssh/config` with a `Host` block (idempotent) -- For self-hosted servers: writes an alias with `HostName`, `Port 2222`, and `User git` so you can clone as `git clone :/.git` +- For self-hosted servers: prompts for an SSH port (default `22`) and writes an alias with `HostName`, `Port`, and `User git` so you can clone as `git clone :/.git` - Prints the public key for pasting into the Git host's settings - Tests the SSH connection after you confirm the key has been added @@ -44,7 +44,7 @@ bash create_ssh_key.sh # or pre-fill inputs via env vars: EMAIL="jane@example.com" GIT_HOST="github.com" KEY_NAME="github" bash create_ssh_key.sh # Self-hosted Git server: -EMAIL="jane@example.com" IS_SELF_HOSTED=true GIT_HOSTNAME="hostname.ts.net" GIT_HOST="gitserver" bash create_ssh_key.sh +EMAIL="jane@example.com" IS_SELF_HOSTED=true GIT_HOSTNAME="hostname.ts.net" GIT_HOST="gitserver" GIT_SSH_PORT=22 bash create_ssh_key.sh ``` --- diff --git a/SSH_and_GPG/create_ssh_key.sh b/SSH_and_GPG/create_ssh_key.sh index 435cb82..d8d0703 100755 --- a/SSH_and_GPG/create_ssh_key.sh +++ b/SSH_and_GPG/create_ssh_key.sh @@ -30,6 +30,7 @@ prompt EMAIL "Email (comment in key)" # ---- Git host selection ---- IS_SELF_HOSTED="${IS_SELF_HOSTED:-}" GIT_HOSTNAME="${GIT_HOSTNAME:-}" +GIT_SSH_PORT="${GIT_SSH_PORT:-}" if [[ -z "$GIT_HOST" ]]; then echo "" @@ -59,6 +60,10 @@ if [[ -z "$GIT_HOST" ]]; then read -r -p "SSH alias for this server [gitserver]: " GIT_HOST GIT_HOST="${GIT_HOST:-gitserver}" fi + if [[ -z "$GIT_SSH_PORT" ]]; then + read -r -p "SSH port [22]: " GIT_SSH_PORT + GIT_SSH_PORT="${GIT_SSH_PORT:-22}" + fi ;; 0) read -r -p "Git host: " GIT_HOST @@ -156,7 +161,8 @@ if [[ -n "$IS_SELF_HOSTED" ]]; then echo "" echo "Host $GIT_HOST" echo " HostName $GIT_HOSTNAME" - echo " Port 2222" + # Port 22 is the SSH default — only emit the line for a non-standard port. + [[ -n "$GIT_SSH_PORT" && "$GIT_SSH_PORT" != "22" ]] && echo " Port $GIT_SSH_PORT" echo " User git" echo " AddKeysToAgent yes" echo " IdentityFile $KEY_PATH" @@ -186,7 +192,7 @@ read -r -p "Press [Enter] after adding the public key to your $GIT_HOST account. # -T avoids trying to open a shell; -v optional for debugging. echo "" if [[ -n "$IS_SELF_HOSTED" ]]; then - echo "Testing SSH connection to self-hosted server ($GIT_HOSTNAME:2222) ..." + echo "Testing SSH connection to self-hosted server ($GIT_HOSTNAME:${GIT_SSH_PORT:-22}) ..." ssh -T "$GIT_HOST" || true else echo "Testing SSH connection to git@$GIT_HOST ..." diff --git a/TODO.md b/TODO.md index 210d0cb..02edfa0 100644 --- a/TODO.md +++ b/TODO.md @@ -146,6 +146,128 @@ Test and validate the linux-desktop setup on the personal CachyOS desktop - [ ] Test `--personal` flag end-to-end - [ ] Create PR for CachyOS support +## Per-service HTTPS rollout (linux-server) + +Convert each self-hosted service from `http://:` to its own +`https://..ts.net/` via a Tailscale sidecar. Pattern, prereqs, +and full rollout table in [linux-server/HTTPS.md](linux-server/HTTPS.md). + +- [x] forgejo — reference impl (sidecar + SSH :22), done in 8ff8a2c +- [x] portainer — sidecar live at https://portainer..ts.net/, homepage + link updated +- [x] uptime-kuma — sidecar live at https://uptime-kuma..ts.net/, + homepage link & widget url updated +- [x] speedtest-tracker — sidecar live at https://speedtest..ts.net/, + homepage link & widget url updated +- [x] ntfy — sidecar live at https://ntfy..ts.net/, homepage link + updated +- [x] filebrowser — sidecar live at https://filebrowser..ts.net/, + homepage link updated +- [x] syncthing — sidecar live at https://syncthing..ts.net/, homepage + link & widget url updated; sync `:22000`/`:21027` confirmed published + on the sidecar +- [x] glances — sidecar live at https://glances..ts.net/ (proxies via + `host.docker.internal`, glances untouched), homepage link updated +- [x] adguard — sidecar live at https://adguard..ts.net/, homepage + link & widget url updated; DNS `:53` confirmed still resolving on the + sidecar after recreate +- [x] atvloadly — was never tracked in this repo (lived in an untracked + `/home/ollie/docker-compose.yml`); migrated into + `linux-server/atvloadly/`, sidecar live at + https://atvloadly..ts.net/, homepage link updated. Apple TV + discovery (avahi/dbus socket mounts) confirmed unaffected by the netns + share — no `hostname:` on the app container, that conflicts with + `network_mode: service:...` +- [x] nginx-proxy-manager — KEPT as the host edge; admin UI sidecar live at + https://npm..ts.net/ (proxies host :81 via host.docker.internal, + no app-side change needed), homepage link updated. NPM keeps binding + host :80/:443 unchanged +- [x] homepage — sidecar live at https://homepage..ts.net/, confirmed + `HOMEPAGE_ALLOWED_HOSTS` includes the new domain and container is + healthy post-recreate +- [x] cockpit — sidecar live at https://cockpit..ts.net/, homepage + link updated. `cockpit.conf.example`'s Origins allow-list turned out + unnecessary — verified via raw WebSocket upgrade (101 with matching + Origin, 403 with a foreign one) +- [x] tailscale-web — not in the original rollout; added because the homepage + Tailscale tile linked plain HTTP. New `linux-server/tailscale-web/` + sidecar-only stack live at https://tailscale-web..ts.net/, + homepage link updated. Host's `tailscale-web.service` unit now runs + `tailscale web --listen 0.0.0.0:8088 --origin + https://tailscale-web..ts.net`; sidecar proxies to + `host.docker.internal:8088`. Verified 200 with no redirect, real page + content, confirmed in browser. Don't use port `:5252` — see HTTPS.md + Gotchas +- [x] watchtower — sidecar live at https://watchtower..ts.net/v1/metrics + (no UI, metrics-only API; confirmed 401 unauthenticated / 200 with the + Bearer token, and the 3am schedule is unaffected). Still need: the + Uptime Kuma HTTP monitor pointed at it (recipe in HTTPS.md). +- [x] Decide auth method: OAuth client + `tag:container` (reusing the elevated + tailscale-proxy client) — resolved during the Forgejo rollout, see HTTPS.md +- [x] Decide whether to retire NPM or keep it — KEEP, as the non-tailnet HTTPS + edge (trusted certs for LAN/public clients like a Plex TV that can't join + the tailnet). See HTTPS.md → "NPM — trusted HTTPS for non-tailnet clients" +- [ ] Set up the NPM trusted-HTTPS edge (domain `ulises-c.me`, already owned): + NPM wildcard Let's Encrypt cert for `*.home.ulises-c.me` via DNS-01, AdGuard + rewrite `*.home.ulises-c.me` → LAN IP, then per-service proxy hosts. Not + started — documented in HTTPS.md to pick up later. +- [ ] Update Homepage hrefs to HTTPS as each service converts; a service's widget + `url:` must move to the HTTPS domain too (localhost stops resolving once the + host port is dropped) + +## Server observability & hardening (post-HTTPS rollout) + +Improvements identified once every service was wired up with a Tailscale sidecar. + +### Watchtower observability — "what updated, and when" + +Watchtower has no native history UI, and its `/v1/metrics` endpoint (now monitored +by Uptime Kuma) is only cumulative **counters** (`watchtower_containers_updated` / +`_failed` / `_scanned`, `watchtower_scans_total`) — no container names or image +versions. So the "what was actually updated" has to come from notifications or +logs, not metrics. Build it up in layers: + +- [ ] **Tier 1 — ntfy notifications (quick win, reuses the existing ntfy).** On the + watchtower service set `WATCHTOWER_NOTIFICATION_URL` to a shoutrrr ntfy URL + pointing at our ntfy instance (dedicated topic, e.g. `watchtower`) and + `WATCHTOWER_NOTIFICATION_REPORT=true` for a per-run report (which containers + updated/failed/skipped, old→new image). Gives a timestamped, persistent + history in ntfy + a phone push — directly answers "what & when." Lowest effort. +- [ ] **Tier 2 — Prometheus + Grafana on the existing `/v1/metrics`.** Scrape the + counters, dashboard the update/scan trend, alert on + `watchtower_containers_failed > 0`. Counts only (no names) — pairs with Tier 1 + for the "what." Heavier (new stack); also becomes the home for other metrics + (glances, node-exporter, cAdvisor). +- [ ] **Tier 3 (optional) — dedicated update tracker with a UI.** Evaluate What's Up + Docker (WUD) or Diun, which show per-container available/applied updates in a + UI. Could complement or take over watchtower's notification role. + +### Broader improvements (from the post-rollout review) + +- [ ] **Pin the Tailscale sidecar image.** All ~13 sidecars run + `tailscale/tailscale:latest` and watchtower auto-updates them — a bad release + could drop every HTTPS front door at once. Pin a stable tag (bump + deliberately) or exclude the sidecars from watchtower. Cheap, high-value. +- [ ] **Backups.** Service data volumes (Forgejo repos, AdGuard/uptime-kuma/etc.), + all `.env` files, and `ts-state/` node keys live only on the server and are + gitignored — no backup today. Document what to back up + a restic/borg or + Syncthing job. +- [ ] **DRY the sidecar boilerplate.** ~13 near-identical `-ts` blocks + + `ts-serve.json` (differ only by hostname/port). Use Compose `extends` from a + shared base so a global change (the image pin above, `TS_EXTRA_ARGS`) is one + edit, not 13. Medium effort — touches all stacks, needs live re-verify. +- [ ] **One shared `TS_AUTHKEY`.** The same OAuth secret is copied into ~13 `.env` + files; rotation/rebuild means editing all of them. Share one env file. +- [ ] **Validation script for the server stacks** (CI, like `dryrun-smoke.sh`): + assert every `linux-server/*/` has matching compose + `ts-serve.json` + + `.env.example`, valid YAML/JSON, serve port == container port, `ts-state/` + gitignored. Catches the drift that bit us mid-rollout (wrong port, stale config). +- [ ] **Tighten the Tailscale ACL** — least-privilege for the `tag:container` nodes + (currently default allow-all). +- [ ] **Forward-auth for the NPM public edge** (Authelia/Authentik) — bundle with the + `*.home.ulises-c.me` NPM setup, since services like filebrowser/glances have + weak/no auth once exposed off-tailnet. + ## linux-server — Raspberry Pi 4 Set up the Raspberry Pi 4 headless server config under `linux-server/`. diff --git a/dotfiles/zshrc.example b/dotfiles/zshrc.example index c196511..69f2c9b 100644 --- a/dotfiles/zshrc.example +++ b/dotfiles/zshrc.example @@ -1,8 +1,11 @@ # ═══════════════════════════════════════════════════════════════ # ~/.zshrc — shared zsh config (macOS / Ubuntu / Arch) # ═══════════════════════════════════════════════════════════════ -# Base zshrc deployed by setup.sh; a platform folder can ship its own -# zshrc.example to override it (linux-server does — headless, no Ghostty). +# Single zshrc deployed by setup.sh on every platform (macOS, Linux desktop, +# and the headless server). Desktop-only bits self-disable headless: the +# notify hook no-ops without a $DISPLAY/$WAYLAND_DISPLAY, fastfetch keys off +# Ghostty or an SSH session, and version managers/zoxide are command-guarded. +# A platform folder may still ship its own zshrc.example to override this base. # macOS-specific bits are guarded on /opt/homebrew (Apple Silicon) or $OSTYPE. # Requires: zsh, antidote, zoxide, fastfetch, fzf, bat, fd, eza # Plugins managed by antidote — see ~/.zsh_plugins.txt @@ -299,8 +302,11 @@ precmd_functions=(_notify_precmd $precmd_functions) # ═══════════════════════════════════════════════════════════════ -# SYSINFO DISPLAY (Ghostty startup) +# SYSINFO DISPLAY (Ghostty startup, or any SSH login) # ═══════════════════════════════════════════════════════════════ -[[ -o interactive && "$TERM_PROGRAM" == "ghostty" ]] && fastfetch +# Ghostty on the desktop/laptop; SSH covers the headless server, where +# there is no TERM_PROGRAM but every login is a remote session. +[[ -o interactive && ( "$TERM_PROGRAM" == "ghostty" || -n "$SSH_CONNECTION" ) ]] && \ + command -v fastfetch &>/dev/null && fastfetch [[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh diff --git a/lib/core.sh b/lib/core.sh index a753254..c89cb73 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -2,14 +2,20 @@ # Shared engine for the unified root setup.sh (UNIFICATION.md, issue #36). # Sourced by setup.sh after SETUP_ROOT is set. Platform modules in # platforms/.sh provide platform_main() plus the hooks used by -# desktop_main(): platform_bootstrap, platform_install_tier, +# linux_main(): platform_bootstrap, platform_install_tier, # platform_pyenv_build_deps, platform_tailscale_step, platform_docker_optional. +# The apt-family hooks (apt_install_tier, platform_tailscale_step, +# platform_docker_optional) default to apt/curl implementations here, shared by +# the Ubuntu desktop and the server profile; arch.sh overrides them for pacman. +# linux_main() runs the shared spine; SERVER_PROFILE gates the desktop-only +# steps off and routes to the server-extras phase. PACKAGES_JSON="$SETUP_ROOT/packages.json" PYTHON_VERSION="3.12.13" PLATFORM="" +SERVER_PROFILE=false INCLUDE_OPTIONAL=false INCLUDE_WORK=false INCLUDE_PERSONAL=false @@ -71,6 +77,7 @@ core_detect_platform() { printf ' Re-run with --platform macos|ubuntu|arch|server to override.\n' >&2 exit 1 ;; esac + if [[ "$PLATFORM" == "server" ]]; then SERVER_PROFILE=true; fi } # ── Run helpers ─────────────────────────────────────────────────────────────── @@ -400,6 +407,69 @@ snap_install_tier() { fi } +# apt install tier — shared by the Ubuntu desktop and the server profile. +# arch.sh overrides the install path with yay_install_tier. +apt_install_tier() { + local priority="$1" names + names=$(pkg_names apt "$priority") + [[ -z "$names" ]] && return 0 + # shellcheck disable=SC2086 + run sudo apt install -y $names +} + +# apt + snap tier composition — shared by the Ubuntu desktop and the server +# profile. Selection is data-driven (package_manager. in packages.json), +# so each manager runs at every tier uniformly; a package only installs where it +# declares that platform. arch.sh overrides this with the yay batch. +platform_install_tier() { + case "$1" in + high) + printf '==> Installing high-priority apt packages...\n' + apt_install_tier high + ;; + medium) + printf '==> Installing medium-priority apt packages...\n' + apt_install_tier medium + printf '\n==> Installing snap packages...\n' + snap_install_tier medium + setup_bat_fd_symlinks + ;; + low) + apt_install_tier low + snap_install_tier low + ;; + esac +} + +# Tailscale via the official curl installer — apt-family default (ubuntu + server). +# arch.sh overrides this (tailscale ships in the yay batch; just enable the daemon). +platform_tailscale_step() { + printf '\n' + if [[ "$DRY_RUN" == true ]]; then + command -v tailscale &>/dev/null \ + && printf '==> Tailscale already installed\n' \ + || printf ' [dry-run] eval: curl -fsSL https://tailscale.com/install.sh | sudo sh\n' + elif ! command -v tailscale &>/dev/null; then + printf '==> Installing Tailscale...\n' + eval "$(custom_cmd tailscale)" + else + printf '==> Tailscale already installed (%s)\n' "$(tailscale version | head -1)" + fi +} + +# Docker via get.docker.com — apt-family default (ubuntu + server). +# arch.sh overrides this (docker ships in the yay batch; just enable the daemon). +platform_docker_optional() { + if ! command -v docker &>/dev/null; then + printf '==> Installing Docker...\n' + run_eval "curl -fsSL https://get.docker.com | sudo sh" + run sudo usermod -aG docker "$USER" + printf ' Log out and back in for the docker group to take effect.\n' + else + printf '==> Docker already installed (%s)\n' "$(docker --version | head -1)" + fi +} + # Echo the other repo setup scripts (those present on disk) for discoverability. # One list for all platforms — root verify.sh auto-detects, so nothing diverges. RELATED_SCRIPTS=( @@ -589,25 +659,44 @@ desktop_footer() { printf '================================================================\n' } -desktop_main() { +# Shared Linux install spine for the apt/yay desktops and the server profile. +# nvm/node and the rust toolchain run on both (claude-hud's statusline needs +# node; the server installs rustup for cargo tools like railguard); pyenv and +# the remaining desktop-only steps (pipx/pnpm, ghostty) are gated off under +# SERVER_PROFILE, which instead routes to the server-extras phase. The zsh +# stack — zshrc, zsh_plugins.txt, and the p10k config — deploys from dotfiles on +# both (the desktop-only zshrc bits self-disable headless). +linux_main() { printf '==> Detected distro family: %s\n' "$PLATFORM" - CONFIG_SRC_DIR="$SETUP_ROOT/linux-desktop" + if [[ "$SERVER_PROFILE" == true ]]; then + CONFIG_SRC_DIR="$SETUP_ROOT/linux-server" + else + CONFIG_SRC_DIR="$SETUP_ROOT/linux-desktop" + fi platform_bootstrap printf '\n' platform_install_tier high - linux_pyenv_flow linux_nvm_flow + if [[ "$SERVER_PROFILE" != true ]]; then + linux_pyenv_flow + fi + printf '\n' platform_install_tier medium platform_tailscale_step + [[ "$SERVER_PROFILE" == true ]] && \ + printf " Run 'sudo tailscale up' to authenticate and connect to your Tailnet.\n" claude_code_step rust_toolchain_step - desktop_pipx_section - desktop_pnpm_section + + if [[ "$SERVER_PROFILE" != true ]]; then + desktop_pipx_section + desktop_pnpm_section + fi set_default_shell printf '\n' @@ -618,7 +707,26 @@ desktop_main() { deploy_config "$SETUP_ROOT/dotfiles/zsh_plugins.txt" "$HOME/.zsh_plugins.txt" "" no printf '\n' deploy_config "$SETUP_ROOT/dotfiles/p10k.zsh.example" "$HOME/.p10k.zsh" "p10k.zsh.example" yes - ghostty_deploy_linux + if [[ "$SERVER_PROFILE" == true ]]; then + # Plugins/prompt are the shared dotfiles set; pre-clone now while the + # network is provably up rather than lazily on first interactive login. + server_preclone_antidote + else + ghostty_deploy_linux + fi + + if [[ "$SERVER_PROFILE" == true ]]; then + printf '\n' + platform_docker_optional + if [[ "$INCLUDE_OPTIONAL" == true ]]; then + printf '\n==> Installing optional (low) packages...\n' + platform_install_tier low + fi + server_extras + custom_reminders_section + server_footer + return 0 + fi if [[ "$INCLUDE_OPTIONAL" == true ]]; then printf '\n==> Installing optional (low) packages...\n' diff --git a/linux-server/HTTPS.md b/linux-server/HTTPS.md new file mode 100644 index 0000000..209f563 --- /dev/null +++ b/linux-server/HTTPS.md @@ -0,0 +1,449 @@ +# Per-service HTTPS over Tailscale + +Goal: reach every self-hosted service at its own HTTPS URL +(`https://..ts.net/`) instead of `http://:`. +The immediate driver is Forgejo — git breaks when Forgejo's `ROOT_URL` is HTTP on +a non-standard port. + +## Why subdomains require one Tailscale node per service + +A Tailscale node has exactly **one** MagicDNS name (`ollie-server..ts.net`). +You cannot mint arbitrary subdomains of it — `forgejo..ts.net` only +exists, resolves, and can get a TLS cert if there is a **node named `forgejo`**. + +So `..ts.net` (the layout we want) is achievable exactly one +way: run a small **Tailscale sidecar container per service**, each joining the +tailnet under its own hostname and terminating HTTPS with `tailscale serve`. +This also avoids the subpath breakage (`/forgejo`) that apps like Forgejo and +Portainer suffer under path routing. + +NPM cannot produce these names — it can only route hostnames that already +resolve to it and for which it holds a cert. Keep NPM for LAN/`.local` access if +you want; it is orthogonal to the tailnet HTTPS layer below and does **not** +conflict (each sidecar binds `:443` inside its own network namespace / tailnet +IP, not the host's `:443`). + +## Architecture (per service) + +``` +tailnet ──HTTPS:443──▶ [-ts sidecar] tailscale serve, owns ..ts.net + cert + │ (shared network namespace via network_mode: service:) + ▼ + [ app] listens on 127.0.0.1: +``` + +The app container uses `network_mode: service:-ts`, so it shares the +sidecar's network namespace: `tailscale serve` proxies inbound `:443` to +`127.0.0.1:`, and the sidecar sets `X-Forwarded-Proto: https` so the +app generates correct HTTPS URLs. + +## Prerequisites (one-time, in the Tailscale admin console) + +1. **Enable HTTPS** for the tailnet (DNS → "Enable HTTPS"), so `tailscale serve` + can provision Let's Encrypt certs for `*.ts.net`. +2. **MagicDNS** enabled (it is, since you resolve `ollie-server..ts.net`). +3. **An auth method for the sidecars — resolved: OAuth client + tag.** Reuses + the existing Tailscale OAuth client (`linux-server/tailscale-proxy/.env`, + originally created read-only for the device-status proxy): + - In the admin console, edit that OAuth client's **scope** to include + **Auth Keys (read+write)**, in addition to its existing Core - Read scope. + Same `client_secret` continues to work for both the proxy and sidecars — + no new client or secret needed. + - Add `tag:container` to the ACL's `tagOwners` (e.g. + `"tag:container": ["autogroup:admin"]`). This alone was sufficient — the + OAuth client did **not** need a separate per-client tag assignment in its + own settings. + - Pass the existing `client_secret` as `TS_AUTHKEY` with + `TS_EXTRA_ARGS=--advertise-tags=tag:container` (already set in + `docker-compose.yml`). Sidecars never expire. + - **Gotcha hit during the Forgejo rollout**: granting the Auth Keys scope + without also adding `tag:container` to `tagOwners` fails at sidecar + startup with `Status: 400, Message: "requested tags [tag:container] are + invalid or not permitted"`. Add the tag to `tagOwners` and restart the + sidecar. + - (Reusable auth key remains a simpler fallback if you'd rather not touch + the OAuth client: generate one in admin → Settings → Keys and put it in + the service's `.env` as `TS_AUTHKEY`, but watch its expiry.) +4. **ACL** must allow tailnet → the sidecar nodes on `:443` (HTTPS) and, for git + over SSH, `:22`. The default allow-all ACL already does; if you've tightened + it, add a grant for `tag:container`. + +## The reusable pattern + +Add to a service's `docker-compose.yml` (replace `` and ``): + +```yaml +services: + -ts: + image: tailscale/tailscale:latest + container_name: -ts + hostname: # → ..ts.net + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + - TS_EXTRA_ARGS=--advertise-tags=tag:container # omit if using a plain reusable key + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + + : + image: + container_name: + network_mode: service:-ts # share the sidecar's network namespace + depends_on: [-ts] + # NO `ports:` block — access is via the tailnet, not host ports + restart: unless-stopped +``` + +`ts-serve.json` (kernel-mode sidecar; `${TS_CERT_DOMAIN}` is filled by Tailscale +with the node's own MagicDNS name, so this file is identical for every service +except the ``): + +```json +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:" } } + } + } +} +``` + +Kernel networking (`/dev/net/tun` + `NET_ADMIN`) is used so the node exposes raw +ports (e.g. SSH `:22`) to the tailnet directly, not only the `:443` serve proxy. + +### Variant: host-networked apps (glances) + +A service that must keep `network_mode: host` — e.g. glances, which reads the +host's real network interfaces and processes — can't be moved into the sidecar's +namespace without degrading exactly what it measures. Leave that app untouched +and run the sidecar in its **own** netns, proxying back to the host's port via +the docker bridge gateway: + +```yaml + -ts: + # ... same sidecar as above, plus: + extra_hosts: + - "host.docker.internal:host-gateway" + # the app keeps network_mode: host, its own ports, everything — unchanged +``` + +with `ts-serve.json` proxying to `http://host.docker.internal:` instead of +`127.0.0.1:`. Because the app keeps its host port, its homepage **widget** +`url:` can stay `http://localhost:` (only the `href` moves to HTTPS) — the +opposite of the netns-shared services. Trade-off: the host port stays open on the +LAN/tailnet (plaintext) since the app still binds it directly. + +## Applying the Forgejo change (the reference, already implemented) + +Forgejo is intentionally **not** in `setup.sh`'s auto-start loop — it needs the +auth key and domain set first. Apply it by hand: + +```bash +cd linux-server/forgejo +cp .env.example .env +# edit .env: set TS_AUTHKEY and FORGEJO_DOMAIN=forgejo..ts.net +docker compose up -d +docker compose logs -f forgejo-ts # watch the node join + cert provision +``` + +Then in Forgejo → Site Administration, confirm the app URL, and update any +existing local clones' remotes (see Migration below). + +If you have to restart/recreate the sidecar (`forgejo-ts`) for any reason +*after* the app container is already up — e.g. to pick up a new ACL tag grant — +restart the app container too. See the netns gotcha below. + +## Git over SSH (Forgejo) + +With kernel-mode networking the sidecar's tailnet IP exposes Forgejo's container +`:22` directly, so SSH clones use the standard port: + +``` +git clone git@forgejo..ts.net:user/repo.git +``` + +Set `SSH_DOMAIN=forgejo..ts.net` and `SSH_PORT=22` (done in the Forgejo +change below). HTTP(S) clone URLs come from `ROOT_URL`. + +## Homepage links + +`homepage/config/services.yaml` hrefs are plain `http://ip:port` today — that's +why a tile click leaves HTTPS. As each service is converted, change its href to +`https://{{HOMEPAGE_VAR__DOMAIN}}/` and add the matching +`HOMEPAGE_VAR__DOMAIN=..ts.net` line to `homepage/.env`. +If the service has a **widget**, its `url:` must move too — `http://localhost:` +no longer resolves once the host `ports:` block is dropped (homepage runs on host +networking and the container no longer publishes a port). Point the widget `url:` +at `https://{{HOMEPAGE_VAR__DOMAIN}}` as well; homepage reaches it over the +tailnet via MagicDNS. + +Homepage itself uses the **host-networked variant**: it keeps `network_mode: +host` (it reaches the host-networked helpers — the tailscale-proxy widget on +`:8089` and glances on `:61208` — via localhost), and its sidecar proxies +`https://homepage..ts.net` to the host's `:3000` via +`host.docker.internal`. The new domain must be appended to +`HOMEPAGE_ALLOWED_HOSTS` in `homepage/docker-compose.yml`, or homepage rejects +the proxied request (its reverse-proxy host-check). + +**`docker compose restart homepage` does not pick up a new/changed `.env` +var** — `env_file` is baked into the container at creation time, and `restart` +reuses that same container. The tile renders the literal `{{HOMEPAGE_VAR_...}}` +placeholder instead of the URL until you run `docker compose up -d` (in +`linux-server/homepage`), which recreates the container with the current +`.env`. Verify with `docker exec homepage printenv | grep HOMEPAGE_VAR_`. + +## Rollout order and per-service ports + +Convert one at a time, verify, then move on. The sidecar steps are identical +every time (ports below); the only part that varies is the **app config** column +— apps that generate absolute URLs or host-check need one extra setting, the +rest work at the root unchanged. `tailscale serve` already sends +`X-Forwarded-Proto: https`. + +The **port** column is the container's *internal* listening port — what +`ts-serve.json` proxies to (`127.0.0.1:`). It equals the old +host-published port for every service except `speedtest-tracker`, whose host +mapping was `8765:80`, so its serve target is `:80`. Always read the container +side of the `ports:` mapping (`host:container`), not the host side. + +| service | port | status | app config beyond the sidecar | +|-------------------|-------|---------------|--------------------------------------------------------| +| forgejo | 3000 | ✅ done | `ROOT_URL` + `SSH_DOMAIN`; also exposes git SSH `:22` | +| portainer | 9000 | ✅ done | none — works at root (websocket console proxied) | +| uptime-kuma | 3001 | ✅ done | none — works at root (websockets proxied) | +| speedtest-tracker | 80 | ✅ done | `APP_URL=https://speedtest..ts.net` (Laravel); proxy to container :80, **not** the old 8765 host map | +| ntfy | 80 | ✅ done | `NTFY_BASE_URL=https://ntfy..ts.net` + `NTFY_BEHIND_PROXY=true`; proxy to container :80, **not** the old 5080 host map | +| filebrowser | 80 | ✅ done | none — works at root; proxy to container :80, **not** the old 8080 host map | +| syncthing | 8384 | ✅ done | set `STGUIADDRESS=127.0.0.1:8384` (disables Syncthing's Host-header check, else `Host check error`); publish sync `:22000`/`:21027` on the **sidecar** (raw TCP/UDP, not via serve) | +| glances | 61208 | ✅ done | **host-networked variant** — keep `network_mode: host`, sidecar proxies via `host.docker.internal`; widget url stays localhost | +| adguard | 80 | ✅ done | UI at container :80 (not the 8083 host map); publish DNS `:53` tcp+udp on the **sidecar** (raw DNS, not via serve); no :443 so no DoH/serve conflict | +| atvloadly | 80 | ✅ done | no `hostname:` on the app container — conflicts with `network_mode: service:...`; Apple TV discovery is unaffected by the shared netns since it goes through the host's avahi-daemon via bind-mounted sockets, not this container's own network | +| nginx-proxy-mgr | 81 | ✅ done | host edge (binds `:80/:443/:81`); its **admin UI** is fronted by a host-gateway sidecar at `npm.`, while NPM itself stays the non-tailnet trusted-cert edge (see section below) | +| homepage | 3000 | ✅ done | host-networked variant — keep `network_mode: host` (reaches localhost widgets), sidecar proxies via `host.docker.internal`; add the domain to `HOMEPAGE_ALLOWED_HOSTS` | +| cockpit | 9090 | ✅ done | host systemd service — **sidecar-only** stack proxies `https+insecure://host.docker.internal:9090`; `cockpit.conf.example`'s `Origins` line turned out to be unnecessary in practice — see Gotchas | +| tailscale-web | 8088 | ✅ done | not in the original rollout — added because the homepage Tailscale tile linked plain HTTP. `tailscale web` is a host **systemd user unit**, not a container; `ExecStart` needs `--listen 0.0.0.0:8088 --origin https://tailscale-web..ts.net` so it's reachable via `host.docker.internal` and knows it's reverse-proxied. Don't use port `:5252` — see Gotchas | +| watchtower | 8080 | ✅ done | **no UI** — the sidecar fronts only watchtower's token-gated `/v1/metrics` HTTP API (enable `WATCHTOWER_HTTP_API_METRICS=true` + `WATCHTOWER_HTTP_API_TOKEN`); no homepage `href`. Monitor it in Uptime Kuma — see below | + +Services that also expose **non-HTTP** ports the LAN/tailnet needs (AdGuard DNS +`:53`, Syncthing sync `:22000`, Forgejo SSH `:22`) keep those as direct +tailnet/host ports — only the web UI goes through `tailscale serve`. + +Each conversion is five mechanical edits — copy the sidecar block + `ts-serve.json` +(change only the port), set `TS_AUTHKEY` in the service's `.env`, drop the app's +`ports:` block, add `HOMEPAGE_VAR__DOMAIN=..ts.net` to +`homepage/.env`, and point the homepage `href` at +`https://{{HOMEPAGE_VAR__DOMAIN}}/` — plus the app-config cell above where +non-empty. `ts-state/` is already gitignored for every service +(`linux-server/*/ts-state/`). Remember `docker compose up -d` (not `restart`) +for `homepage` afterward — see Homepage links below. + +### Monitoring a UI-less service in Uptime Kuma (watchtower) + +Watchtower is a headless daemon — no UI, nothing to click. To get the same +up/down tracking as the other services, enable its HTTP metrics API and point an +Uptime Kuma HTTP monitor at it (metrics-only, so the `WATCHTOWER_SCHEDULE` keeps +running — only the *update* API would disable periodic polls): + +1. In `watchtower/.env`: set `WATCHTOWER_API_TOKEN` (e.g. `openssl rand -hex 32`) + and `TS_AUTHKEY`; `docker compose up -d`. +2. In Uptime Kuma, add an **HTTP(s)** monitor: + - URL: `https://watchtower..ts.net/v1/metrics` + - Header: `Authorization: Bearer ` + - Accepted status codes: `200` (an unauthenticated probe gets `401`, so the + header is what proves it's both up *and* reachable). + +The same pattern fits any future no-UI service that exposes a health/metrics +endpoint. For a daemon with *no* endpoint at all, Uptime Kuma's "Docker Container" +monitor (via the docker socket) checks the container's running state instead. + +## Gotchas / migration + +- **Existing git remotes** pointing at `http://...:3300` must be updated: + `git remote set-url origin git@forgejo..ts.net:user/repo.git`. +- **Forgejo data persists** (`./data`); only the URL config changes. Forgejo + regenerates `app.ini` from the `FORGEJO__*` env vars on each start. +- **Device count**: each sidecar is a tailnet device (fine on the free 100-device + tier). Name them after the service. +- **One node, one cert**: first start of each sidecar takes a few seconds to + provision its cert; `tailscale serve status` inside the sidecar shows progress. +- **Stale netns after restarting the sidecar alone**: `network_mode: + service:-ts` makes the app container join the sidecar's network + namespace at *the app container's own start time* — it does not stay + dynamically linked. If you restart only the sidecar (e.g. + `docker compose restart -ts`) after the app is already running, the + sidecar gets a fresh netns but the app is still pinned to the old one. The + app's own healthcheck (`wget http://localhost:` from inside the app + container) keeps reporting healthy — checking against its own stale + loopback, not the sidecar's — so the only symptom is `tailscale serve` + returning `502` with sidecar logs showing + `http: proxy error: dial tcp 127.0.0.1:: connect: connection refused`. + Fix: `docker compose restart ` (the app) after the sidecar so it + re-resolves and rejoins the sidecar's current namespace. +- **ntfy's `/config.js` and `/v1/config` always report `"base_url": ""`** — + this is not a sign that `NTFY_BASE_URL` failed to apply. ntfy's source + hardcodes that field blank on purpose (`server.go`'s `configResponse()`), + so the web app falls back to `window.location.origin` instead of trusting + the server. To verify `NTFY_BASE_URL` actually took effect server-side, + hit `GET /_matrix/push/v1/notify` instead — its handler 500s + (`errHTTPInternalErrorMissingBaseURL`) if `BaseURL` is empty and returns + `200` once it's set, regardless of whether Matrix push is otherwise used. +- **Cockpit's WebSocket Origin check worked without touching + `cockpit.conf.example`'s `Origins` line.** That file documents the + textbook fix for "Cockpit rejects proxied requests from an unrecognized + Origin," but on this Cockpit version it already derives the allowed origin + from the proxied request's `Host`/`X-Forwarded-Proto` (which `tailscale + serve` sets correctly), so the default behavior just works. Verified by + hand-crafting a WebSocket upgrade to `/cockpit/socket`: matching `Origin` + header → `101 Switching Protocols`, a foreign `Origin` → `403` (proving the + check is live, just already satisfied). Keep `cockpit.conf.example` as a + fallback if a future Cockpit/Tailscale version regresses this. +- **`tailscale web` needs both `--listen 0.0.0.0:` and `--origin + https://..ts.net` on its `ExecStart`**, unlike the other + "host-networked apps" sidecars (glances, cockpit) which needed no app-side + change at all. It's a host **systemd user unit** + (`tailscale-web.service` — `systemctl --user`, not the system scope), not a + container. By default it listens on `localhost:8088` only, which + `host.docker.internal` can't reach (loopback is per-network-namespace); + `--listen 0.0.0.0:8088` fixes that. Without `--origin` set to the HTTPS + sidecar domain, the app redirects browsers to its own bare `ip:port` — + harmless over plain HTTP, but a hard `SSL_ERROR_RX_RECORD_TOO_LONG` once + the tile is HTTPS (the browser inherits `https:` from the page and tries + to TLS-handshake a plain-HTTP port). Edit with `systemctl --user edit --full + tailscale-web.service`, then `daemon-reload` + `restart`. +- **Don't proxy to port `:5252`** — something else (unidentified, not this + unit, not a container, not killed by a reboot) answers there and serves + what looks like the same Tailscale UI but never reflects `--origin`/ + `--listen` changes made to `tailscale-web.service`. Cost real debugging + time chasing a stale redirect before realizing the sidecar's + `ts-serve.json` was still pointed at the old `:5252` address instead of + wherever `tailscale web` actually ends up listening. Always confirm the + port with `systemctl --user status tailscale-web.service`'s logged + `web server running on:` line before setting the `Proxy` target. + +## Decisions to confirm + +1. ~~**Auth**: OAuth-client-with-tag (recommended) vs reusable auth key?~~ + Resolved during the Forgejo rollout: OAuth client + `tag:container`, reusing + the existing `tailscale-proxy` client elevated to read+write scope. See + Prerequisites above. +2. ~~**NPM**: retire it or keep it?~~ Resolved: **keep** NPM as the non-tailnet + HTTPS edge — trusted certs (no browser warning) for clients that can't/won't + join the tailnet (e.g. a TV running Plex). See "NPM — trusted HTTPS for + non-tailnet clients" below. (It's also the vendor-independent equivalent of + the whole tailnet layer — see "Resilience / exit strategy".) +3. ~~**Homepage host**: main node vs own sidecar?~~ Resolved: its own + `homepage..ts.net` sidecar (host-networked variant), to keep the + per-service subdomain scheme consistent. + +## Resilience / exit strategy + +This layer leans on Tailscale's hosted control plane. Worth knowing what breaks +if Tailscale has an outage or goes away — and that there's a clean exit. + +### What depends on Tailscale (hosted) vs what's open + +Every `..ts.net` URL depends on the hosted **coordination server** +for: node auth + the `100.x` tailnet IP, **MagicDNS** (`ts.net` is Tailscale's +domain), the **Let's Encrypt certs** `tailscale serve` auto-provisions for +`*.ts.net`, and **DERP** relays for NAT traversal. The sidecars' OAuth auth keys +also flow through it. + +What is **not** dependent: the data plane is **WireGuard** (open, in-kernel, +peer-to-peer — traffic never routes through Tailscale once peers are connected), +`tailscaled` is open source, and the apps + data are all local. + +### Failure modes + +- **Temporary control-plane outage:** mostly fine. Existing tunnels run on cached + keys/endpoints; already-issued certs keep working (90-day lifetime). You just + can't add/re-auth nodes until it's back. +- **Tailscale shuts down permanently:** a long fuse, not a cliff — it degrades + over **~90 days** as certs hit renewal and can't reissue, MagicDNS for `.ts.net` + stops, and sidecars eventually can't re-auth. + +### The exit: Headscale (planned) + +[Headscale](https://github.com/juanfont/headscale) is an open-source, self-hostable +reimplementation of the coordination server (could even run on this box). Point +`tailscaled --login-server=https://` and the tailnet model — MagicDNS, +ACLs, DERP — keeps working without the company. **Catch:** Headscale gives you +neither `.ts.net` nor the zero-config certs, so you switch to **a domain you own** +(`.home.example.com`) and **manage your own certs** — which is exactly the +**NPM + real-domain** setup. So NPM is the bridge to vendor independence; keeping +it (or the know-how) is the insurance policy. + +### Operational single point of failure + +Every front door now routes through `tailscaled` on this host — if the daemon or +its config breaks (local, not Tailscale's fault), all HTTPS URLs drop at once. The +apps keep running underneath. Two cheap mitigations: + +- **Keep host SSH reachable on the LAN** (not only over the tailnet), so you can + always get in to fix the box when the tailnet is the broken thing. +- Each service's compose still documents its container port, so re-exposing a + host `ports:` block for LAN access is a one-line fallback (next section). + +### Sharing a container on the LAN without Tailscale + +The tailnet sidecar and a plain LAN host-port can **coexist** — a service can be +reachable both at `https://..ts.net` (sidecar) and at +`http://:` (host port) for devices not on the tailnet. + +Because a netns-shared app can't publish its own ports, add the `ports:` block to +the **sidecar** (it owns the namespace), exactly as syncthing/adguard already do +for their non-HTTP ports: + +```yaml + -ts: + # ... sidecar as usual, plus: + ports: + - "8096:80" # LAN access at http://:8096 → app's :80 +``` + +That's plaintext HTTP on the LAN, which browsers flag as "not secure." For +**trusted LAN HTTPS** (no warning) you need a publicly-valid cert — that's NPM's +job, see the next section. Host-networked services (e.g. glances) already keep +their LAN port open, so no change is needed for those. + +## NPM — trusted HTTPS for non-tailnet clients + +The tailnet sidecars give no-warning HTTPS, but **only to devices on the tailnet** +(`*.ts.net` resolves and is trusted only there). For clients that can't or won't +join the tailnet — a smart TV, a game console, a guest, a Plex client — plaintext +HTTP triggers the browser's "not secure" warning. NPM is kept to solve exactly +this: a **publicly-trusted cert** on a name those clients can use. + +You **cannot** get a trusted cert for a made-up name (`*.local`, `*.home`) — a CA +must verify you control the domain. So the requirement is a **real public domain** +— that's **`ulises-c.me`** (already owned), so the prerequisite is met. **Not set +up yet — documented here to pick up later.** When you do, leveraging the two tools +already on this box: + +1. **NPM holds a wildcard Let's Encrypt cert** for `*.home.ulises-c.me` via a + **DNS-01** challenge (NPM has built-in DNS-provider plugins). DNS-01 proves + domain control through a DNS record — it does **not** require exposing the + server to the public internet, so this stays LAN-only if you want. +2. **AdGuard resolves those names to the server's LAN IP** via a DNS rewrite + (`*.home.ulises-c.me` → `192.168.1.x`) — split-horizon DNS. Set AdGuard as the + LAN's resolver (it already is, for ad-blocking). +3. **NPM proxies** `https://plex.home.ulises-c.me` → the service. Add a host + `ports:` block on the relevant **sidecar** (see previous section) so NPM can + reach the service, or point NPM at the service's `*.ts.net` name (the host is on + the tailnet). + +Result: `https://plex.home.ulises-c.me` loads with a green lock on any LAN device, +no tailnet membership, no warning. For **public** access (outside the LAN), add a +router port-forward `80/443` → the server; DNS-01 means the cert already works. + +**Plex caveat:** Plex ships its own TLS (`*.plex.direct` certs) and its clients +prefer Plex's own discovery/relay, so they often bypass a reverse proxy. Plex is +usually best left on its native HTTPS rather than fronted by NPM; the NPM path +above is the general recipe for the *other* services you'd share this way. diff --git a/linux-server/README.md b/linux-server/README.md index fb8c347..d052548 100644 --- a/linux-server/README.md +++ b/linux-server/README.md @@ -27,7 +27,8 @@ sudo tailscale up - [`setup.sh`](setup.sh) — thin shim onto the root [`setup.sh`](../setup.sh) (server platform) - [`post-install.md`](post-install.md) — step-by-step checklist to follow after setup.sh - [`apt_packages.md`](apt_packages.md) — full package list with descriptions and links -- [`zshrc.example`](zshrc.example) — server zsh config, deployed to `~/.zshrc` by `setup.sh`; overrides the shared [`dotfiles/zshrc.example`](../dotfiles/zshrc.example) base because the server is headless (no Ghostty/fastfetch/notification hooks) +- [`../dotfiles/zshrc.example`](../dotfiles/zshrc.example) — shared zsh config (incl. Powerlevel10k), deployed to `~/.zshrc` by `setup.sh`; the desktop-only bits self-disable headless, so the server uses the same base as every other platform +- [`../dotfiles/zsh_plugins.txt`](../dotfiles/zsh_plugins.txt) — shared antidote plugin list, deployed to `~/.zsh_plugins.txt` and pre-cloned by `setup.sh` - [`../dotfiles/tmux.conf`](../dotfiles/tmux.conf) — tmux config with mouse support, vi copy mode, and a status bar (shared across all platforms); copied to `~/.tmux.conf` by `setup.sh` - [`../packages.json`](../packages.json) — machine-readable package manifest (shared across all platforms) @@ -219,7 +220,9 @@ In NPM admin (`http://:81`): 3. Listens on `localhost:8089`; Homepage queries it via `customapi` widget 11. nginx proxy manager | [GitHub](https://github.com/jc21/nginx-proxy-manager) | [Docs](https://nginxproxymanager.com/guide/) - 1. Reverse proxy with a web UI — handles HTTPS termination for Homepage (Tailscale cert) and HTTP→HTTPS redirect + 1. Reverse proxy with a web UI — the non-tailnet HTTPS edge: trusted certs for + LAN/public clients that can't join the tailnet (the tailnet sidecars cover + on-tailnet HTTPS). See [HTTPS.md](HTTPS.md) → "NPM — trusted HTTPS for non-tailnet clients" 2. Deploy: ```sh cd linux-server/nginx-proxy-manager && docker compose up -d diff --git a/linux-server/adguard/.env.example b/linux-server/adguard/.env.example new file mode 100644 index 0000000..5fecc3f --- /dev/null +++ b/linux-server/adguard/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and set the value. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/adguard/docker-compose.yml b/linux-server/adguard/docker-compose.yml index d827e09..2fe7b73 100644 --- a/linux-server/adguard/docker-compose.yml +++ b/linux-server/adguard/docker-compose.yml @@ -1,13 +1,43 @@ services: + # Tailscale sidecar — joins the tailnet as `adguard`, terminating HTTPS for the + # web UI at https://adguard..ts.net. See ../HTTPS.md. + # DNS (:53 tcp+udp) stays host-published HERE (the sidecar owns the network + # namespace) — it's raw DNS for LAN clients, not HTTP, and must not go through + # tailscale serve. AdGuard publishes no :443, so there's no DoH/serve conflict. + adguard-ts: + image: tailscale/tailscale:latest + container_name: adguard-ts + hostname: adguard + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + ports: + - "53:53/tcp" # DNS — must stay host-published for LAN clients + - "53:53/udp" + # First-run only: to re-run the setup wizard, temporarily add "3003:3000/tcp" + # here and browse to it; the configured UI lives on :80 (served via HTTPS). + restart: unless-stopped + adguardhome: image: adguard/adguardhome:latest container_name: adguardhome + # Share the sidecar's network namespace: tailscale serve proxies :443 to the + # configured web UI on 127.0.0.1:80 (8083 was only the old host mapping). + network_mode: service:adguard-ts + depends_on: + - adguard-ts restart: unless-stopped - ports: - - "53:53/tcp" - - "53:53/udp" - - "3003:3000/tcp" # setup wizard (first run only) — 3000:homepage, 3001:uptime-kuma - - "8083:80/tcp" # web UI after setup volumes: - ./work:/opt/adguardhome/work - ./conf:/opt/adguardhome/conf diff --git a/linux-server/adguard/ts-serve.json b/linux-server/adguard/ts-serve.json new file mode 100644 index 0000000..4022a7a --- /dev/null +++ b/linux-server/adguard/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:80" } } + } + } +} diff --git a/linux-server/apt_packages.md b/linux-server/apt_packages.md index 7ea227e..c974c6d 100644 --- a/linux-server/apt_packages.md +++ b/linux-server/apt_packages.md @@ -8,7 +8,7 @@ Tested on Ubuntu Server LTS. Most packages work on other Debian-based distros. 1. zsh | [apt](https://packages.ubuntu.com/search?keywords=zsh) | [Homepage](https://www.zsh.org/) 1. Z shell — consistent shell across all devices; set as default by `setup.sh` 2. zsh-antidote | [apt](https://packages.ubuntu.com/search?keywords=zsh-antidote) | [GitHub](https://github.com/mattmc3/antidote) - 1. Zsh plugin manager — manages zsh-autosuggestions and zsh-syntax-highlighting per `~/.zsh_plugins.txt` + 1. Zsh plugin manager — loads the plugins in `~/.zsh_plugins.txt` (autosuggestions, syntax highlighting, completions, history substring search, abbreviations, and the Powerlevel10k prompt) 2. Run `antidote update` to pull latest plugin versions ## CLI Tools diff --git a/linux-server/atvloadly/.env.example b/linux-server/atvloadly/.env.example new file mode 100644 index 0000000..5fecc3f --- /dev/null +++ b/linux-server/atvloadly/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and set the value. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/atvloadly/docker-compose.yml b/linux-server/atvloadly/docker-compose.yml new file mode 100644 index 0000000..4c44e1c --- /dev/null +++ b/linux-server/atvloadly/docker-compose.yml @@ -0,0 +1,40 @@ +services: + # Tailscale sidecar — joins the tailnet as `atvloadly`, terminating HTTPS for + # https://atvloadly..ts.net. See ../HTTPS.md for the full pattern. + atvloadly-ts: + image: tailscale/tailscale:latest + container_name: atvloadly-ts + hostname: atvloadly + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + + atvloadly: + image: bitxeno/atvloadly:latest + container_name: atvloadly + privileged: true + # Share the sidecar's network namespace: tailscale serve proxies :443 to the + # app's HTTP port 80 (5533 was only the old host mapping). Apple TV discovery + # goes through the host's avahi-daemon via the bind-mounted sockets below, not + # through this container's own network namespace, so sharing it with the + # sidecar doesn't affect mDNS/Bonjour pairing. + network_mode: service:atvloadly-ts + depends_on: + - atvloadly-ts + restart: unless-stopped + volumes: + - /etc/atvloadly:/data + - /var/run/dbus:/var/run/dbus + - /var/run/avahi-daemon:/var/run/avahi-daemon diff --git a/linux-server/atvloadly/ts-serve.json b/linux-server/atvloadly/ts-serve.json new file mode 100644 index 0000000..4022a7a --- /dev/null +++ b/linux-server/atvloadly/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:80" } } + } + } +} diff --git a/linux-server/cockpit/.env.example b/linux-server/cockpit/.env.example new file mode 100644 index 0000000..5fecc3f --- /dev/null +++ b/linux-server/cockpit/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and set the value. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/cockpit/cockpit.conf.example b/linux-server/cockpit/cockpit.conf.example new file mode 100644 index 0000000..dff4a7b --- /dev/null +++ b/linux-server/cockpit/cockpit.conf.example @@ -0,0 +1,13 @@ +# Cockpit host config for running behind the Tailscale sidecar proxy. +# Cockpit rejects proxied requests/websockets from an unrecognized Origin (its +# version of a host-check), so the proxied hostname must be allow-listed. +# +# Deploy on the SERVER (not via this repo — Cockpit is a host service): +# sudo cp cockpit.conf.example /etc/cockpit/cockpit.conf # edit first +# sudo systemctl restart cockpit +# +# Replace with your tailnet (e.g. tail01d63b.ts.net). + +[WebService] +Origins = https://cockpit..ts.net wss://cockpit..ts.net +ProtocolHeader = X-Forwarded-Proto diff --git a/linux-server/cockpit/docker-compose.yml b/linux-server/cockpit/docker-compose.yml new file mode 100644 index 0000000..c02b05d --- /dev/null +++ b/linux-server/cockpit/docker-compose.yml @@ -0,0 +1,27 @@ +services: + # Cockpit is a HOST systemd service (not a container) on :9090 with its own + # self-signed TLS. This is a sidecar-ONLY stack — there is no app container to + # share a netns with. The sidecar joins the tailnet as `cockpit` and proxies + # https://cockpit..ts.net to the host's Cockpit via host.docker.internal. + # See ../HTTPS.md ("host-networked apps" variant). + cockpit-ts: + image: tailscale/tailscale:latest + container_name: cockpit-ts + hostname: cockpit + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped diff --git a/linux-server/cockpit/ts-serve.json b/linux-server/cockpit/ts-serve.json new file mode 100644 index 0000000..7450ca1 --- /dev/null +++ b/linux-server/cockpit/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "https+insecure://host.docker.internal:9090" } } + } + } +} diff --git a/linux-server/filebrowser/.env.example b/linux-server/filebrowser/.env.example index 6eace57..67f9663 100644 --- a/linux-server/filebrowser/.env.example +++ b/linux-server/filebrowser/.env.example @@ -1,3 +1,7 @@ # Path on the host to expose in the file browser # Defaults to / (entire filesystem) if not set FB_ROOT=/ + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). See ../HTTPS.md. +TS_AUTHKEY= diff --git a/linux-server/filebrowser/docker-compose.yml b/linux-server/filebrowser/docker-compose.yml index cc7071d..b5f43b6 100644 --- a/linux-server/filebrowser/docker-compose.yml +++ b/linux-server/filebrowser/docker-compose.yml @@ -1,10 +1,36 @@ services: + # Tailscale sidecar — joins the tailnet as `filebrowser`, terminating HTTPS for + # https://filebrowser..ts.net. See ../HTTPS.md for the full pattern. + filebrowser-ts: + image: tailscale/tailscale:latest + container_name: filebrowser-ts + hostname: filebrowser + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + filebrowser: image: filebrowser/filebrowser:latest container_name: filebrowser + # Share the sidecar's network namespace: tailscale serve proxies :443 to the + # app's HTTP port 80 (8080 was only the old host mapping). Works at root, no + # app-side base-URL config needed. + network_mode: service:filebrowser-ts + depends_on: + - filebrowser-ts restart: unless-stopped - ports: - - "8080:80" volumes: - ${FB_ROOT:-/}:/srv - ./database:/database diff --git a/linux-server/filebrowser/ts-serve.json b/linux-server/filebrowser/ts-serve.json new file mode 100644 index 0000000..4022a7a --- /dev/null +++ b/linux-server/filebrowser/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:80" } } + } + } +} diff --git a/linux-server/forgejo/.env.example b/linux-server/forgejo/.env.example index 8866bec..654bab1 100644 --- a/linux-server/forgejo/.env.example +++ b/linux-server/forgejo/.env.example @@ -1,8 +1,14 @@ -# Copy this file to .env and update the values. +# Copy this file to .env and update the values. See ../HTTPS.md for the full setup. -# Tailscale hostname — shown in clone URLs in the Forgejo web UI -# Run: tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//' -FORGEJO_DOMAIN=..ts.net +# Tailscale auth for the sidecar. Either a reusable auth key, OR an OAuth client +# secret (recommended) paired with TS_EXTRA_ARGS=--advertise-tags=tag:container +# in docker-compose.yml. Create at: tailscale.com/admin/settings/keys (or /oauth) +TS_AUTHKEY= + +# Forgejo's own MagicDNS name = the sidecar `hostname:` (forgejo) + your tailnet. +# Find your tailnet: tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//' +# (that prints ollie-server..ts.net — swap the host part for `forgejo`) +FORGEJO_DOMAIN=forgejo..ts.net # Where to store repositories and Forgejo config. # Default: ./data (same directory as docker-compose.yml) diff --git a/linux-server/forgejo/docker-compose.yml b/linux-server/forgejo/docker-compose.yml index 967ee5a..28fdbe2 100644 --- a/linux-server/forgejo/docker-compose.yml +++ b/linux-server/forgejo/docker-compose.yml @@ -1,21 +1,45 @@ services: + # Tailscale sidecar — joins the tailnet as `forgejo`, terminating HTTPS for + # https://forgejo..ts.net and exposing git SSH on :22. See HTTPS.md. + forgejo-ts: + image: tailscale/tailscale:latest + container_name: forgejo-ts + hostname: forgejo + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + forgejo: # Pin to a major version tag to avoid surprise upgrades — check latest at: # https://codeberg.org/forgejo/forgejo/releases image: codeberg.org/forgejo/forgejo:15 container_name: forgejo + # Share the sidecar's network namespace: tailscale serve proxies :443 to + # 127.0.0.1:3000, and git SSH is reachable at forgejo..ts.net:22. + network_mode: service:forgejo-ts + depends_on: + - forgejo-ts restart: unless-stopped environment: - USER_UID=1000 - USER_GID=1000 - TZ=${TZ:-America/Los_Angeles} - FORGEJO__database__DB_TYPE=sqlite3 - - FORGEJO__server__ROOT_URL=http://${FORGEJO_DOMAIN}:3300 + - FORGEJO__server__ROOT_URL=https://${FORGEJO_DOMAIN}/ - FORGEJO__server__SSH_DOMAIN=${FORGEJO_DOMAIN} - - FORGEJO__server__SSH_PORT=2222 - ports: - - "3300:3000" # web UI - - "2222:22" # git over SSH + - FORGEJO__server__SSH_PORT=22 volumes: - ${FORGEJO_DATA_PATH:-./data}:/data healthcheck: diff --git a/linux-server/forgejo/ts-serve.json b/linux-server/forgejo/ts-serve.json new file mode 100644 index 0000000..53329d7 --- /dev/null +++ b/linux-server/forgejo/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:3000" } } + } + } +} diff --git a/linux-server/glances/.env.example b/linux-server/glances/.env.example new file mode 100644 index 0000000..5fecc3f --- /dev/null +++ b/linux-server/glances/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and set the value. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/glances/docker-compose.yml b/linux-server/glances/docker-compose.yml index 959b7ef..396f2eb 100644 --- a/linux-server/glances/docker-compose.yml +++ b/linux-server/glances/docker-compose.yml @@ -1,4 +1,34 @@ services: + # Tailscale sidecar — joins the tailnet as `glances`, terminating HTTPS for + # https://glances..ts.net. See ../HTTPS.md ("host-networked apps"). + # + # Unlike the other services, glances is NOT moved into the sidecar's network + # namespace — it must keep `network_mode: host` to read the host's real network + # interfaces and processes. Instead the sidecar runs in its own netns and proxies + # back to the host's glances port via host.docker.internal (the docker bridge + # gateway), so glances' monitoring fidelity is untouched. + glances-ts: + image: tailscale/tailscale:latest + container_name: glances-ts + hostname: glances + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + glances: image: nicolargo/glances:latest-full container_name: glances diff --git a/linux-server/glances/ts-serve.json b/linux-server/glances/ts-serve.json new file mode 100644 index 0000000..d732a48 --- /dev/null +++ b/linux-server/glances/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://host.docker.internal:61208" } } + } + } +} diff --git a/linux-server/homepage/.env.example b/linux-server/homepage/.env.example index 86c64b1..5667245 100644 --- a/linux-server/homepage/.env.example +++ b/linux-server/homepage/.env.example @@ -5,9 +5,6 @@ # Access the dashboard at http://.local:3000 HOMEPAGE_VAR_SERVER_IP=ollie-server.local -# Tailscale web UI — get your Tailscale IP with: tailscale ip -4 -HOMEPAGE_VAR_TAILSCALE_IP= - # Tailscale widget uses a local proxy (linux-server/tailscale-proxy) — configure credentials there # AdGuard Home — credentials for the dashboard stats widget @@ -18,10 +15,57 @@ HOMEPAGE_VAR_ADGUARD_PASS= # Syncthing — API key from web UI → Actions → Settings → API Key HOMEPAGE_VAR_SYNCTHING_KEY= -# Forgejo — same Tailscale hostname as TAILSCALE_HOSTNAME; API token from Forgejo → Settings → Applications -HOMEPAGE_VAR_FORGEJO_DOMAIN= +# Forgejo — its own Tailscale sidecar name (forgejo..ts.net), per HTTPS.md; +# API token from Forgejo → Settings → Applications +HOMEPAGE_VAR_FORGEJO_DOMAIN=forgejo..ts.net HOMEPAGE_VAR_FORGEJO_TOKEN= +# Portainer — its own Tailscale sidecar name (portainer..ts.net), per HTTPS.md +HOMEPAGE_VAR_PORTAINER_DOMAIN=portainer..ts.net + +# Uptime Kuma — its own Tailscale sidecar name (uptime-kuma..ts.net), per HTTPS.md +HOMEPAGE_VAR_UPTIMEKUMA_DOMAIN=uptime-kuma..ts.net + +# Speedtest Tracker — its own Tailscale sidecar name (speedtest..ts.net), per HTTPS.md +HOMEPAGE_VAR_SPEEDTEST_DOMAIN=speedtest..ts.net + +# ntfy — its own Tailscale sidecar name (ntfy..ts.net), per HTTPS.md +HOMEPAGE_VAR_NTFY_DOMAIN=ntfy..ts.net + +# Filebrowser — its own Tailscale sidecar name (filebrowser..ts.net), per HTTPS.md +HOMEPAGE_VAR_FILEBROWSER_DOMAIN=filebrowser..ts.net + +# Syncthing — its own Tailscale sidecar name (syncthing..ts.net), per HTTPS.md +HOMEPAGE_VAR_SYNCTHING_DOMAIN=syncthing..ts.net + +# Glances — its own Tailscale sidecar name (glances..ts.net), per HTTPS.md +HOMEPAGE_VAR_GLANCES_DOMAIN=glances..ts.net + +# AdGuard Home — its own Tailscale sidecar name (adguard..ts.net), per HTTPS.md +HOMEPAGE_VAR_ADGUARD_DOMAIN=adguard..ts.net + +# Cockpit — its own Tailscale sidecar name (cockpit..ts.net), per HTTPS.md +HOMEPAGE_VAR_COCKPIT_DOMAIN=cockpit..ts.net + +# atvloadly — its own Tailscale sidecar name (atvloadly..ts.net), per HTTPS.md +HOMEPAGE_VAR_ATVLOADLY_DOMAIN=atvloadly..ts.net + +# Homepage itself — its own Tailscale sidecar name (homepage..ts.net), per +# HTTPS.md. Also appended to HOMEPAGE_ALLOWED_HOSTS in docker-compose.yml. +HOMEPAGE_VAR_HOMEPAGE_DOMAIN=homepage..ts.net + +# tailscale web — its own Tailscale sidecar name (tailscale-web..ts.net), +# per HTTPS.md. Proxies Tailscale's own local node UI (host process on :8088, +# requires --listen 0.0.0.0:8088 --origin on its systemd unit). +HOMEPAGE_VAR_TAILSCALE_WEB_DOMAIN=tailscale-web..ts.net + +# nginx proxy manager — admin UI sidecar name (npm..ts.net), per HTTPS.md. +# Fronts NPM's :81 admin UI; NPM still binds host :80/:443 as the non-tailnet edge. +HOMEPAGE_VAR_NPM_DOMAIN=npm..ts.net + +# Tailscale auth for the homepage sidecar — same OAuth client secret as the others. +TS_AUTHKEY= + # Server hostname — used to build HOMEPAGE_ALLOWED_HOSTS in docker-compose.yml HOSTNAME=ollie-server # Server LAN IP — used in HOMEPAGE_ALLOWED_HOSTS to allow direct IP access diff --git a/linux-server/homepage/config/services.yaml b/linux-server/homepage/config/services.yaml index 69b28eb..dde059e 100644 --- a/linux-server/homepage/config/services.yaml +++ b/linux-server/homepage/config/services.yaml @@ -1,13 +1,13 @@ - Management: - homepage: icon: mdi-view-dashboard - href: http://{{HOMEPAGE_VAR_SERVER_IP}} + href: https://{{HOMEPAGE_VAR_HOMEPAGE_DOMAIN}}/ description: Server dashboard server: my-docker container: homepage - portainer: icon: si-portainer - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:9000 + href: https://{{HOMEPAGE_VAR_PORTAINER_DOMAIN}}/ description: Docker manager server: my-docker container: portainer @@ -18,27 +18,30 @@ container: watchtower - cockpit: icon: mdi-monitor-dashboard - href: https://{{HOMEPAGE_VAR_SERVER_IP}}:9090 + href: https://{{HOMEPAGE_VAR_COCKPIT_DOMAIN}}/ description: Server admin UI + # cockpit stays a host service on :9090, so the local ping still works ping: https://localhost:9090 - uptime kuma: icon: si-uptimekuma - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:3001 + href: https://{{HOMEPAGE_VAR_UPTIMEKUMA_DOMAIN}}/ description: Uptime monitor server: my-docker container: uptime-kuma widget: type: uptimekuma - url: http://localhost:3001 + url: https://{{HOMEPAGE_VAR_UPTIMEKUMA_DOMAIN}} slug: default - glances: icon: si-glances - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:61208 + href: https://{{HOMEPAGE_VAR_GLANCES_DOMAIN}}/ description: System monitor server: my-docker container: glances widget: type: glances + # glances keeps network_mode: host, so :61208 stays on the host and the + # widget reaches it via localhost (no need to route through the tailnet). url: http://localhost:61208 version: 4 metric: cpu @@ -46,24 +49,24 @@ - Network: - nginx proxy manager: icon: si-nginxproxymanager - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:81 + href: https://{{HOMEPAGE_VAR_NPM_DOMAIN}}/ description: Reverse proxy + SSL server: my-docker container: nginx-proxy-manager - adguard home: icon: si-adguard - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:8083 + href: https://{{HOMEPAGE_VAR_ADGUARD_DOMAIN}}/ description: DNS ad blocker server: my-docker container: adguardhome widget: type: adguard - url: http://localhost:8083 + url: https://{{HOMEPAGE_VAR_ADGUARD_DOMAIN}} username: "{{HOMEPAGE_VAR_ADGUARD_USER}}" password: "{{HOMEPAGE_VAR_ADGUARD_PASS}}" - tailscale: icon: si-tailscale - href: http://{{HOMEPAGE_VAR_TAILSCALE_IP}}:5252 + href: https://{{HOMEPAGE_VAR_TAILSCALE_WEB_DOMAIN}}/ description: Zero-config VPN widget: type: customapi @@ -80,16 +83,16 @@ format: relativeDate - speedtest tracker: icon: mdi-speedometer - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:8765 + href: https://{{HOMEPAGE_VAR_SPEEDTEST_DOMAIN}}/ description: ISP speed monitor server: my-docker container: speedtest-tracker widget: type: speedtest - url: http://localhost:8765 + url: https://{{HOMEPAGE_VAR_SPEEDTEST_DOMAIN}} - ntfy: icon: si-ntfy - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:5080 + href: https://{{HOMEPAGE_VAR_NTFY_DOMAIN}}/ description: Push notifications server: my-docker container: ntfy @@ -97,23 +100,23 @@ - Storage: - forgejo: icon: si-forgejo - href: http://{{HOMEPAGE_VAR_FORGEJO_DOMAIN}}:3300 + href: https://{{HOMEPAGE_VAR_FORGEJO_DOMAIN}}/ description: Self-hosted git server: my-docker container: forgejo widget: type: gitea - url: http://localhost:3300 + url: https://{{HOMEPAGE_VAR_FORGEJO_DOMAIN}} key: "{{HOMEPAGE_VAR_FORGEJO_TOKEN}}" - syncthing: icon: si-syncthing - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:8384 + href: https://{{HOMEPAGE_VAR_SYNCTHING_DOMAIN}}/ description: File sync server: my-docker container: syncthing widget: type: customapi - url: http://localhost:8384/rest/system/status + url: https://{{HOMEPAGE_VAR_SYNCTHING_DOMAIN}}/rest/system/status headers: X-API-Key: "{{HOMEPAGE_VAR_SYNCTHING_KEY}}" mappings: @@ -128,13 +131,13 @@ format: bytes - filebrowser: icon: mdi-folder-network - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:8080 + href: https://{{HOMEPAGE_VAR_FILEBROWSER_DOMAIN}}/ description: Web file manager server: my-docker container: filebrowser - atvloadly: icon: mdi-television-play - href: http://{{HOMEPAGE_VAR_SERVER_IP}}:5533 + href: https://{{HOMEPAGE_VAR_ATVLOADLY_DOMAIN}}/ description: Apple TV IPA sideloader server: my-docker container: atvloadly diff --git a/linux-server/homepage/docker-compose.yml b/linux-server/homepage/docker-compose.yml index 0c704cb..1544152 100644 --- a/linux-server/homepage/docker-compose.yml +++ b/linux-server/homepage/docker-compose.yml @@ -1,15 +1,48 @@ services: + # Tailscale sidecar — joins the tailnet as `homepage`, terminating HTTPS for + # https://homepage..ts.net. See ../HTTPS.md ("host-networked apps"). + # + # homepage stays network_mode: host (it reaches the host-networked helpers — + # the tailscale-proxy widget on :8089 and glances on :61208 — via localhost), + # so the sidecar runs in its own netns and proxies back to the host's :3000 via + # host.docker.internal, exactly like the glances/cockpit variant. + homepage-ts: + image: tailscale/tailscale:latest + container_name: homepage-ts + hostname: homepage + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + homepage: image: ghcr.io/gethomepage/homepage:latest container_name: homepage network_mode: host + depends_on: + - homepage-ts volumes: - ./config:/app/config - /var/run/docker.sock:/var/run/docker.sock:ro env_file: - .env environment: - HOMEPAGE_ALLOWED_HOSTS: "${HOSTNAME},${HOSTNAME}.local,localhost,${SERVER_IP},${TAILSCALE_HOSTNAME}" + # The proxied hostname (homepage..ts.net) must be allow-listed or + # homepage rejects the request — its version of a reverse-proxy host-check. + HOMEPAGE_ALLOWED_HOSTS: "${HOSTNAME},${HOSTNAME}.local,localhost,${SERVER_IP},${TAILSCALE_HOSTNAME},${HOMEPAGE_VAR_HOMEPAGE_DOMAIN}" HOSTNAME: "0.0.0.0" PORT: "3000" restart: unless-stopped diff --git a/linux-server/homepage/ts-serve.json b/linux-server/homepage/ts-serve.json new file mode 100644 index 0000000..d85ef3a --- /dev/null +++ b/linux-server/homepage/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://host.docker.internal:3000" } } + } + } +} diff --git a/linux-server/nginx-proxy-manager/.env.example b/linux-server/nginx-proxy-manager/.env.example new file mode 100644 index 0000000..0cfc1f7 --- /dev/null +++ b/linux-server/nginx-proxy-manager/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and set the value. See ../HTTPS.md for the full setup. + +# Tailscale auth for the admin-UI sidecar — same OAuth client secret as the other +# sidecars (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/nginx-proxy-manager/docker-compose.yml b/linux-server/nginx-proxy-manager/docker-compose.yml index 0b6180d..005eaf8 100644 --- a/linux-server/nginx-proxy-manager/docker-compose.yml +++ b/linux-server/nginx-proxy-manager/docker-compose.yml @@ -1,4 +1,31 @@ services: + # Tailscale sidecar — fronts NPM's admin UI at https://npm..ts.net. + # NPM is the non-tailnet edge: it must keep binding the host's :80/:443/:81, so + # it can't join a shared netns. This is the host-networked variant — the sidecar + # runs in its own netns and proxies to the host's :81 (admin UI) via + # host.docker.internal. NPM's :80/:443 proxying is unaffected. See ../HTTPS.md. + nginx-proxy-manager-ts: + image: tailscale/tailscale:latest + container_name: nginx-proxy-manager-ts + hostname: npm + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + nginx-proxy-manager: image: jc21/nginx-proxy-manager:latest container_name: nginx-proxy-manager diff --git a/linux-server/nginx-proxy-manager/ts-serve.json b/linux-server/nginx-proxy-manager/ts-serve.json new file mode 100644 index 0000000..854cc9a --- /dev/null +++ b/linux-server/nginx-proxy-manager/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://host.docker.internal:81" } } + } + } +} diff --git a/linux-server/ntfy/.env.example b/linux-server/ntfy/.env.example new file mode 100644 index 0000000..f24cdc9 --- /dev/null +++ b/linux-server/ntfy/.env.example @@ -0,0 +1,9 @@ +# Copy this file to .env and update the values. See ../HTTPS.md for the full setup. + +# Public HTTPS URL (no trailing slash) — must match the Tailscale sidecar domain, +# or notification/web links and the web app break. ntfy reads NTFY_BASE_URL. +NTFY_BASE_URL=https://ntfy..ts.net + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/ntfy/docker-compose.yml b/linux-server/ntfy/docker-compose.yml index dcd4b3c..9108246 100644 --- a/linux-server/ntfy/docker-compose.yml +++ b/linux-server/ntfy/docker-compose.yml @@ -1,13 +1,44 @@ services: + # Tailscale sidecar — joins the tailnet as `ntfy`, terminating HTTPS for + # https://ntfy..ts.net. See ../HTTPS.md for the full pattern. + ntfy-ts: + image: tailscale/tailscale:latest + container_name: ntfy-ts + hostname: ntfy + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + ntfy: image: binwiederhier/ntfy:latest container_name: ntfy - restart: unless-stopped command: serve + # Share the sidecar's network namespace: tailscale serve proxies :443 to the + # app's HTTP port 80 (5080 was only the old host mapping). NTFY_BASE_URL must + # match the HTTPS domain or web/notification links and the web app break. + network_mode: service:ntfy-ts + depends_on: + - ntfy-ts + restart: unless-stopped + env_file: + - .env environment: - TZ=America/Los_Angeles - ports: - - "5080:80" + # ntfy is behind a reverse proxy (tailscale serve) — trust X-Forwarded-For + # so rate limiting / visitor identification uses the real client IP. + - NTFY_BEHIND_PROXY=true volumes: - ./data:/var/lib/ntfy - ./cache:/var/cache/ntfy diff --git a/linux-server/ntfy/ts-serve.json b/linux-server/ntfy/ts-serve.json new file mode 100644 index 0000000..4022a7a --- /dev/null +++ b/linux-server/ntfy/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:80" } } + } + } +} diff --git a/linux-server/portainer/.env.example b/linux-server/portainer/.env.example new file mode 100644 index 0000000..6fa4a94 --- /dev/null +++ b/linux-server/portainer/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .env and update the values. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — the same OAuth client secret used for the +# other sidecars (it just needs the Auth Keys scope + tag:container in the ACL). +# Create at: tailscale.com/admin/settings/keys (or /oauth) +TS_AUTHKEY= diff --git a/linux-server/portainer/docker-compose.yml b/linux-server/portainer/docker-compose.yml index f7ff628..d5ca830 100644 --- a/linux-server/portainer/docker-compose.yml +++ b/linux-server/portainer/docker-compose.yml @@ -1,11 +1,36 @@ services: + # Tailscale sidecar — joins the tailnet as `portainer`, terminating HTTPS for + # https://portainer..ts.net. See ../HTTPS.md for the full pattern. + portainer-ts: + image: tailscale/tailscale:latest + container_name: portainer-ts + hostname: portainer + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + portainer: image: portainer/portainer-ce:latest container_name: portainer + # Share the sidecar's network namespace: tailscale serve proxies :443 to the + # plaintext HTTP UI on 127.0.0.1:9000 (9443 is Portainer's own self-signed + # TLS, not needed behind Tailscale). + network_mode: service:portainer-ts + depends_on: + - portainer-ts restart: unless-stopped - ports: - - "9000:9000" - - "9443:9443" volumes: - /var/run/docker.sock:/var/run/docker.sock - portainer_data:/data diff --git a/linux-server/portainer/ts-serve.json b/linux-server/portainer/ts-serve.json new file mode 100644 index 0000000..5f314ae --- /dev/null +++ b/linux-server/portainer/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:9000" } } + } + } +} diff --git a/linux-server/speedtest-tracker/.env.example b/linux-server/speedtest-tracker/.env.example index df6c702..f595c58 100644 --- a/linux-server/speedtest-tracker/.env.example +++ b/linux-server/speedtest-tracker/.env.example @@ -1,7 +1,14 @@ # Required — generate with: echo "base64:$(openssl rand -base64 32)" +# (setup.sh auto-generates this when it first creates .env from this example) APP_KEY= -APP_URL=http://localhost:8765 +# Public HTTPS URL — must match the Tailscale sidecar domain (see ../HTTPS.md) so +# the Laravel app builds correct asset/links and avoids mixed-content over HTTPS. +APP_URL=https://speedtest..ts.net # Cron schedule for automatic speedtests (default: every 6 hours) SPEEDTEST_SCHEDULE=0 */6 * * * + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). See ../HTTPS.md. +TS_AUTHKEY= diff --git a/linux-server/speedtest-tracker/docker-compose.yml b/linux-server/speedtest-tracker/docker-compose.yml index c0c66b5..46d8a75 100644 --- a/linux-server/speedtest-tracker/docker-compose.yml +++ b/linux-server/speedtest-tracker/docker-compose.yml @@ -1,7 +1,35 @@ services: + # Tailscale sidecar — joins the tailnet as `speedtest`, terminating HTTPS for + # https://speedtest..ts.net. See ../HTTPS.md for the full pattern. + speedtest-tracker-ts: + image: tailscale/tailscale:latest + container_name: speedtest-tracker-ts + hostname: speedtest + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + speedtest-tracker: image: lscr.io/linuxserver/speedtest-tracker:latest container_name: speedtest-tracker + # Share the sidecar's network namespace: tailscale serve proxies :443 to the + # app's HTTP port 80 (the image's plaintext UI; 8765 was only the old host + # mapping). APP_URL in .env must match the HTTPS domain for correct links. + network_mode: service:speedtest-tracker-ts + depends_on: + - speedtest-tracker-ts restart: unless-stopped env_file: - .env @@ -10,7 +38,5 @@ services: - PGID=1000 - TZ=America/Los_Angeles - DB_CONNECTION=sqlite - ports: - - "8765:80" volumes: - ./data:/config diff --git a/linux-server/speedtest-tracker/ts-serve.json b/linux-server/speedtest-tracker/ts-serve.json new file mode 100644 index 0000000..4022a7a --- /dev/null +++ b/linux-server/speedtest-tracker/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:80" } } + } + } +} diff --git a/linux-server/syncthing/.env.example b/linux-server/syncthing/.env.example new file mode 100644 index 0000000..5fecc3f --- /dev/null +++ b/linux-server/syncthing/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and set the value. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/syncthing/docker-compose.yml b/linux-server/syncthing/docker-compose.yml index 36e97d9..16c4b96 100644 --- a/linux-server/syncthing/docker-compose.yml +++ b/linux-server/syncthing/docker-compose.yml @@ -1,17 +1,49 @@ services: + # Tailscale sidecar — joins the tailnet as `syncthing`, terminating HTTPS for + # the GUI at https://syncthing..ts.net. See ../HTTPS.md. + # The sync/discovery ports stay host-published HERE (the sidecar owns the + # network namespace, so the app container can't declare its own ports) — peer + # sync is raw TCP/UDP, not HTTP, and must not go through tailscale serve. + syncthing-ts: + image: tailscale/tailscale:latest + container_name: syncthing-ts + hostname: syncthing + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + ports: + - "22000:22000/tcp" # sync protocol + - "22000:22000/udp" # sync protocol (QUIC) + - "21027:21027/udp" # local discovery + restart: unless-stopped + syncthing: image: lscr.io/linuxserver/syncthing:latest container_name: syncthing + # Share the sidecar's network namespace: tailscale serve proxies :443 to the + # GUI on 127.0.0.1:8384. STGUIADDRESS pins the GUI to loopback (only the serve + # proxy reaches it) and disables Syncthing's Host-header check, which would + # otherwise reject the proxied hostname with "Host check error". + network_mode: service:syncthing-ts + depends_on: + - syncthing-ts restart: unless-stopped environment: - PUID=1000 - PGID=1000 - TZ=America/Los_Angeles - ports: - - "8384:8384" # web UI - - "22000:22000/tcp" # sync protocol - - "22000:22000/udp" # sync protocol (QUIC) - - "21027:21027/udp" # local discovery + - STGUIADDRESS=127.0.0.1:8384 volumes: - ./config:/config # Add a volume mount for each folder you want to sync, then configure diff --git a/linux-server/syncthing/ts-serve.json b/linux-server/syncthing/ts-serve.json new file mode 100644 index 0000000..37d1c31 --- /dev/null +++ b/linux-server/syncthing/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:8384" } } + } + } +} diff --git a/linux-server/tailscale-web/.env.example b/linux-server/tailscale-web/.env.example new file mode 100644 index 0000000..5fecc3f --- /dev/null +++ b/linux-server/tailscale-web/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env and set the value. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/tailscale-web/docker-compose.yml b/linux-server/tailscale-web/docker-compose.yml new file mode 100644 index 0000000..3858500 --- /dev/null +++ b/linux-server/tailscale-web/docker-compose.yml @@ -0,0 +1,34 @@ +services: + # `tailscale web` is a HOST process (not a container), Tailscale's own + # lightweight local node management UI. The host's tailscale-web.service + # systemd user unit runs it with `--listen 0.0.0.0:8088 --origin + # https://tailscale-web..ts.net` — --origin so it knows it's being + # reverse-proxied (else it redirects browsers to its own bare ip:port), and + # --listen 0.0.0.0 (not the default localhost-only) so host.docker.internal + # can actually reach it from this sidecar's netns. Port :5252 is NOT used — + # an unrelated process occupies it; don't proxy there. + # This is a sidecar-ONLY stack — there is no app container to share a netns + # with. The sidecar joins the tailnet as `tailscale-web` and proxies + # https://tailscale-web..ts.net to the host's :8088. + # See ../HTTPS.md ("host-networked apps" variant). + tailscale-web-ts: + image: tailscale/tailscale:latest + container_name: tailscale-web-ts + hostname: tailscale-web + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped diff --git a/linux-server/tailscale-web/ts-serve.json b/linux-server/tailscale-web/ts-serve.json new file mode 100644 index 0000000..de0fabe --- /dev/null +++ b/linux-server/tailscale-web/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://host.docker.internal:8088" } } + } + } +} diff --git a/linux-server/uptime-kuma/.env.example b/linux-server/uptime-kuma/.env.example new file mode 100644 index 0000000..6fa4a94 --- /dev/null +++ b/linux-server/uptime-kuma/.env.example @@ -0,0 +1,6 @@ +# Copy this file to .env and update the values. See ../HTTPS.md for the full setup. + +# Tailscale auth for the sidecar — the same OAuth client secret used for the +# other sidecars (it just needs the Auth Keys scope + tag:container in the ACL). +# Create at: tailscale.com/admin/settings/keys (or /oauth) +TS_AUTHKEY= diff --git a/linux-server/uptime-kuma/docker-compose.yml b/linux-server/uptime-kuma/docker-compose.yml index ed7d464..6aa967f 100644 --- a/linux-server/uptime-kuma/docker-compose.yml +++ b/linux-server/uptime-kuma/docker-compose.yml @@ -1,11 +1,36 @@ services: + # Tailscale sidecar — joins the tailnet as `uptime-kuma`, terminating HTTPS for + # https://uptime-kuma..ts.net. See ../HTTPS.md for the full pattern. + uptime-kuma-ts: + image: tailscale/tailscale:latest + container_name: uptime-kuma-ts + hostname: uptime-kuma + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + uptime-kuma: image: louislam/uptime-kuma:latest container_name: uptime-kuma + # Share the sidecar's network namespace: tailscale serve proxies :443 to + # 127.0.0.1:3001. Outbound monitor checks still work (kernel-mode netns). + network_mode: service:uptime-kuma-ts + depends_on: + - uptime-kuma-ts restart: unless-stopped environment: - TZ=America/Los_Angeles - ports: - - "3001:3001" volumes: - ./data:/app/data diff --git a/linux-server/uptime-kuma/ts-serve.json b/linux-server/uptime-kuma/ts-serve.json new file mode 100644 index 0000000..dc247ad --- /dev/null +++ b/linux-server/uptime-kuma/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:3001" } } + } + } +} diff --git a/linux-server/watchtower/.env.example b/linux-server/watchtower/.env.example new file mode 100644 index 0000000..43336df --- /dev/null +++ b/linux-server/watchtower/.env.example @@ -0,0 +1,10 @@ +# Copy this file to .env and set the values. See ../HTTPS.md for the full setup. + +# Bearer token guarding watchtower's HTTP API (/v1/metrics). Generate any random +# string, e.g.: openssl rand -hex 32 +# Uptime Kuma sends it as the header: Authorization: Bearer +WATCHTOWER_API_TOKEN= + +# Tailscale auth for the sidecar — same OAuth client secret as the other sidecars +# (needs the Auth Keys scope + tag:container in the ACL). +TS_AUTHKEY= diff --git a/linux-server/watchtower/docker-compose.yml b/linux-server/watchtower/docker-compose.yml index b3f2df7..04c85a6 100644 --- a/linux-server/watchtower/docker-compose.yml +++ b/linux-server/watchtower/docker-compose.yml @@ -1,11 +1,45 @@ services: + # Tailscale sidecar — joins the tailnet as `watchtower`, terminating HTTPS for + # https://watchtower..ts.net. Watchtower has no human UI; this fronts + # only its token-gated /v1/metrics API so Uptime Kuma can health-check it. + # See ../HTTPS.md. + watchtower-ts: + image: tailscale/tailscale:latest + container_name: watchtower-ts + hostname: watchtower + environment: + - TS_AUTHKEY=${TS_AUTHKEY} + - TS_STATE_DIR=/var/lib/tailscale + - TS_SERVE_CONFIG=/config/serve.json + # Requires TS_AUTHKEY to be allowed to apply tag:container (OAuth client, or + # a tag-scoped auth key). Drop this line if using an untagged personal key. + - TS_EXTRA_ARGS=--advertise-tags=tag:container + volumes: + - ./ts-state:/var/lib/tailscale + - ./ts-serve.json:/config/serve.json:ro + devices: + - /dev/net/tun:/dev/net/tun + cap_add: + - NET_ADMIN + restart: unless-stopped + watchtower: image: containrrr/watchtower:latest container_name: watchtower + # Share the sidecar's netns: tailscale serve proxies :443 to the HTTP API on + # 127.0.0.1:8080. The docker socket is a file mount, unaffected by the netns. + network_mode: service:watchtower-ts + depends_on: + - watchtower-ts restart: unless-stopped environment: - WATCHTOWER_SCHEDULE=0 0 3 * * * # 3am daily (6-field cron: sec min hr dom mon dow) - WATCHTOWER_CLEANUP=true # remove old images after updating - DOCKER_API_VERSION=1.40 # override default 1.25 — incompatible with Docker 29.x + # HTTP API: metrics only (NOT the update endpoint), so the schedule above + # keeps running — only the update API disables periodic polls. Serves + # GET /v1/metrics on :8080, gated by the Bearer token below. + - WATCHTOWER_HTTP_API_METRICS=true + - WATCHTOWER_HTTP_API_TOKEN=${WATCHTOWER_API_TOKEN} volumes: - /var/run/docker.sock:/var/run/docker.sock diff --git a/linux-server/watchtower/ts-serve.json b/linux-server/watchtower/ts-serve.json new file mode 100644 index 0000000..c238e2b --- /dev/null +++ b/linux-server/watchtower/ts-serve.json @@ -0,0 +1,8 @@ +{ + "TCP": { "443": { "HTTPS": true } }, + "Web": { + "${TS_CERT_DOMAIN}:443": { + "Handlers": { "/": { "Proxy": "http://127.0.0.1:8080" } } + } + } +} diff --git a/linux-server/zsh_plugins.txt b/linux-server/zsh_plugins.txt deleted file mode 100644 index 6c7bf58..0000000 --- a/linux-server/zsh_plugins.txt +++ /dev/null @@ -1,15 +0,0 @@ -# ═══════════════════════════════════════════════════════════════ -# ~/.zsh_plugins.txt — antidote plugin list (server override) -# ═══════════════════════════════════════════════════════════════ -# antidote clones these from GitHub and generates a static -# source file. Run `antidote update` to pull latest versions. -# Headless subset of dotfiles/zsh_plugins.txt — no fpath -# completions (compinit runs before antidote load in the server -# zshrc; the desktop long-command notify hook lives in the -# desktop zshrc, which the server override doesn't ship). - -# Fish-style autosuggestions -zsh-users/zsh-autosuggestions - -# Syntax highlighting (load near end — must see all widgets) -zsh-users/zsh-syntax-highlighting diff --git a/linux-server/zshrc.example b/linux-server/zshrc.example deleted file mode 100644 index 8519258..0000000 --- a/linux-server/zshrc.example +++ /dev/null @@ -1,117 +0,0 @@ -# ═══════════════════════════════════════════════════════════════ -# ~/.zshrc — Linux server zsh config (Ubuntu Server) -# ═══════════════════════════════════════════════════════════════ -# Requires: zsh, zsh-antidote, fastfetch, fzf, -# bat (batcat), fd-find (fdfind), eza -# Plugins managed by antidote — see ~/.zsh_plugins.txt -# Install: bash linux-server/setup.sh - - -# ═══════════════════════════════════════════════════════════════ -# PATH -# ═══════════════════════════════════════════════════════════════ -export PATH="$HOME/.local/bin:$PATH" - - -# ═══════════════════════════════════════════════════════════════ -# HISTORY -# ═══════════════════════════════════════════════════════════════ -HISTFILE="$HOME/.zsh_history" -HISTSIZE=10000 -SAVEHIST=10000 -setopt SHARE_HISTORY -setopt HIST_IGNORE_DUPS -setopt HIST_IGNORE_SPACE -setopt HIST_REDUCE_BLANKS - - -# ═══════════════════════════════════════════════════════════════ -# COMPLETION -# ═══════════════════════════════════════════════════════════════ -autoload -Uz compinit && compinit -zstyle ':completion:*' menu select -zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}' -zstyle ':completion:*' list-colors "${(s.:.)LS_COLORS}" - - -# ═══════════════════════════════════════════════════════════════ -# OPTIONS -# ═══════════════════════════════════════════════════════════════ -setopt AUTO_CD -setopt CORRECT -setopt GLOB_DOTS -setopt NO_BEEP - - -# ═══════════════════════════════════════════════════════════════ -# PROMPT (branch · * unstaged · + staged) -# ═══════════════════════════════════════════════════════════════ -autoload -Uz vcs_info -precmd() { vcs_info } -zstyle ':vcs_info:*' enable git -zstyle ':vcs_info:*' check-for-changes true -zstyle ':vcs_info:git:*' stagedstr '%F{green}+%f' -zstyle ':vcs_info:git:*' unstagedstr '%F{red}*%f' -zstyle ':vcs_info:git:*' formats ' (%F{magenta}%b%f%u%c)' -zstyle ':vcs_info:git:*' actionformats ' (%F{magenta}%b%f|%F{yellow}%a%f%u%c)' -setopt PROMPT_SUBST -PROMPT='%F{blue}%n%f%F{white}@%f%F{cyan}%m%f %F{yellow}%~%f${vcs_info_msg_0_} %# ' - - -# ═══════════════════════════════════════════════════════════════ -# ALIASES -# ═══════════════════════════════════════════════════════════════ -alias ls='ls --color=auto' -alias ll='eza -lAh --git' -alias la='eza -A' -alias ..='cd ..' -alias ...='cd ../..' -alias grep='grep --color=auto' -alias df='df -h' -alias du='du -sh' -alias reload='source ~/.zshrc' -command -v fastfetch &>/dev/null && alias cf='clear && fastfetch' - -# Ubuntu binary name shims -alias bat='batcat' -alias fd='fdfind' - -# Git -alias gs='git status' -alias ga='git add' -alias gc='git commit' -alias gp='git push' -alias gl='git log --oneline --graph --decorate' - - -# ═══════════════════════════════════════════════════════════════ -# ENVIRONMENT -# ═══════════════════════════════════════════════════════════════ -export EDITOR='micro' -export LANG='en_US.UTF-8' -export LC_ALL='en_US.UTF-8' - - -# ═══════════════════════════════════════════════════════════════ -# FZF (apt install fzf) -# ═══════════════════════════════════════════════════════════════ -[[ -f /usr/share/doc/fzf/examples/key-bindings.zsh ]] && \ - source /usr/share/doc/fzf/examples/key-bindings.zsh -[[ -f /usr/share/doc/fzf/examples/completion.zsh ]] && \ - source /usr/share/doc/fzf/examples/completion.zsh - - -# ═══════════════════════════════════════════════════════════════ -# SYSINFO DISPLAY (every interactive shell — SSH, tmux panes, clear && zsh) -# ═══════════════════════════════════════════════════════════════ -[[ -o interactive ]] && command -v fastfetch &>/dev/null && fastfetch - - -# ═══════════════════════════════════════════════════════════════ -# ANTIDOTE (apt install zsh-antidote) -# ═══════════════════════════════════════════════════════════════ -# antidote sources all plugins listed in ~/.zsh_plugins.txt -if [[ -f /usr/share/zsh-antidote/antidote.zsh && -f "$HOME/.zsh_plugins.txt" ]]; then - source /usr/share/zsh-antidote/antidote.zsh - antidote load -fi diff --git a/packages.json b/packages.json index 30005a8..65d7cbb 100644 --- a/packages.json +++ b/packages.json @@ -39,7 +39,7 @@ }, { "name": "nvm", - "package_manager": { "macos": "custom", "ubuntu": "custom", "arch": "custom" }, + "package_manager": { "macos": "custom", "ubuntu": "custom", "arch": "custom", "server": "custom" }, "priority": "high", "optional": false, "handled_by_setup": true, @@ -63,11 +63,11 @@ }, { "name": "build-essential", - "package_manager": { "ubuntu": "apt", "arch": "yay" }, + "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, "arch_name": "base-devel", "priority": "high", "optional": false, - "description": "Compiler toolchain — gcc, make, etc.; required for pyenv, native Node modules, and AUR builds" + "description": "Compiler toolchain — gcc, make, etc.; required for pyenv, native Node modules, AUR builds, and cargo install (e.g. railguard)" }, { "name": "git", @@ -85,9 +85,10 @@ }, { "name": "rust", - "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, + "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "ubuntu_name": "rustup", "arch_name": "rustup", + "server_name": "rustup", "priority": "medium", "optional": false, "description": "Rust toolchain — rustup on Linux (distro rustc goes stale; setup runs 'rustup default stable'), plain rust formula on macOS" @@ -163,7 +164,7 @@ }, { "name": "shellcheck", - "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, + "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, "description": "Static analysis linter for shell scripts — used by the pre-commit config and CI" @@ -175,6 +176,16 @@ "optional": false, "description": "Spell checker for source code — catches common misspellings in identifiers, comments, and docs" }, + { + "name": "pytest", + "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, + "ubuntu_name": "python3-pytest", + "arch_name": "python-pytest", + "server_name": "python3-pytest", + "priority": "medium", + "optional": false, + "description": "Python testing framework — used by the post-test-runner.sh hook to auto-run test suites in Python projects" + }, { "name": "swiftlint", "package_manager": { "macos": "brew" }, @@ -405,15 +416,6 @@ "install_command": "curl -fsSL https://get.docker.com | sudo sh", "description": "Container platform — Docker Desktop on macOS, official Docker Engine on Linux" }, - { - "name": "docker-ce", - "package_manager": { "server": "custom" }, - "priority": "medium", - "optional": false, - "handled_by_setup": true, - "install_command": "curl -fsSL https://get.docker.com | sudo sh", - "description": "Container platform — official Docker Engine for Linux; convenience script installs CE, CLI, compose plugin, and containerd" - }, { "name": "claude-code", "package_manager": { "macos": "custom", "ubuntu": "custom", "arch": "custom", "server": "custom" }, diff --git a/platforms/arch.sh b/platforms/arch.sh index f7d00cb..58d729a 100755 --- a/platforms/arch.sh +++ b/platforms/arch.sh @@ -87,5 +87,5 @@ platform_docker_optional() { } platform_main() { - desktop_main + linux_main } diff --git a/platforms/server.sh b/platforms/server.sh index d4e8f90..f3c8721 100755 --- a/platforms/server.sh +++ b/platforms/server.sh @@ -1,72 +1,26 @@ #!/usr/bin/env bash -# Headless server (Ubuntu Server LTS) profile: apt + snap, no GUI packages, -# SSH/Tailscale/Docker services, homepage dashboard stack. +# Headless server (Ubuntu Server LTS) profile: apt + snap, no GUI packages. +# Shares the Linux install spine (linux_main) with the apt desktop — the +# apt/Tailscale/Docker hooks live in lib/core.sh. This module only carries the +# server tier composition and the server-only "step two" infra: SSH/Tailscale +# services, Docker dashboard stacks, cockpit, AdGuard. # (The Raspberry Pi — Debian proper, no snapd — is a future target; TODO.md.) -CONFIG_SRC_DIR="$SETUP_ROOT/linux-server" - -server_apt_install_tier() { - local priority="$1" names - names=$(pkg_names apt "$priority") - [[ -z "$names" ]] && return 0 - # shellcheck disable=SC2086 - run sudo apt install -y $names -} - -platform_main() { +platform_bootstrap() { apt_bootstrap +} - printf '\n==> Installing high-priority packages...\n' - server_apt_install_tier high - - printf '\n==> Installing medium-priority packages...\n' - server_apt_install_tier medium - - printf '\n==> Installing snap packages...\n' - snap_install_tier medium - - if [[ "$INCLUDE_OPTIONAL" == true ]]; then - printf '\n==> Installing optional (low) packages...\n' - server_apt_install_tier low - fi - - setup_bat_fd_symlinks - set_default_shell - - printf '\n' - deploy_zshrc - printf '\n' - deploy_config "$SETUP_ROOT/dotfiles/tmux.conf" "$HOME/.tmux.conf" "tmux.conf" yes - printf '\n' - deploy_config "$CONFIG_SRC_DIR/zsh_plugins.txt" "$HOME/.zsh_plugins.txt" "linux-server/zsh_plugins.txt (platform override)" no - - # Pre-clone the plugins now while network is provably up; otherwise the - # first interactive login does the GitHub clones lazily. +# Pre-clone the antidote plugins now while the network is provably up; otherwise +# the first interactive login does the GitHub clones lazily. +server_preclone_antidote() { printf '\n==> Pre-cloning antidote plugins...\n' run zsh -c 'source /usr/share/zsh-antidote/antidote.zsh && antidote bundle <"$HOME/.zsh_plugins.txt" >/dev/null' \ || printf 'warning: antidote pre-clone failed; plugins will clone on first login\n' >&2 +} - # ── Tailscale ─────────────────────────────────────────────────────────────── - printf '\n' - if ! command -v tailscale &>/dev/null; then - printf '==> Installing Tailscale...\n' - run_eval "$(custom_cmd tailscale)" - else - printf '==> Tailscale already installed (%s)\n' "$(tailscale version | head -1)" - fi - printf " Run 'sudo tailscale up' to authenticate and connect to your Tailnet.\n" - - # ── Docker ────────────────────────────────────────────────────────────────── - printf '\n' - if ! command -v docker &>/dev/null; then - printf '==> Installing Docker...\n' - run_eval "$(custom_cmd docker-ce)" - run sudo usermod -aG docker "$USER" - printf ' Docker installed. Log out and back in for the docker group to take effect.\n' - else - printf '==> Docker already installed (%s)\n' "$(docker --version | head -1)" - fi - +# Server-only "step two": the headless service + dashboard layer that runs after +# the shared base install (packages, shell, dotfiles, Tailscale, Docker engine). +server_extras() { # ── Cockpit ───────────────────────────────────────────────────────────────── printf '\n' if systemctl is-active --quiet cockpit.socket 2>/dev/null; then @@ -90,15 +44,6 @@ platform_main() { fi run loginctl enable-linger "$USER" - # ── claude-code ───────────────────────────────────────────────────────────── - printf '\n' - if ! command -v claude &>/dev/null; then - printf '==> Installing claude-code...\n' - run_eval "$(custom_cmd claude-code)" - else - printf '==> claude-code already installed\n' - fi - # ── AdGuard: free port 53 ─────────────────────────────────────────────────── printf '\n' local resolved_conf="/etc/systemd/resolved.conf" @@ -182,11 +127,9 @@ platform_main() { fi fi done +} - # ── Manual install reminders ──────────────────────────────────────────────── - custom_reminders_section - - # ── Done ──────────────────────────────────────────────────────────────────── +server_footer() { printf '\n' if [[ "$DRY_RUN" == true ]]; then printf 'Dry run complete — nothing was installed.\n' @@ -220,3 +163,7 @@ platform_main() { cat "$CONFIG_SRC_DIR/post-install.md" fi } + +platform_main() { + linux_main +} diff --git a/platforms/ubuntu.sh b/platforms/ubuntu.sh index 1a5b851..84bbe45 100755 --- a/platforms/ubuntu.sh +++ b/platforms/ubuntu.sh @@ -1,69 +1,17 @@ #!/usr/bin/env bash -# Ubuntu/Debian desktop quirks: apt + snap, external apt repos, batcat/fdfind -# symlinks, curl-based Tailscale/Docker installs. +# Ubuntu/Debian desktop quirks: pyenv build deps. The apt bootstrap, install +# tiers, snap, symlinks, and Tailscale/Docker hooks are all shared defaults in +# lib/core.sh. platform_bootstrap() { apt_bootstrap } -apt_install_tier() { - local priority="$1" names - names=$(pkg_names apt "$priority") - [[ -z "$names" ]] && return 0 - # shellcheck disable=SC2086 - run sudo apt install -y $names -} - -platform_install_tier() { - case "$1" in - high) - printf '==> Installing high-priority apt packages...\n' - apt_install_tier high - ;; - medium) - printf '==> Installing medium-priority apt packages...\n' - apt_install_tier medium - printf '\n==> Installing snap packages...\n' - snap_install_tier medium - setup_bat_fd_symlinks - ;; - low) - apt_install_tier low - snap_install_tier low - ;; - esac -} - platform_pyenv_build_deps() { sudo apt install -y libssl-dev libffi-dev libncurses-dev libreadline-dev \ libbz2-dev libsqlite3-dev liblzma-dev zlib1g-dev tk-dev } -platform_tailscale_step() { - printf '\n' - if [[ "$DRY_RUN" == true ]]; then - command -v tailscale &>/dev/null \ - && printf '==> Tailscale already installed\n' \ - || printf ' [dry-run] eval: curl -fsSL https://tailscale.com/install.sh | sudo sh\n' - elif ! command -v tailscale &>/dev/null; then - printf '==> Installing Tailscale...\n' - eval "$(custom_cmd tailscale)" - else - printf '==> Tailscale already installed (%s)\n' "$(tailscale version | head -1)" - fi -} - -platform_docker_optional() { - if ! command -v docker &>/dev/null; then - printf '==> Installing Docker...\n' - run_eval "curl -fsSL https://get.docker.com | sudo sh" - run sudo usermod -aG docker "$USER" - printf ' Log out and back in for the docker group to take effect.\n' - else - printf '==> Docker already installed (%s)\n' "$(docker --version | head -1)" - fi -} - platform_main() { - desktop_main + linux_main }