Files
ubuntu-post-install/CLAUDE.md
T
Claude cf3d4bf6de Extract coturn into a shared service; add Mattermost multi-instance support
Asterisk and Mattermost each used to embed their own dedicated coturn
container (network_mode: host), and their default relay port ranges
overlapped by ~100 UDP ports — running both on one box meant a
coin-flip over which service's active call lost its media relay.

- services/coturn.sh: new shared TURN/STUN relay, one instance for
  every consumer instead of one each. Runs --lt-cred-mech with a
  SQLite user database (not --use-auth-secret — coturn doesn't
  support both auth mechanisms on one instance at once, confirmed via
  coturn's own upstream docs/issues) so each consumer gets its own
  dedicated username/password without stepping on any other's.

- lib/common.sh: ensure_coturn_user() — chain-installs coturn.sh on
  first need (same declare -F guard pattern as the existing
  asterisk -> security-dashboard chaining) and registers/reuses a
  per-consumer credential, mirroring configure_caddy_for_service's
  out-param convention.

- services/asterisk.sh: _asterisk_write_compose gains a
  USE_EMBEDDED_COTURN flag. New installs use the shared service;
  existing installs keep their dedicated coturn exactly as-is on
  every "update" (detected from the existing compose file before
  regenerating it, so a rebuild can never silently drop the container
  its own .env TURN_PASSWORD still points at) and only switch on an
  explicit "fresh" reinstall, with a warning first.

- services/mattermost.sh: same embedded/shared coturn handling, plus
  genuine multi-instance support (separate dir/containers/DB/ports per
  instance, auto-scanned free ports for extras) for real isolation
  between groups, as opposed to Team Edition's built-in Teams feature.
  Calls plugin TURN config switched from the HMAC "TURN Static Auth
  Secret" field to the verified "ICE Servers Configurations" JSON
  field, which accepts the same fixed username/credential shared
  coturn issues. Also fixes a latent bug found while adding proper
  update-mode detection: DB_PASS/MM_SECRET were regenerated on every
  single rerun with no existing-install check at all, silently
  breaking Postgres auth on any reinstall.

- CLAUDE.md: documents the ensure_coturn_user pattern (including the
  auth-mechanism constraint and the embedded-coturn migration-safety
  rule) for any future service that needs TURN.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NQkdAn3iG5A4WoqU9FHMaN
2026-08-04 16:32:08 +00:00

26 KiB
Raw Blame History

CLAUDE.md — ubuntu-post-install contributor guide

Context for adding or modifying services. Read this before touching any service file so the result matches what's already here.

How the system works

setup.sh sources lib/common.sh then globs every services/*.sh file. Each service file self-registers and defines its install function. Nothing in setup.sh needs to change when you add a service — just add the file.

The wizard groups services by category (from register_service), shows a checklist per group, and calls install_<name>() for each selected item. --list, --dry-run, and --unattended all work automatically.

Adding a service — the three-step rule

  1. Create services/<name>.sh (kebab-case filename)
  2. Call register_service at the top of the file
  3. Define install_<name>() — keep hyphens literal in the function name (install_pstn-trunk, not install_pstn_trunk). setup.sh's dispatcher calls install_${name} with no hyphen→underscore conversion, so the function name must match the service name exactly. Confirmed live: a mismatched underscore here produces Service 'x' has no install_x at runtime, not a load-time error.

That's it. The menu picks it up on the next run.

Also update the Services table in README.md — add the service name to the appropriate group row so the README stays current.

Retiring a service name (merging two services)

Deleting services/<name>.sh removes it from the menu, but sudo ./setup.sh <name> then fails outright for anyone with that name in their notes, docs, or shell history. Add the old name to SERVICE_ALIAS in setup.sh instead — run_service resolves it to the surviving service, says so once, and runs that. The alias never gets its own menu entry, which is the whole point.

services/asterisk-digital-ocean.sh was merged into services/asterisk.sh this way: one installer that detects a DigitalOcean droplet (metadata service, with a y/n either way) and applies the droplet-only extras — swapfile, public-FQDN-only flow, hand-built Caddy site block, remote Authelia, Cloud Firewall — behind that one answer. Two lessons worth reusing:

  • Don't rename a live install's directory or containers. New installs land in ~/docker/asterisk with easy-asterisk; a pre-merge droplet keeps ~/docker/asterisk-digital-ocean and easy-asterisk-do, because its Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all name those exact paths. _asterisk_resolve_layout() picks whichever exists, and every sibling service probes both.
  • Check whether a "flavor-specific" behavior was actually flavor-specific. The Asterisk security-logging patch and the logs/full logrotate config were droplet-only purely because that's where they got written first — the Security Dashboard's Security Log and CrowdSec's Asterisk acquisition were silently empty on every home/LAN install as a result. Both now apply everywhere.

The pre-merge installer is parked at attic/asterisk-digital-ocean.sh as a rollback path until the unified one is confirmed on real hardware. attic/ is outside setup.sh's services/*.sh glob, so nothing there registers or runs on its own — see attic/README.md, including why it's a way to get the old script back rather than an undo button. Delete it once the merge is proven; a second copy of the same logic is what the merge existed to remove, and fixes are deliberately not backported into it.

Minimal Docker service template

#!/bin/bash
# services/my-tool.sh — One-line description.
# Part of the modular post-install system (sourced by setup.sh).

register_service my-tool utilities "What it does (My Tool)" 8080

install_my_tool() {
    require_docker || return 1

    local DIR="$DOCKER_DIR/my-tool"

    if [ "$DRY_RUN" = true ]; then
        echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
        return 0
    fi

    mkdir -p "$DIR"
    ensure_docker_dir_ownership "$DIR"
    cd "$DIR" || return 1

    cat > docker-compose.yml << 'EOF'
name: my-tool
services:
  my-tool:
    image: vendor/my-tool:latest
    container_name: my-tool
    restart: unless-stopped
    ports:
      - "8080:8080"
    volumes:
      - ./data:/data
EOF

    configure_caddy_for_service "My Tool" "8080" "my-tool"

    write_readme "$DIR" << 'MD'
# My Tool
Brief description.

## Manage
```bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d

MD

local START=""
prompt_yn "Start My Tool now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
    docker compose up -d \
        && log_success "My Tool started" \
        || log_warning "Start failed — check: docker compose logs"
fi

}


## register_service signature

```bash
register_service <name> <group> "<description>" [port]
  • name — kebab-case, matches the filename and the install_ function
  • group — one of the categories below; determines which menu it appears in
  • description — shown in --list and the menu checklist
  • port — optional; informational only (not used by the framework)

Available globals

Variable Value
DOCKER_DIR ~/docker — parent for all Docker service directories
ACTUAL_USER The non-root user that invoked sudo
ACTUAL_HOME Home directory of ACTUAL_USER
SITE_TZ Timezone from site config, e.g. America/New_York
SITE_DOMAIN Base domain from site config, e.g. example.com
SITE_CADDY_NET Docker network name for Caddy (default: caddy_net)
DRY_RUN true/false — set by --dry-run flag
UNATTENDED true/false — set by --unattended flag

Available helpers (lib/common.sh)

Logging

log_info    "message"   # blue   [INFO]
log_success "message"   # green  [OK]
log_warning "message"   # yellow [WARN]
log_error   "message"   # red    [ERROR]

Prompts — honor UNATTENDED automatically

prompt_yn   "Question? (y/n):" "default_y_or_n" VARNAME
prompt_text "Question? [default]:" "default" VARNAME
prompt_reinstall_mode VARNAME   # sets VARNAME to: update | fresh | cancel

When UNATTENDED=true all three skip the prompt; prompt_yn/prompt_text use their given default, prompt_reinstall_mode always resolves to cancel. See Update vs. fresh reinstall on rerun below for how to use the latter.

Pre-flight

require_root    # exits with an error if not running as root
require_docker  # installs Docker CE + Compose plugin if missing, then returns

Execution and ownership

run_cmd COMMAND [args...]           # no-ops in DRY_RUN, executes otherwise
ensure_docker_dir_ownership DIR...  # chown -R ACTUAL_USER:ACTUAL_USER (skips in DRY_RUN)
generate_password [length]          # alphanumeric random string, default 32 chars
pip_user_install PACKAGE...         # pip3 --user with --break-system-packages on 24.04+

Caddy reverse proxy

configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"]

Prompts the user for a domain, appends a site block to the Caddyfile, and reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument is an optional string inserted verbatim inside the Caddy site block (use it for import authelia or custom matchers).

The function places that block before reverse_proxy in the generated site block — don't reorder this. forward_auth (what import authelia expands to) is the same directive family as reverse_proxy internally, and Caddy doesn't reorder repeats of the same directive within a block; it runs them in the order they're written. reverse_proxy written first would handle and terminate every request immediately, making an auth check written after it dead code that never runs — full bypass regardless of what the auth server's own access-control rules say. Confirmed live: this was the actual cause of a "Caddy proxies fine but Authelia never prompts for login" bug, on a site block that otherwise looked completely correct. If a service builds its own site block instead of using this helper (e.g. services/asterisk.sh does in droplet mode, deliberately — see _asterisk_configure_caddy_public's comment for why), put its auth block first there too.

forward_auth to a remote Authelia over a scheme-qualified URL needs explicit header_up pins. A bare forward_auth authelia:9091 (Authelia on the same Docker network, one hop) is fine relying on Caddy's default X-Forwarded-* headers. But forward_auth https://auth.example.com { ... } (Authelia on a different machine, reached over its own public domain+TLS — see services/asterisk.sh's droplet-mode remote-Authelia prompt) is a second Caddy hop: Caddy rewrites the outgoing request's Host header to auth.example.com so the remote Caddy can route/SNI-match it, and without an override X-Forwarded-Host picks up that rewritten value instead of the original site's host. Confirmed live: Authelia evaluated every protected domain as if the request were for auth.example.com itself (which typically has policy: bypass in access_control.rules so its own login portal isn't gated behind itself) — so every domain behind the remote instance silently passed through with no 2FA prompt, regardless of that domain's own policy. Fix: pin the forwarded headers to the original request explicitly instead of trusting Caddy's default derivation:

forward_auth https://auth.example.com {
    uri /api/authz/forward-auth
    copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
    header_up X-Forwarded-Method {method}
    header_up X-Forwarded-Proto {scheme}
    header_up X-Forwarded-Host {host}
    header_up X-Forwarded-Uri {uri}
}

This only affects the remote-Authelia path — same-machine authelia:9091 snippets (services/authelia.sh) are a single hop and don't need it.

Sets two out-params (not local — read them after the call returns) so the caller can tell whether Caddy actually ended up fronting the service:

CADDY_SERVICE_CONFIGURED   # true/false
CADDY_SERVICE_MODE         # "local" or "remote" (only meaningful if configured)

Use this to skip opening a host firewall port for a service Caddy already fronts locally (it reaches the service over host.docker.internal, not the network) — but still open it when CADDY_SERVICE_MODE is "remote", since a remote Caddy machine needs to reach this host over the network instead. See services/asterisk.sh for the reference pattern: it decides the Caddy question before building firewall rules, not after, so the answer is known in time (in droplet mode it hand-builds its own site block and sets the same flag itself, for the reasons noted above).

UFW enable

ensure_ufw_enabled

Call this after your service has already added its own ufw allow rules — it only flips UFW from inactive to active, it doesn't add rules for you. No-ops if UFW is already active or not installed. Always allows SSH first (reading the real port from sshd_config in case it's non-default) before enabling, so this can't lock out the session running the installer.

Closing a port to the internet without also closing it to Caddy

ufw_allow_from_caddy_net PORT [PROTO]   # PROTO defaults to tcp

When CADDY_SERVICE_MODE is "local" (see above) and you ufw delete allow a port because Caddy fronts it now, don't stop there — UFW rules apply to all interfaces unless scoped, and Caddy's own request to host.docker.internal:PORT is ordinary INPUT-chain traffic arriving over the caddy_net bridge, not the public internet. A bare ufw delete allow blocks that too and silently breaks the service (confirmed live: closing the web admin port outright took Caddy down with it). Call ufw_allow_from_caddy_net right after the delete to re-open the port scoped to just caddy_net's subnet — reachable from Caddy, not from the internet. See services/asterisk.sh for the pattern.

README generation

write_readme "$DIR" << 'MD'
# Title
Content
MD

Writes $DIR/README.md (creates the directory if needed). No-ops in DRY_RUN. Every Docker service should call this so ~/docker/<name>/README.md is self-documenting on the deployed box.

Categories

Group Purpose
base CLI packages installed on every box
homelab Core infrastructure — reverse proxy, auth, intrusion prevention
utilities Self-hosted web apps — budget, DNS, files, monitoring, VPN, etc.
media Media servers, photo backup, disc ripping
cameras NVR and camera tooling (Frigate)
gaming Game servers, cloud gaming (Wolf), emulation
extras Non-Docker tools and scripts
backup Backup solutions

Authelia SSO — which services need it

Some services have their own login screens; others have none and need Caddy to gate them via Authelia.

Protecting more than one apex domain from the same box — same instance, not a second one. services/authelia.sh, re-run against an existing install, offers "Add another protected domain to this instance": it appends a new access_control.rules entry and a new session.cookies entry (both are YAML lists — Authelia natively supports multiple independent cookie scopes) plus a Caddy auth.<domain> portal block for the new domain, all on the same Authelia + Redis container. Each domain gets its own login/session (no cross-domain SSO between them) and shares one user database, without the RAM cost of a second full Authelia+Redis stack — the right choice whenever the domains are going to live on the same machine anyway. See add_authelia_domain() in services/authelia.sh.

Running a genuinely separate instance (e.g. one per machine). services/authelia.sh runs standalone on any box (sudo bash authelia.sh, same pattern as crowdsec.sh) and asterisk.sh already auto-detects a local install (if [ -d "$DOCKER_DIR/authelia" ]), switching from the remote-Authelia forward_auth flow to the local import authelia snippet automatically — so a second, fully independent instance on another machine (e.g. a droplet, for resilience if the first machine goes down) works with no code changes. Use this instead of the same-instance approach above when the two domains are on different machines, not just different domains on one machine.

The one real constraint for genuinely separate instances: Authelia's session cookie is scoped to AUTHELIA_DOMAIN (the apex domain entered at install time) with includeSubDomains-style matching, and the portal itself lives at auth.${AUTHELIA_DOMAIN}. Two independent instances must not share the same AUTHELIA_DOMAIN. If they did, both would try to claim the same auth.<domain> hostname (DNS can only point that at one machine) and the same cookie scope with completely separate session stores — users bouncing between subdomains fronted by different instances would see confusing repeated logins as each instance's cookie gets overwritten/rejected by the other's. Give each instance either a genuinely separate apex domain, or a distinct subdomain tree the other instance doesn't also claim. (This constraint doesn't apply to the same-instance, multiple-domains approach above — each domain there gets its own cookie entry by design, which is exactly what avoids the collision.)

Has built-in auth — no Authelia needed: emby, jellyfin, audiobookshelf, immich, mealie, actualbudget, homeassistant, portainer, meshcentral, traccar, uptimekuma, filebrowser, wg-easy, ntfy (configurable)

No built-in auth — should be protected: magicmirror, wolf-pair, js99er, sky-cam

For services without built-in auth, prompt the user before calling configure_caddy_for_service and pass import authelia as the extra block if Authelia is installed and the user wants SSO protection:

local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
    local _use_auth=""
    prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth
    [[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK="    import authelia"
fi
configure_caddy_for_service "MagicMirror" "8081" "mirror" "$EXTRA_BLOCK"

Authelia "stay logged in" / kiosk mode: Edit ~/docker/authelia/config/configuration.yml and set a long remember_me_duration. Users then check "Remember me" once on login and the session persists through reboots (Redis stores the session in a volume):

session:
  secret: 'your-existing-secret'
  remember_me_duration: 1y     # add or update this line
  expiration: 1h
  inactivity: 5m
  cookies:
    - domain: 'example.com'
      authelia_url: 'https://auth.example.com'

After editing: docker compose -f ~/docker/authelia/docker-compose.yml restart

Non-Docker services

Not everything is a container. For apt-based or git-clonebased services, skip require_docker and the Docker helpers. See services/base.sh (apt packages + Charm repo) and services/crowdsec.sh (official apt repo) as reference patterns.

For non-Docker services the default is_installed check in setup.sh looks for $DOCKER_DIR/$name, which won't exist. Add a case to the is_installed() function in setup.sh so the [installed] marker appears correctly in the menu:

# In setup.sh → is_installed()
my-tool) command -v my-tool >/dev/null 2>&1 ;;

Docker services use the default case and don't need an entry.

DRY_RUN convention

Every install_* function must check $DRY_RUN before touching the filesystem, installing packages, or starting containers. The pattern is:

if [ "$DRY_RUN" = true ]; then
    echo "[DRY-RUN] Would do X"
    echo "[DRY-RUN] Would do Y"
    return 0
fi

Put the check early — after any pure-display output (banners, info text) but before the first write.

Update vs. fresh reinstall on rerun

Every service should detect an existing install at the top of its install_<name>() — after the DRY_RUN check, before any prompts — and offer prompt_reinstall_mode instead of silently re-running every prompt (domain, secrets, firewall, Authelia, extras...) from scratch. What counts as "already installed" is service-specific: usually docker-compose.yml and .env both existing in the service's $DOCKER_DIR/<name> directory.

if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
    local MODE=""
    prompt_reinstall_mode MODE
    case "$MODE" in
        update)
            # Refresh vendor files / config templates, rebuild, done.
            # Do NOT touch .env, firewall rules, or Caddy/Authelia config.
            ...
            return 0
            ;;
        cancel)
            log_info "Leaving the existing install as-is."
            return 0
            ;;
        fresh) ;;  # fall through to the full install flow below
    esac
fi

update should be genuinely non-destructive: refresh whatever the service vendors or templates (Docker image sources, config templates, docker-compose.yml) and rebuild/restart, but never touch .env, firewall rules, or reverse-proxy/SSO config that's already in place. If the vendor-copy or docker-compose.yml-generation logic is more than a few lines, factor it into a helper function so the fresh-install path and the update path share one copy instead of drifting apart — see _asterisk_refresh_vendor_files/_asterisk_write_compose in services/asterisk.sh for the reference pattern.

cancel must leave the install completely untouched — it's the default for a reason (a stray Enter on a service you're just checking on shouldn't trigger anything). fresh runs the exact same flow a first-time install would, prompts included.

Chaining into another service from within your own

A service can call another service's install_<name>() directly as a convenience step at the end of its own flow, instead of making the user remember to separately run sudo ./setup.sh <other-name> afterward. services/asterisk.sh does this for services/security-dashboard.sh and services/pstn-trunk.sh — after Asterisk itself is installed/updated, it asks once whether to also set up the dashboard and/or a PSTN trunk (or, if either is already installed, silently re-invokes it so it gets refreshed as part of the same run — its own prompt_reinstall_mode gate decides update vs. skip, so this never re-asks the target service's detailed prompts unless the user is actually setting it up fresh).

The target service keeps its own register_service call — it stays independently selectable/invocable exactly as before (sudo ./setup.sh pstn-trunk still works standalone). Chaining is purely additive, not a replacement for the target's own entry point, so nothing breaks for anyone already relying on running it directly.

Guard every cross-file call with declare -F, since a service can also run completely standalone (sudo bash asterisk.sh, no setup.sh, no sibling services/*.sh files sourced at all):

if declare -F install_security-dashboard >/dev/null 2>&1; then
    install_security-dashboard
fi

Only chain in one direction, and only when the relationship is genuinely one-way (the target is meaningless without the caller already installed — pstn-trunk.sh itself says so in its own error message when Asterisk isn't present). Don't have both sides call each other.

.env files and secrets

Generate passwords with generate_password (never hardcode them). Write secrets to .env files in the service directory, owned by ACTUAL_USER, permissions 600. Document every variable with a comment in the .env heredoc so the user knows what to change later.

Caddy network wiring

Services that need to reach Caddy (or each other) over Docker networking should join the $SITE_CADDY_NET network. Add to docker-compose.yml:

networks:
  caddy_net:
    external: true
    name: ${CADDY_NET:-caddy_net}

And read the network name from .env using CADDY_NET=$SITE_CADDY_NET.

external: true means this service expects the network to already exist — it doesn't create it. require_docker creates it for you (via ensure_caddy_network in lib/common.sh) the first time any service calls it, so as long as your install_<name>() calls require_docker before docker compose up (it always should), the network is guaranteed to exist regardless of whether Caddy itself has been installed yet.

network_mode: host services (e.g. asterisk) don't join caddy_net at all — Caddy reaching them (or anything else on the host network) needs host.docker.internal:PORT in the Caddyfile, not localhost:PORT or a container name. Caddy's own compose file (services/caddy.sh) sets extra_hosts: host.docker.internal:host-gateway so that hostname resolves; configure_caddy_for_service's bare-port upstream case already does this for you — don't hand-roll localhost:PORT in a Caddy site block.

Shared coturn (TURN/STUN) relay

Any service that needs a TURN server for WebRTC/SIP NAT traversal shares one coturn instance (services/coturn.sh) instead of running its own. This exists because it didn't always: asterisk and mattermost used to each embed a dedicated coturn container (network_mode: host, each with its own relay port range) — confirmed live, their default ranges overlapped by ~100 UDP ports, so running both on one box meant a coin-flip over which service's active call lost its media relay. One shared instance with one port range removes the collision instead of just moving it around.

Use ensure_coturn_user (lib/common.sh), not your own coturn container:

ensure_coturn_user "my-service"
if [ -n "$COTURN_HOST" ]; then
    # Out-params (not `local` — read them after the call returns, same
    # convention as configure_caddy_for_service's CADDY_SERVICE_*):
    #   COTURN_HOST COTURN_PORT COTURN_USERNAME COTURN_PASSWORD
else
    # coturn unavailable (not installed and services/coturn.sh isn't loaded
    # to chain-install it — e.g. this file run fully standalone) — degrade
    # gracefully. Don't block the rest of your install on this.
fi

ensure_coturn_user chain-installs services/coturn.sh the first time any service needs one (guarded with declare -F install_coturn, same pattern as the asterisk → security-dashboard chaining below), then registers a dedicated long-term-credential username/password for your consumer name. The credential is cached in ~/docker/coturn/users/<consumer>.env, so calling this again on a rerun reuses the same credential instead of minting a new one and silently orphaning whatever client already has the old one configured.

Why long-term credentials (--lt-cred-mech), not the REST-API/HMAC mode (--use-auth-secret) some WebRTC apps default to: coturn does not support running both auth mechanisms on one instance at once — enabling --use-auth-secret silently overrides --lt-cred-mech server-wide, which would break every static-credential consumer. --lt-cred-mech supports any number of named users out of the box, which is the actual shape a shared-multi-consumer coturn needs. If the service you're adding only exposes an HMAC-secret TURN setting in its own UI (no plain username/password option), check its docs for an alternative field first — Mattermost's Calls plugin looked HMAC-only at a glance but also accepts a fixed username/credential pair via its "ICE Servers Configurations" JSON field (see services/mattermost.sh for the exact format). Don't fall back to a second coturn instance just because the first field you found expects a shared secret.

Migrating an existing service from its own embedded coturn: don't do it silently. An update rerun must keep whatever coturn shape a service already has — detect the existing embedded container (e.g. grep -q '^ coturn:' docker-compose.yml before regenerating it) and preserve it exactly, the same non-destructive rule as every other update path in this file. Only switch to the shared coturn on an explicit fresh reinstall, and warn before doing it — the TURN username/password changes, and any already-configured client (a SIP phone, a browser session) keeps the old credentials until it's reconfigured. See services/asterisk.sh's USE_EMBEDDED_COTURN handling for the reference pattern.