Merge pull request #265 from outis1one/claude/droplet-capacity-assessment-s2voix
Claude/droplet capacity assessment s2voix
This commit is contained in:
@@ -556,3 +556,64 @@ network) needs `host.docker.internal:PORT` in the Caddyfile, not
|
|||||||
so that hostname resolves; `configure_caddy_for_service`'s bare-port upstream
|
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
|
case already does this for you — don't hand-roll `localhost:PORT` in a
|
||||||
Caddy site block.
|
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:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
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.
|
||||||
|
|||||||
@@ -67,13 +67,13 @@ a ready-to-copy Caddy config snippet to `~/docker/caddy-snippets/`.
|
|||||||
| Group | Services |
|
| Group | Services |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo); Docker CE + Compose plugin; `openssh-server` with GitHub/Launchpad SSH key import, optional password-auth lockdown, and SSH Host aliases; optional NetBird overlay network |
|
||||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` |
|
| `homelab` | `caddy`, `crowdsec`, `authelia`, `coturn` (shared TURN/STUN relay — Asterisk, Mattermost Calls, and future WebRTC-capable services all register a dedicated credential against one instance instead of each running its own), `homeassistant`, `asterisk`, `pstn-trunk`, `sms-inbound`, `security-dashboard`, `sunshine` |
|
||||||
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
|
| `utilities` | `actualbudget`, `ai-gpu`, `ai-stack`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `iopaint`, `joplin`, `koha`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `paintplus`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
|
||||||
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||||
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
| `gaming` | `drum-rhythm-game`, `js99er`, `kyber-launcher`, `kyber-server`, `minecraft`, `wolf`, `wolf-pair` |
|
||||||
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `sync-cc` |
|
| `extras` | `kdeconnect`, `silent-send`, `ssh-config`, `sync-cc` |
|
||||||
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency); `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
|
| `backup` | `backup` — complete recovery: entire `~/docker/<service>/` for every service via Kopia (Minecraft: flush+snap, no downtime; others: stop/snap/start for DB consistency), optional offsite mirror (`kopia repository sync-to`), plus `dr_bringup.sh` — unattended restore-everything-and-start for standing up a cold spare box; `borg-backup` — same coverage via Borg (chunk dedup, SSH remote repos, Borgmatic/Vorta compatible); `gaming-backup` — frequent game-save snapshots (Minecraft world data, emulator saves, Steam — no downtime, run hourly) |
|
||||||
|
|
||||||
Run `./setup.sh --list` to see descriptions.
|
Run `./setup.sh --list` to see descriptions.
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,29 @@ if [ "${REMOTE_TYPE:-none}" != "none" ] && [ -n "${REMOTE_TYPE:-}" ]; then
|
|||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Keep a spare box's copy of backup.conf + README current ─────────────────
|
||||||
|
# Runs after the data itself is backed up (and mirrored, if configured) so a
|
||||||
|
# sync never ships config pointing at a repo state that isn't actually there
|
||||||
|
# yet. dr_bringup.sh on the spare only needs these two small files — the
|
||||||
|
# repo data itself already lives wherever REMOTE_TYPE mirrored it (or is
|
||||||
|
# local, if the spare IS that target).
|
||||||
|
if [ -n "${DR_SYNC_HOST:-}" ]; then
|
||||||
|
_dr_path="${DR_SYNC_PATH:-~/docker/backup}"
|
||||||
|
log "Syncing backup.conf + README to spare ($DR_SYNC_HOST:$_dr_path)..."
|
||||||
|
_dr_files=("$CONF")
|
||||||
|
[ -f "$HERE/README.md" ] && _dr_files+=("$HERE/README.md")
|
||||||
|
if ssh -o BatchMode=yes -o ConnectTimeout=10 "$DR_SYNC_HOST" "mkdir -p '$_dr_path'" 2>"$_ERR" \
|
||||||
|
&& scp -o BatchMode=yes -o ConnectTimeout=10 "${_dr_files[@]}" "$DR_SYNC_HOST:$_dr_path/" 2>>"$_ERR" \
|
||||||
|
&& ssh -o BatchMode=yes -o ConnectTimeout=10 "$DR_SYNC_HOST" "chmod 600 '$_dr_path/backup.conf'" 2>>"$_ERR"; then
|
||||||
|
log "OK spare sync ($DR_SYNC_HOST)"
|
||||||
|
else
|
||||||
|
_reason="$(categorize_error "$(cat "$_ERR")")"
|
||||||
|
log "WARNING: spare sync failed — $_reason"
|
||||||
|
FAILED_SVCS+=("spare-sync: $_reason")
|
||||||
|
rc=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
DURATION=$(( $(date +%s) - START_TS ))
|
DURATION=$(( $(date +%s) - START_TS ))
|
||||||
DURATION_STR="$((DURATION/60))m $((DURATION%60))s"
|
DURATION_STR="$((DURATION/60))m $((DURATION%60))s"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,242 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# extras/dr_bringup_kopia.sh — non-interactive disaster-recovery bring-up.
|
||||||
|
# Installed to ~/docker/backup/dr_bringup.sh by the backup service installer.
|
||||||
|
#
|
||||||
|
# Restores the LATEST snapshot of every backed-up service (or one chosen
|
||||||
|
# service) straight into place and brings it up with `docker compose up -d`.
|
||||||
|
# Meant to run unattended on a cold spare box during a real outage — unlike
|
||||||
|
# restore_kopia.sh (one service at a time, interactive prompts per step),
|
||||||
|
# this walks every discovered service with no prompts so it can complete a
|
||||||
|
# full-stack recovery in one command.
|
||||||
|
#
|
||||||
|
# sudo ./dr_bringup.sh restore + start every service
|
||||||
|
# sudo ./dr_bringup.sh --service NAME restore + start one service
|
||||||
|
# sudo ./dr_bringup.sh --list list restorable sources and exit
|
||||||
|
# sudo ./dr_bringup.sh --dry-run show what would happen, touch nothing
|
||||||
|
# sudo ./dr_bringup.sh --no-start restore only, skip docker compose up -d
|
||||||
|
#
|
||||||
|
# Reads backup.conf from the same directory. On the spare box this file
|
||||||
|
# won't exist yet on its own — copy it over from the primary box first (it
|
||||||
|
# holds the repository paths/passwords needed to connect):
|
||||||
|
# scp primary:~/docker/backup/backup.conf ~/docker/backup/backup.conf
|
||||||
|
# If the destination repo is a local path shared with the primary (e.g. the
|
||||||
|
# spare box IS the box the primary's REMOTE_TYPE=sftp mirror targets),
|
||||||
|
# nothing else is needed — the repo data is already there.
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
CONF="${BACKUP_CONF:-$HERE/backup.conf}"
|
||||||
|
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
|
||||||
|
die() { err "$*"; exit 1; }
|
||||||
|
|
||||||
|
# ── Preflight ─────────────────────────────────────────────────────────────────
|
||||||
|
[ "${EUID:-$(id -u)}" -eq 0 ] || die "Run as root: sudo $0"
|
||||||
|
[ -f "$CONF" ] || die "backup.conf not found: $CONF — copy it from the primary box first."
|
||||||
|
command -v jq >/dev/null 2>&1 || die "jq is required — install it: sudo apt install jq"
|
||||||
|
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$CONF"
|
||||||
|
|
||||||
|
# gaming-backup's single-dest conf format normalises the same way restore_kopia.sh does.
|
||||||
|
if [ -z "${DEST_NAMES:-}" ]; then
|
||||||
|
DEST_NAMES="default"
|
||||||
|
DEST_default_CONFIG="${KOPIA_CONFIG:-}"
|
||||||
|
DEST_default_PASSWORD="${KOPIA_PASSWORD:-}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
command -v "$KOPIA" >/dev/null 2>&1 || die "Kopia not found: $KOPIA"
|
||||||
|
command -v docker >/dev/null 2>&1 || die "Docker not found — run this repo's post-install (base + require_docker) on this box first."
|
||||||
|
|
||||||
|
ACTUAL_USER="${SUDO_USER:-${USER:-$(id -un)}}"
|
||||||
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "/home/$ACTUAL_USER")"
|
||||||
|
DOCKER_BASE="$ACTUAL_HOME/docker"
|
||||||
|
|
||||||
|
# ── Args ──────────────────────────────────────────────────────────────────────
|
||||||
|
DO_LIST=false DRY=false START=true ONLY_SVC=""
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--list) DO_LIST=true; shift ;;
|
||||||
|
--dry-run) DRY=true; shift ;;
|
||||||
|
--no-start) START=false; shift ;;
|
||||||
|
--service) ONLY_SVC="${2:-}"; shift 2 ;;
|
||||||
|
*) die "Unknown argument: $1 (see --help by reading the script header)" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Bounded so a stalled repo connection (offsite mirror host unreachable
|
||||||
|
# mid-restore, etc.) can't hang forever and block every service behind it in
|
||||||
|
# the batch — 300s is generous for this stack's data sizes; raise it if a
|
||||||
|
# service's dataset genuinely needs longer.
|
||||||
|
k_for() {
|
||||||
|
local dest="$1"; shift
|
||||||
|
local cfg_var="DEST_${dest}_CONFIG" pw_var="DEST_${dest}_PASSWORD"
|
||||||
|
local cfg="${!cfg_var:-}" pw="${!pw_var:-}"
|
||||||
|
[ -n "$cfg" ] || return 1
|
||||||
|
timeout 300 env KOPIA_PASSWORD="$pw" "$KOPIA" --config-file="$cfg" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Discover the latest restorable snapshot per service, across every
|
||||||
|
# destination — no dependency on backup.conf's SVC_<name> map, which only
|
||||||
|
# says where a *new* backup should go, not where past snapshots actually
|
||||||
|
# landed (relevant if a service was ever reassigned between destinations).
|
||||||
|
read -ra _DEST_ARR <<< "$DEST_NAMES"
|
||||||
|
declare -a SRC_PATH_LIST=() SRC_DEST_LIST=() SRC_SNAP_LIST=()
|
||||||
|
|
||||||
|
for dest in "${_DEST_ARR[@]}"; do
|
||||||
|
if ! k_for "$dest" repository status >/dev/null 2>&1; then
|
||||||
|
warn "Cannot connect to destination '$dest' — skipping."
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
SNAP_JSON="$(k_for "$dest" snapshot list --all --json 2>/dev/null)"
|
||||||
|
[ -z "$SNAP_JSON" ] && continue
|
||||||
|
[ "$SNAP_JSON" = "null" ] && continue
|
||||||
|
|
||||||
|
mapfile -t _paths < <(echo "$SNAP_JSON" | jq -r --arg base "$DOCKER_BASE/" \
|
||||||
|
'[.[] | select(.source.path | startswith($base))] | group_by(.source.path)[] | .[0].source.path')
|
||||||
|
|
||||||
|
for p in "${_paths[@]}"; do
|
||||||
|
svc="$(basename "$p")"
|
||||||
|
[ -n "$ONLY_SVC" ] && [ "$svc" != "$ONLY_SVC" ] && continue
|
||||||
|
|
||||||
|
# First destination to claim a service name wins (DEST_NAMES always
|
||||||
|
# lists "default" first — see backup.sh) so a stale duplicate in a
|
||||||
|
# second repo can't shadow the current one.
|
||||||
|
_dupe=false
|
||||||
|
for _seen in "${SRC_PATH_LIST[@]:-}"; do
|
||||||
|
[ "$(basename "$_seen")" = "$svc" ] && _dupe=true && break
|
||||||
|
done
|
||||||
|
[ "$_dupe" = true ] && continue
|
||||||
|
|
||||||
|
latest_id="$(echo "$SNAP_JSON" | jq -r --arg p "$p" \
|
||||||
|
'[.[] | select(.source.path == $p)] | sort_by(.startTime) | reverse | .[0].id')"
|
||||||
|
[ -z "$latest_id" ] && continue
|
||||||
|
[ "$latest_id" = "null" ] && continue
|
||||||
|
|
||||||
|
SRC_PATH_LIST+=("$p")
|
||||||
|
SRC_DEST_LIST+=("$dest")
|
||||||
|
SRC_SNAP_LIST+=("$latest_id")
|
||||||
|
done
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔═══════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Kopia Disaster-Recovery Bring-Up ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════╝"
|
||||||
|
|
||||||
|
if [ "$DO_LIST" = true ]; then
|
||||||
|
echo ""
|
||||||
|
[ "${#SRC_PATH_LIST[@]}" -eq 0 ] && { warn "No restorable sources found."; exit 0; }
|
||||||
|
printf " %-16s %-10s %s\n" "SERVICE" "DEST" "PATH"
|
||||||
|
for i in "${!SRC_PATH_LIST[@]}"; do
|
||||||
|
printf " %-16s %-10s %s\n" "$(basename "${SRC_PATH_LIST[$i]}")" "${SRC_DEST_LIST[$i]}" "${SRC_PATH_LIST[$i]}"
|
||||||
|
done
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#SRC_PATH_LIST[@]}" -eq 0 ]; then
|
||||||
|
if [ -n "$ONLY_SVC" ]; then
|
||||||
|
die "No snapshots found for service '$ONLY_SVC'."
|
||||||
|
else
|
||||||
|
die "No snapshots found. Run a backup on the primary box first, then copy backup.conf here."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Restore + start ───────────────────────────────────────────────────────────
|
||||||
|
TOTAL_START=$(date +%s)
|
||||||
|
declare -a UP_SVCS=() FAILED_SVCS=()
|
||||||
|
|
||||||
|
for i in "${!SRC_PATH_LIST[@]}"; do
|
||||||
|
path="${SRC_PATH_LIST[$i]}"
|
||||||
|
dest="${SRC_DEST_LIST[$i]}"
|
||||||
|
snap="${SRC_SNAP_LIST[$i]}"
|
||||||
|
svc="$(basename "$path")"
|
||||||
|
compose="${path%/}/docker-compose.yml"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
info "── $svc (dest: $dest, snapshot: ${snap:0:12}...) ──"
|
||||||
|
|
||||||
|
if [ "$DRY" = true ]; then
|
||||||
|
echo " [DRY-RUN] Would restore → $path"
|
||||||
|
[ "$START" = true ] && echo " [DRY-RUN] Would run: docker compose -f $compose up -d"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
SVC_START=$(date +%s)
|
||||||
|
|
||||||
|
if [ -e "$path" ]; then
|
||||||
|
aside="${path%/}.pre-dr-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
mv "$path" "$aside"
|
||||||
|
info " Existing data moved aside → $(basename "$aside")"
|
||||||
|
fi
|
||||||
|
mkdir -p "$path"
|
||||||
|
|
||||||
|
if ! k_for "$dest" restore "$snap" "$path"; then
|
||||||
|
err " Restore failed (or timed out) for $svc"
|
||||||
|
FAILED_SVCS+=("$svc: restore failed or timed out")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$path" 2>/dev/null || true
|
||||||
|
ok " Restored"
|
||||||
|
|
||||||
|
if [ "$START" = true ]; then
|
||||||
|
if [ ! -f "$compose" ]; then
|
||||||
|
warn " No docker-compose.yml at $path — restored but not started"
|
||||||
|
FAILED_SVCS+=("$svc: no compose file")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Bounded so a stuck image pull or a compose file waiting on something
|
||||||
|
# (e.g. an interactive prompt) can't stall the rest of the batch — one
|
||||||
|
# bad service should never cost the others their spot in the 10-minute
|
||||||
|
# budget this script exists for.
|
||||||
|
if timeout 120 docker compose -f "$compose" up -d 2>/dev/null; then
|
||||||
|
ok " Started"
|
||||||
|
else
|
||||||
|
err " docker compose up -d failed (or timed out) for $svc"
|
||||||
|
FAILED_SVCS+=("$svc: compose up failed or timed out")
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
SVC_ELAPSED=$(( $(date +%s) - SVC_START ))
|
||||||
|
UP_SVCS+=("$svc (${SVC_ELAPSED}s)")
|
||||||
|
done
|
||||||
|
|
||||||
|
TOTAL_ELAPSED=$(( $(date +%s) - TOTAL_START ))
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════════"
|
||||||
|
if [ "$DRY" = true ]; then
|
||||||
|
echo " DRY-RUN COMPLETE — nothing was touched"
|
||||||
|
else
|
||||||
|
echo " DISASTER-RECOVERY BRING-UP COMPLETE"
|
||||||
|
fi
|
||||||
|
echo "═══════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
[ "$DRY" = false ] && echo " Total time: $((TOTAL_ELAPSED/60))m $((TOTAL_ELAPSED%60))s" && echo ""
|
||||||
|
|
||||||
|
if [ "${#UP_SVCS[@]}" -gt 0 ]; then
|
||||||
|
echo " Up:"
|
||||||
|
for s in "${UP_SVCS[@]}"; do echo " ✓ $s"; done
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#FAILED_SVCS[@]}" -gt 0 ]; then
|
||||||
|
echo " Failed (skipped — did not stop the rest of the batch):"
|
||||||
|
for s in "${FAILED_SVCS[@]}"; do echo " ✗ $s"; done
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# One bad service is a partial success, not a failed run — the whole point of
|
||||||
|
# this script is getting as much of the stack back up as possible. Only a
|
||||||
|
# fully empty result (nothing came up at all) counts as a failed exit code.
|
||||||
|
if [ "$DRY" = false ] && [ "${#UP_SVCS[@]}" -eq 0 ]; then
|
||||||
|
err "Nothing came up."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -698,3 +698,82 @@ CADDY_BLOCK
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Shared coturn (TURN/STUN) wiring ──────────────────────────────────────────
|
||||||
|
# Usage: ensure_coturn_user "<consumer-name>"
|
||||||
|
#
|
||||||
|
# Installs the shared coturn service (services/coturn.sh) if this is the
|
||||||
|
# first service on the box that needs TURN, then registers (or reuses) a
|
||||||
|
# dedicated long-term-credential user for the caller — one coturn instance,
|
||||||
|
# one relay port range, shared by every consumer instead of each service
|
||||||
|
# running its own and fighting over host ports (see services/coturn.sh's
|
||||||
|
# header for why that used to be a real, confirmed-live problem).
|
||||||
|
#
|
||||||
|
# Out-params (not `local` — read them after the call returns), same
|
||||||
|
# convention as configure_caddy_for_service's CADDY_SERVICE_* above:
|
||||||
|
# COTURN_HOST host/IP TURN clients should connect to
|
||||||
|
# COTURN_PORT coturn's listening port
|
||||||
|
# COTURN_USERNAME this consumer's long-term-credential username
|
||||||
|
# COTURN_PASSWORD this consumer's long-term-credential password
|
||||||
|
# COTURN_HOST is left empty if coturn couldn't be installed or reached —
|
||||||
|
# callers should treat that as "no TURN available" and degrade gracefully,
|
||||||
|
# same as checking CADDY_SERVICE_CONFIGURED after configure_caddy_for_service.
|
||||||
|
#
|
||||||
|
# Credentials are cached per-consumer in coturn's own users/<name>.env so a
|
||||||
|
# service re-running its own installer reuses the same one instead of
|
||||||
|
# minting a new credential and orphaning the old one (which would silently
|
||||||
|
# break already-configured clients still holding it).
|
||||||
|
ensure_coturn_user() {
|
||||||
|
local _consumer="$1"
|
||||||
|
COTURN_HOST="" COTURN_PORT="" COTURN_USERNAME="" COTURN_PASSWORD=""
|
||||||
|
|
||||||
|
if [ ! -d "$DOCKER_DIR/coturn" ]; then
|
||||||
|
if declare -F install_coturn >/dev/null 2>&1; then
|
||||||
|
log_info "No shared coturn (TURN/STUN) server yet — setting one up for $_consumer..."
|
||||||
|
install_coturn || { log_warning "coturn setup failed — $_consumer will run without TURN."; return 1; }
|
||||||
|
else
|
||||||
|
log_warning "services/coturn.sh not loaded — $_consumer will run without TURN."
|
||||||
|
log_warning "Run: sudo ./setup.sh coturn"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would register coturn user '$_consumer'"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local _env="$DOCKER_DIR/coturn/.env"
|
||||||
|
[ -f "$_env" ] || { log_warning "coturn installed but $_env missing — cannot register '$_consumer'."; return 1; }
|
||||||
|
local _realm _host _port
|
||||||
|
_realm="$(grep '^COTURN_REALM=' "$_env" | cut -d= -f2-)"
|
||||||
|
_host="$(grep '^COTURN_HOST=' "$_env" | cut -d= -f2-)"
|
||||||
|
_port="$(grep '^COTURN_PORT=' "$_env" | cut -d= -f2-)"; _port="${_port:-3478}"
|
||||||
|
|
||||||
|
local _userdir="$DOCKER_DIR/coturn/users"
|
||||||
|
local _userfile="$_userdir/${_consumer}.env"
|
||||||
|
mkdir -p "$_userdir"
|
||||||
|
|
||||||
|
if [ -f "$_userfile" ]; then
|
||||||
|
local _u _p
|
||||||
|
_u="$(grep '^COTURN_USER=' "$_userfile" | cut -d= -f2-)"
|
||||||
|
_p="$(grep '^COTURN_PASS=' "$_userfile" | cut -d= -f2-)"
|
||||||
|
COTURN_USERNAME="$_u" COTURN_PASSWORD="$_p"
|
||||||
|
else
|
||||||
|
COTURN_USERNAME="$_consumer"
|
||||||
|
COTURN_PASSWORD="$(generate_password 24)"
|
||||||
|
if docker exec coturn turnadmin -a -u "$COTURN_USERNAME" -p "$COTURN_PASSWORD" \
|
||||||
|
-r "$_realm" -b /var/lib/coturn/turndb >/dev/null 2>&1; then
|
||||||
|
{ echo "COTURN_USER=$COTURN_USERNAME"; echo "COTURN_PASS=$COTURN_PASSWORD"; } > "$_userfile"
|
||||||
|
chmod 600 "$_userfile"
|
||||||
|
log_success "Registered coturn user '$COTURN_USERNAME' for $_consumer"
|
||||||
|
else
|
||||||
|
log_warning "Could not register a coturn user for $_consumer — is the coturn container running?"
|
||||||
|
COTURN_USERNAME="" COTURN_PASSWORD=""
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
COTURN_HOST="$_host"
|
||||||
|
COTURN_PORT="$_port"
|
||||||
|
}
|
||||||
|
|||||||
+133
-58
@@ -232,7 +232,7 @@ CBLOCK
|
|||||||
fi
|
fi
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (intercom/VoIP; auto-tunes for a DigitalOcean droplet)" 5061
|
register_service asterisk homelab "Easy Asterisk PBX (intercom/VoIP; auto-tunes for a DigitalOcean droplet); TURN via the shared coturn service" 5061
|
||||||
|
|
||||||
# ── Install layout: directory + container names ────────────────────────────
|
# ── Install layout: directory + container names ────────────────────────────
|
||||||
# Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT.
|
# Sets ASTERISK_DIR / ASTERISK_CONTAINER / ASTERISK_COTURN / ASTERISK_PROJECT.
|
||||||
@@ -795,9 +795,52 @@ _asterisk_offer_dashboard_and_trunk() {
|
|||||||
# literally (it interpolates them from .env, this script must not). Project
|
# literally (it interpolates them from .env, this script must not). Project
|
||||||
# and container names are therefore substituted afterwards, same placeholder
|
# and container names are therefore substituted afterwards, same placeholder
|
||||||
# trick the Caddy volume line already uses below.
|
# trick the Caddy volume line already uses below.
|
||||||
|
#
|
||||||
|
# USE_EMBEDDED_COTURN controls whether this install runs its own dedicated
|
||||||
|
# coturn container (legacy shape) or relies on the shared coturn service
|
||||||
|
# (services/coturn.sh) instead. This is NOT a free choice at every call site
|
||||||
|
# — an install that already has its own embedded coturn must keep getting
|
||||||
|
# one on every "update" regeneration of this file, or the next `docker
|
||||||
|
# compose up` silently drops the container its own .env TURN_PASSWORD still
|
||||||
|
# points at, breaking every already-configured phone with no warning. See
|
||||||
|
# the two call sites below for how each decides.
|
||||||
_asterisk_write_compose() {
|
_asterisk_write_compose() {
|
||||||
local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3"
|
local PROJECT="$1" CONTAINER="$2" COTURN_CONTAINER="$3" USE_EMBEDDED_COTURN="${4:-true}"
|
||||||
cat > docker-compose.yml << 'EOF'
|
|
||||||
|
local _COTURN_DEPENDS=" depends_on:
|
||||||
|
coturn:
|
||||||
|
condition: service_started
|
||||||
|
"
|
||||||
|
local _COTURN_SERVICE="
|
||||||
|
coturn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: COTURN_CONTAINER_PLACEHOLDER
|
||||||
|
network_mode: host
|
||||||
|
user: root
|
||||||
|
entrypoint: [\"/coturn-entrypoint.sh\"]
|
||||||
|
volumes:
|
||||||
|
- ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro
|
||||||
|
env_file: .env
|
||||||
|
command:
|
||||||
|
- -n
|
||||||
|
- --listening-port=\${TURN_PORT:-3478}
|
||||||
|
- --listening-ip=0.0.0.0
|
||||||
|
- --fingerprint
|
||||||
|
- --lt-cred-mech
|
||||||
|
- --user=\${TURN_USERNAME:-easyasterisk}:\${TURN_PASSWORD}
|
||||||
|
- --realm=\${DOMAIN_NAME:-localhost}
|
||||||
|
- --min-port=49152
|
||||||
|
- --max-port=49252
|
||||||
|
- --no-tls
|
||||||
|
- --no-dtls
|
||||||
|
- --no-cli
|
||||||
|
- --no-multicast-peers
|
||||||
|
- --log-file=stdout
|
||||||
|
restart: unless-stopped
|
||||||
|
"
|
||||||
|
[[ "$USE_EMBEDDED_COTURN" != true ]] && _COTURN_DEPENDS="" && _COTURN_SERVICE=""
|
||||||
|
|
||||||
|
cat > docker-compose.yml << EOF
|
||||||
name: PROJECT_NAME_PLACEHOLDER
|
name: PROJECT_NAME_PLACEHOLDER
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -805,10 +848,7 @@ services:
|
|||||||
build: .
|
build: .
|
||||||
container_name: ASTERISK_CONTAINER_PLACEHOLDER
|
container_name: ASTERISK_CONTAINER_PLACEHOLDER
|
||||||
network_mode: host
|
network_mode: host
|
||||||
depends_on:
|
${_COTURN_DEPENDS} volumes:
|
||||||
coturn:
|
|
||||||
condition: service_started
|
|
||||||
volumes:
|
|
||||||
- ./config/asterisk:/etc/asterisk
|
- ./config/asterisk:/etc/asterisk
|
||||||
- ./config/easy-asterisk:/etc/easy-asterisk
|
- ./config/easy-asterisk:/etc/easy-asterisk
|
||||||
- ./logs:/var/log/asterisk
|
- ./logs:/var/log/asterisk
|
||||||
@@ -824,33 +864,7 @@ CADDY_VOLUME_PLACEHOLDER
|
|||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
${_COTURN_SERVICE}
|
||||||
coturn:
|
|
||||||
image: coturn/coturn:latest
|
|
||||||
container_name: COTURN_CONTAINER_PLACEHOLDER
|
|
||||||
network_mode: host
|
|
||||||
user: root
|
|
||||||
entrypoint: ["/coturn-entrypoint.sh"]
|
|
||||||
volumes:
|
|
||||||
- ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro
|
|
||||||
env_file: .env
|
|
||||||
command:
|
|
||||||
- -n
|
|
||||||
- --listening-port=${TURN_PORT:-3478}
|
|
||||||
- --listening-ip=0.0.0.0
|
|
||||||
- --fingerprint
|
|
||||||
- --lt-cred-mech
|
|
||||||
- --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD}
|
|
||||||
- --realm=${DOMAIN_NAME:-localhost}
|
|
||||||
- --min-port=49152
|
|
||||||
- --max-port=49252
|
|
||||||
- --no-tls
|
|
||||||
- --no-dtls
|
|
||||||
- --no-cli
|
|
||||||
- --no-multicast-peers
|
|
||||||
- --log-file=stdout
|
|
||||||
restart: unless-stopped
|
|
||||||
|
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
sed -i "s#PROJECT_NAME_PLACEHOLDER#${PROJECT}#; \
|
sed -i "s#PROJECT_NAME_PLACEHOLDER#${PROJECT}#; \
|
||||||
@@ -859,8 +873,10 @@ EOF
|
|||||||
|
|
||||||
# Share Caddy's cert store (read-only) so the entrypoint can auto-sync a
|
# Share Caddy's cert store (read-only) so the entrypoint can auto-sync a
|
||||||
# real Let's Encrypt cert for DOMAIN_NAME instead of falling back to
|
# real Let's Encrypt cert for DOMAIN_NAME instead of falling back to
|
||||||
# self-signed. No-op if Caddy isn't installed on this box.
|
# self-signed. No-op if Caddy isn't installed on this box. Only relevant
|
||||||
if [[ -d "$DOCKER_DIR/caddy/data" ]]; then
|
# to the embedded coturn — the shared coturn service doesn't do TLS/TURNS
|
||||||
|
# at all (see services/coturn.sh's README for that tradeoff).
|
||||||
|
if [[ "$USE_EMBEDDED_COTURN" == true && -d "$DOCKER_DIR/caddy/data" ]]; then
|
||||||
sed -i "s#CADDY_VOLUME_PLACEHOLDER# - ${DOCKER_DIR}/caddy/data:/caddy-data:ro#" docker-compose.yml
|
sed -i "s#CADDY_VOLUME_PLACEHOLDER# - ${DOCKER_DIR}/caddy/data:/caddy-data:ro#" docker-compose.yml
|
||||||
else
|
else
|
||||||
sed -i "/CADDY_VOLUME_PLACEHOLDER/d" docker-compose.yml
|
sed -i "/CADDY_VOLUME_PLACEHOLDER/d" docker-compose.yml
|
||||||
@@ -1145,7 +1161,9 @@ _asterisk_configure_do_cloud_firewall() {
|
|||||||
# the two deployment shapes can't document themselves differently by accident.
|
# the two deployment shapes can't document themselves differently by accident.
|
||||||
_asterisk_write_readme() {
|
_asterisk_write_readme() {
|
||||||
local EA_DIR="$1" CONTAINER="$2" IS_DO="$3" DOMAIN_NAME="$4" PUBLIC_IP="$5" WEB_ADMIN_PORT_VAL="$6"
|
local EA_DIR="$1" CONTAINER="$2" IS_DO="$3" DOMAIN_NAME="$4" PUBLIC_IP="$5" WEB_ADMIN_PORT_VAL="$6"
|
||||||
|
local USE_EMBEDDED_COTURN="${7:-true}" TURN_USERNAME_VAL="${8:-easyasterisk}" TURN_SERVER_DISPLAY="${9:-}"
|
||||||
local _host="${DOMAIN_NAME:-${PUBLIC_IP:-<host-ip>}}"
|
local _host="${DOMAIN_NAME:-${PUBLIC_IP:-<host-ip>}}"
|
||||||
|
[ -z "$TURN_SERVER_DISPLAY" ] && TURN_SERVER_DISPLAY="${_host}:3478"
|
||||||
|
|
||||||
{
|
{
|
||||||
cat << MD
|
cat << MD
|
||||||
@@ -1186,10 +1204,14 @@ connecting a phone. The Security Dashboard's Extensions tab
|
|||||||
|-----------------|--------------------------------------|
|
|-----------------|--------------------------------------|
|
||||||
| SIP server | \`${_host}\` |
|
| SIP server | \`${_host}\` |
|
||||||
| SIP port | 5061 (TLS) / 5060 (UDP) |
|
| SIP port | 5061 (TLS) / 5060 (UDP) |
|
||||||
| TURN server | \`${_host}:3478\` |
|
| TURN server | \`${TURN_SERVER_DISPLAY}\` |
|
||||||
| TURN username | easyasterisk |
|
| TURN username | ${TURN_USERNAME_VAL} |
|
||||||
| TURN password | see \`.env\` → \`TURN_PASSWORD\` |
|
| TURN password | see \`.env\` → \`TURN_PASSWORD\` |
|
||||||
|
|
||||||
|
$( [[ "$USE_EMBEDDED_COTURN" == true ]] \
|
||||||
|
&& echo "This install runs its own dedicated coturn container (the \`coturn:\` service in docker-compose.yml)." \
|
||||||
|
|| echo "TURN is served by the box's shared coturn service, not a container in this compose file — see \`~/docker/coturn/README.md\`. Every service on the box that needs TURN (Mattermost Calls, etc.) shares this same relay, each with its own dedicated username." )
|
||||||
|
|
||||||
Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and
|
Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave, and
|
||||||
[Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP +
|
[Sipnetic](https://www.sipnetic.com/) on Android (free, TLS/SRTP +
|
||||||
STUN/TURN/ICE). For a phone to work the same way regardless of network (LAN,
|
STUN/TURN/ICE). For a phone to work the same way regardless of network (LAN,
|
||||||
@@ -1403,7 +1425,11 @@ install_asterisk() {
|
|||||||
echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin"
|
echo "[DRY-RUN] - offer local OR remote Authelia to protect the web admin"
|
||||||
echo "[DRY-RUN] - offer to create a DigitalOcean Cloud Firewall via doctl"
|
echo "[DRY-RUN] - offer to create a DigitalOcean Cloud Firewall via doctl"
|
||||||
echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)"
|
echo "[DRY-RUN] Would scan for a free web admin port starting at 8081 (avoids e.g. CrowdSec's 8080)"
|
||||||
echo "[DRY-RUN] Would open UFW ports: 5060, 5061, <web admin port>, 8088, 8089, 3478, 10000-20000, 49152-49252"
|
echo "[DRY-RUN] Would register a TURN user with the shared coturn service (chain-installing it"
|
||||||
|
echo "[DRY-RUN] if this is the first service on the box that needs one), falling back to"
|
||||||
|
echo "[DRY-RUN] Asterisk's own dedicated coturn if the shared service is unavailable"
|
||||||
|
echo "[DRY-RUN] Would open UFW ports: 5060, 5061, <web admin port>, 8088, 8089, 10000-20000,"
|
||||||
|
echo "[DRY-RUN] plus 3478 + 49152-49252 only if falling back to a dedicated coturn"
|
||||||
echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists"
|
echo "[DRY-RUN] Would offer 'update in place' instead of a fresh install if $EA_DIR already exists"
|
||||||
echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route"
|
echo "[DRY-RUN] Would patch vendor device-creation code + extensions.conf generator to route"
|
||||||
echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context,"
|
echo "[DRY-RUN] internal SIP MESSAGE through a dedicated [sip-messaging] dialplan context,"
|
||||||
@@ -1434,13 +1460,24 @@ install_asterisk() {
|
|||||||
prompt_reinstall_mode REINSTALL_MODE
|
prompt_reinstall_mode REINSTALL_MODE
|
||||||
case "$REINSTALL_MODE" in
|
case "$REINSTALL_MODE" in
|
||||||
update)
|
update)
|
||||||
|
# Detect BEFORE regenerating docker-compose.yml below: an
|
||||||
|
# install that already runs its own dedicated coturn (every
|
||||||
|
# install predating the shared coturn service) must keep
|
||||||
|
# getting one on every update, or this rebuild silently
|
||||||
|
# drops the container its own .env TURN_PASSWORD still
|
||||||
|
# points at — every already-configured phone loses TURN with
|
||||||
|
# no warning. Only an install with no embedded coturn block
|
||||||
|
# (new installs made after shared coturn existed) skips it.
|
||||||
|
local _HAD_EMBEDDED_COTURN=false
|
||||||
|
grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null && _HAD_EMBEDDED_COTURN=true
|
||||||
|
|
||||||
mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \
|
mkdir -p "$EA_DIR/config/asterisk" "$EA_DIR/config/easy-asterisk" \
|
||||||
"$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports"
|
"$EA_DIR/logs" "$EA_DIR/spool" "$EA_DIR/lib" "$EA_DIR/exports"
|
||||||
ensure_docker_dir_ownership "$EA_DIR"
|
ensure_docker_dir_ownership "$EA_DIR"
|
||||||
cd "$EA_DIR" || return 1
|
cd "$EA_DIR" || return 1
|
||||||
|
|
||||||
_asterisk_refresh_vendor_files
|
_asterisk_refresh_vendor_files
|
||||||
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN"
|
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$_HAD_EMBEDDED_COTURN"
|
||||||
_asterisk_write_logrotate "$EA_DIR"
|
_asterisk_write_logrotate "$EA_DIR"
|
||||||
_asterisk_patch_messaging_vendor_files "$EA_DIR"
|
_asterisk_patch_messaging_vendor_files "$EA_DIR"
|
||||||
_asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
|
_asterisk_write_messaging_dialplan "$EA_DIR/config/asterisk/messaging-dialplan.conf"
|
||||||
@@ -1559,21 +1596,53 @@ install_asterisk() {
|
|||||||
[[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y"
|
[[ -n "$VLAN_SUBNETS_VAL" ]] && HAS_VLANS_VAL="y"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Secrets ───────────────────────────────────────────────────────────────
|
# ── Secrets / TURN ───────────────────────────────────────────────────────
|
||||||
local TURN_PASSWORD
|
# Prefer the shared coturn service (services/coturn.sh) — one TURN server
|
||||||
TURN_PASSWORD="$(generate_password 24)"
|
# for every service on the box instead of Asterisk running its own and
|
||||||
|
# fighting other consumers (Mattermost, etc.) over relay ports. Falls
|
||||||
|
# back to Asterisk's own dedicated coturn if the shared service isn't
|
||||||
|
# available (e.g. this file run standalone with no sibling services/*.sh
|
||||||
|
# sourced) or registration fails for any reason — Asterisk should never
|
||||||
|
# end up with no TURN at all just because the shared path had a problem.
|
||||||
|
local USE_EMBEDDED_COTURN=true
|
||||||
|
local TURN_USERNAME TURN_PASSWORD TURN_PORT_VAL TURN_SERVER_VAL
|
||||||
|
|
||||||
# A public box always has a usable TURN address (the FQDN if set, else its
|
# Only reachable here via an explicit "fresh" choice above — "update"
|
||||||
# public IP). A LAN box with no FQDN has none — coturn is only reachable
|
# is handled separately and always preserves whatever coturn shape
|
||||||
# over the local network, so clients use the server's LAN address directly.
|
# already exists, never silently switches it.
|
||||||
local TURN_SERVER_VAL=""
|
if [[ -f "$EA_DIR/docker-compose.yml" ]] && grep -q '^ coturn:' "$EA_DIR/docker-compose.yml" 2>/dev/null; then
|
||||||
if [[ "$IS_DO" == true ]]; then
|
echo ""
|
||||||
TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478"
|
log_warning "This box's existing Asterisk install has its own dedicated coturn."
|
||||||
elif [[ -n "$DOMAIN_NAME" ]]; then
|
log_warning "Continuing may switch it to the new shared coturn service — any"
|
||||||
TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
|
log_warning "phone/softphone configured with the OLD TURN username/password will"
|
||||||
|
log_warning "need updating once this completes."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN"
|
ensure_coturn_user "asterisk"
|
||||||
|
if [[ -n "${COTURN_HOST:-}" ]]; then
|
||||||
|
USE_EMBEDDED_COTURN=false
|
||||||
|
TURN_USERNAME="$COTURN_USERNAME"
|
||||||
|
TURN_PASSWORD="$COTURN_PASSWORD"
|
||||||
|
TURN_PORT_VAL="$COTURN_PORT"
|
||||||
|
TURN_SERVER_VAL="${COTURN_HOST}:${COTURN_PORT}"
|
||||||
|
log_success "Using the shared coturn service — TURN username '$COTURN_USERNAME'."
|
||||||
|
else
|
||||||
|
TURN_USERNAME="easyasterisk"
|
||||||
|
TURN_PASSWORD="$(generate_password 24)"
|
||||||
|
TURN_PORT_VAL="3478"
|
||||||
|
# A public box always has a usable TURN address (the FQDN if set, else its
|
||||||
|
# public IP). A LAN box with no FQDN has none — coturn is only reachable
|
||||||
|
# over the local network, so clients use the server's LAN address directly.
|
||||||
|
TURN_SERVER_VAL=""
|
||||||
|
if [[ "$IS_DO" == true ]]; then
|
||||||
|
TURN_SERVER_VAL="${DOMAIN_NAME:-$PUBLIC_IP}:3478"
|
||||||
|
elif [[ -n "$DOMAIN_NAME" ]]; then
|
||||||
|
TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
|
||||||
|
fi
|
||||||
|
log_info "Shared coturn unavailable — Asterisk will run its own dedicated coturn."
|
||||||
|
fi
|
||||||
|
|
||||||
|
_asterisk_write_compose "$ASTERISK_PROJECT" "$CONTAINER" "$ASTERISK_COTURN" "$USE_EMBEDDED_COTURN"
|
||||||
|
|
||||||
# ── Pick a free port for the web admin ─────────────────────────────────────
|
# ── Pick a free port for the web admin ─────────────────────────────────────
|
||||||
# Hardcoding a single number gets fragile fast once several services share
|
# Hardcoding a single number gets fragile fast once several services share
|
||||||
@@ -1612,9 +1681,10 @@ install_asterisk() {
|
|||||||
DOMAIN_NAME=${DOMAIN_NAME}
|
DOMAIN_NAME=${DOMAIN_NAME}
|
||||||
|
|
||||||
# ── TURN/STUN ─────────────────────────────────────────────────
|
# ── TURN/STUN ─────────────────────────────────────────────────
|
||||||
TURN_USERNAME=easyasterisk
|
# $( [[ "$USE_EMBEDDED_COTURN" == true ]] && echo "This install runs its own dedicated coturn (see the coturn: service in docker-compose.yml)." || echo "Using the shared coturn service — see ~/docker/coturn/README.md." )
|
||||||
|
TURN_USERNAME=${TURN_USERNAME}
|
||||||
TURN_PASSWORD=${TURN_PASSWORD}
|
TURN_PASSWORD=${TURN_PASSWORD}
|
||||||
TURN_PORT=3478
|
TURN_PORT=${TURN_PORT_VAL}
|
||||||
# Empty when there's no publicly resolvable address (LAN-only, no FQDN).
|
# Empty when there's no publicly resolvable address (LAN-only, no FQDN).
|
||||||
TURN_SERVER=${TURN_SERVER_VAL}
|
TURN_SERVER=${TURN_SERVER_VAL}
|
||||||
|
|
||||||
@@ -1679,10 +1749,14 @@ ENV
|
|||||||
fi
|
fi
|
||||||
ufw allow 8088/tcp
|
ufw allow 8088/tcp
|
||||||
ufw allow 8089/tcp
|
ufw allow 8089/tcp
|
||||||
ufw allow 3478/udp
|
|
||||||
ufw allow 3478/tcp
|
|
||||||
ufw allow 10000:20000/udp
|
ufw allow 10000:20000/udp
|
||||||
ufw allow 49152:49252/udp
|
if [[ "$USE_EMBEDDED_COTURN" == true ]]; then
|
||||||
|
ufw allow 3478/udp
|
||||||
|
ufw allow 3478/tcp
|
||||||
|
ufw allow 49152:49252/udp
|
||||||
|
fi
|
||||||
|
# Shared coturn opens its own ports once, at its own install time
|
||||||
|
# (services/coturn.sh) — nothing to open here when using it.
|
||||||
ensure_ufw_enabled
|
ensure_ufw_enabled
|
||||||
log_success "UFW rules added."
|
log_success "UFW rules added."
|
||||||
fi
|
fi
|
||||||
@@ -1713,7 +1787,8 @@ ENV
|
|||||||
_asterisk_run_presence_step "$EA_DIR" "$CONTAINER"
|
_asterisk_run_presence_step "$EA_DIR" "$CONTAINER"
|
||||||
|
|
||||||
# ── README ────────────────────────────────────────────────────────────────
|
# ── README ────────────────────────────────────────────────────────────────
|
||||||
_asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL"
|
_asterisk_write_readme "$EA_DIR" "$CONTAINER" "$IS_DO" "$DOMAIN_NAME" "$PUBLIC_IP" "$WEB_ADMIN_PORT_VAL" \
|
||||||
|
"$USE_EMBEDDED_COTURN" "$TURN_USERNAME" "$TURN_SERVER_VAL"
|
||||||
|
|
||||||
# ── Start ─────────────────────────────────────────────────────────────────
|
# ── Start ─────────────────────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
+173
-1
@@ -214,6 +214,7 @@ install_backup() {
|
|||||||
local CONF_FILE="$DIR/backup.conf"
|
local CONF_FILE="$DIR/backup.conf"
|
||||||
local WORKER="$DIR/backup_kopia.sh"
|
local WORKER="$DIR/backup_kopia.sh"
|
||||||
local RESTORE="$DIR/restore_kopia.sh"
|
local RESTORE="$DIR/restore_kopia.sh"
|
||||||
|
local DR_BRINGUP="$DIR/dr_bringup.sh"
|
||||||
local SVC_NAME="post-install-backup"
|
local SVC_NAME="post-install-backup"
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
@@ -451,6 +452,40 @@ install_backup() {
|
|||||||
if [ -n "$NTFY_URL" ]; then
|
if [ -n "$NTFY_URL" ]; then
|
||||||
prompt_text " ntfy access token (blank if public/no auth):" "" NTFY_TOKEN
|
prompt_text " ntfy access token (blank if public/no auth):" "" NTFY_TOKEN
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Disaster-recovery spare box (optional) ────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo "═══════════════════════════════════════════════════════"
|
||||||
|
echo " DISASTER-RECOVERY SPARE (optional)"
|
||||||
|
echo "═══════════════════════════════════════════════════════"
|
||||||
|
echo ""
|
||||||
|
echo " If you keep a spare box ready to take over on failure (running"
|
||||||
|
echo " dr_bringup.sh), this can push backup.conf + README.md to it after"
|
||||||
|
echo " every successful backup, so it's always ready without a manual copy."
|
||||||
|
echo ""
|
||||||
|
echo " Requires passwordless SSH (key-based) from THIS box to the spare,"
|
||||||
|
echo " as the account below. Since the backup timer runs as root, that"
|
||||||
|
echo " usually means a key in /root/.ssh authorized on the spare — set that"
|
||||||
|
echo " up first if you haven't (ssh-keygen, then ssh-copy-id to the spare)."
|
||||||
|
echo ""
|
||||||
|
local DR_SYNC_HOST="" DR_SYNC_PATH=""
|
||||||
|
prompt_text " Spare box SSH destination, user@host (blank to skip):" "" DR_SYNC_HOST
|
||||||
|
if [ -n "$DR_SYNC_HOST" ]; then
|
||||||
|
prompt_text " Path for backup.conf/README on the spare:" "~/docker/backup" DR_SYNC_PATH
|
||||||
|
DR_SYNC_PATH="${DR_SYNC_PATH:-~/docker/backup}"
|
||||||
|
|
||||||
|
# Catch a missing/unauthorized key now, not at 2am during the first
|
||||||
|
# scheduled backup. Non-fatal either way — the setting is saved
|
||||||
|
# regardless, since the key may simply not be set up yet.
|
||||||
|
if ssh -o BatchMode=yes -o ConnectTimeout=5 "$DR_SYNC_HOST" true 2>/dev/null; then
|
||||||
|
log_success " SSH to $DR_SYNC_HOST works — spare sync will run after each backup."
|
||||||
|
else
|
||||||
|
log_warning " Couldn't SSH to $DR_SYNC_HOST without a password right now."
|
||||||
|
log_warning " Spare sync is saved but will fail until this works (as root, since"
|
||||||
|
log_warning " the backup timer runs as root): ssh-keygen; ssh-copy-id $DR_SYNC_HOST"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$DIR"
|
mkdir -p "$DIR"
|
||||||
ensure_docker_dir_ownership "$DIR"
|
ensure_docker_dir_ownership "$DIR"
|
||||||
|
|
||||||
@@ -530,6 +565,14 @@ install_backup() {
|
|||||||
echo "# Leave blank to disable. NTFY_TOKEN is optional (for private topics)."
|
echo "# Leave blank to disable. NTFY_TOKEN is optional (for private topics)."
|
||||||
printf "NTFY_URL='%s'\n" "${NTFY_URL:-}"
|
printf "NTFY_URL='%s'\n" "${NTFY_URL:-}"
|
||||||
printf "NTFY_TOKEN='%s'\n" "${NTFY_TOKEN:-}"
|
printf "NTFY_TOKEN='%s'\n" "${NTFY_TOKEN:-}"
|
||||||
|
echo ""
|
||||||
|
echo "# ── Disaster-recovery spare sync ───────────────────────────────────────────"
|
||||||
|
echo "# If set, backup_kopia.sh scp's this backup.conf + README.md to"
|
||||||
|
echo "# DR_SYNC_HOST:DR_SYNC_PATH after every successful backup, so a spare box"
|
||||||
|
echo "# running dr_bringup.sh is always ready with no manual copy step. Requires"
|
||||||
|
echo "# passwordless SSH from this box to the spare (see README.md)."
|
||||||
|
printf "DR_SYNC_HOST='%s'\n" "${DR_SYNC_HOST:-}"
|
||||||
|
printf "DR_SYNC_PATH='%s'\n" "${DR_SYNC_PATH:-~/docker/backup}"
|
||||||
} > "$CONF_FILE"
|
} > "$CONF_FILE"
|
||||||
chown root:root "$CONF_FILE" 2>/dev/null || true
|
chown root:root "$CONF_FILE" 2>/dev/null || true
|
||||||
chmod 600 "$CONF_FILE"
|
chmod 600 "$CONF_FILE"
|
||||||
@@ -554,6 +597,21 @@ install_backup() {
|
|||||||
log_warning "Copy it manually: cp extras/restore_kopia.sh $RESTORE"
|
log_warning "Copy it manually: cp extras/restore_kopia.sh $RESTORE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── 10b. Install disaster-recovery bring-up script ────────────────────────
|
||||||
|
# Non-interactive counterpart to restore_kopia.sh: restores every service's
|
||||||
|
# latest snapshot and runs `docker compose up -d` with no prompts, meant to
|
||||||
|
# run on a cold spare box during a real outage rather than the primary.
|
||||||
|
local DR_BRINGUP_SRC="${HERE:-}/extras/dr_bringup_kopia.sh"
|
||||||
|
if [ -f "$DR_BRINGUP_SRC" ]; then
|
||||||
|
cp "$DR_BRINGUP_SRC" "$DR_BRINGUP"
|
||||||
|
chmod +x "$DR_BRINGUP"
|
||||||
|
chown root:root "$DR_BRINGUP" 2>/dev/null || true
|
||||||
|
log_success "dr_bringup.sh installed"
|
||||||
|
else
|
||||||
|
log_warning "extras/dr_bringup_kopia.sh not found — DR bring-up script not installed"
|
||||||
|
log_warning "Copy it manually: cp extras/dr_bringup_kopia.sh $DR_BRINGUP"
|
||||||
|
fi
|
||||||
|
|
||||||
# ── 11. Install test scripts ─────────────────────────────────────────────
|
# ── 11. Install test scripts ─────────────────────────────────────────────
|
||||||
local TEST_SCRIPT="$DIR/test_backup_kopia.sh"
|
local TEST_SCRIPT="$DIR/test_backup_kopia.sh"
|
||||||
local TEST_SRC="${HERE:-}/extras/test_backup_kopia.sh"
|
local TEST_SRC="${HERE:-}/extras/test_backup_kopia.sh"
|
||||||
@@ -660,6 +718,106 @@ SVCEOF
|
|||||||
AUTORUN="cat /etc/cron.d/${SVC_NAME}"
|
AUTORUN="cat /etc/cron.d/${SVC_NAME}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Write README ─────────────────────────────────────────────────────────
|
||||||
|
# Written before the optional first run below so, if DR sync is enabled,
|
||||||
|
# the very first backup already ships an up-to-date README to the spare.
|
||||||
|
local DEST_LIST_MD=""
|
||||||
|
for dn in "${DEST_NAMES_ARR[@]}"; do
|
||||||
|
DEST_LIST_MD+="- **${dn}**: ${DEST_REPOS[$dn]}"$'\n'
|
||||||
|
done
|
||||||
|
|
||||||
|
local DR_SYNC_MD OFFSITE_MD
|
||||||
|
if [ -n "${DR_SYNC_HOST:-}" ]; then
|
||||||
|
DR_SYNC_MD="Configured: after every successful backup, this box copies backup.conf + this README to \`${DR_SYNC_HOST}:${DR_SYNC_PATH:-~/docker/backup}\` over SSH."
|
||||||
|
else
|
||||||
|
DR_SYNC_MD="Not configured. Re-run this installer to set it up, or copy backup.conf to the spare manually whenever it changes."
|
||||||
|
fi
|
||||||
|
if [ "${REMOTE_TYPE:-none}" != "none" ]; then
|
||||||
|
OFFSITE_MD="Configured: every backup also runs \`kopia repository sync-to ${REMOTE_TYPE}\` to mirror the repo off this box."
|
||||||
|
else
|
||||||
|
OFFSITE_MD="Not configured. Set REMOTE_TYPE/REMOTE_ARGS in backup.conf (see the comment above them) to mirror the repo off this box."
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_readme "$DIR" << MD
|
||||||
|
# Backup — Kopia
|
||||||
|
|
||||||
|
Full recovery for every Docker service under \`$DOCKER_DIR\`: each service's
|
||||||
|
entire directory (compose file, \`.env\`, config, data, databases) is
|
||||||
|
snapshotted with Kopia — deduplicated, compressed (zstd), and encrypted.
|
||||||
|
Databases are captured consistently (container stopped briefly, snapshotted,
|
||||||
|
restarted); Minecraft instead gets a live save-all flush, no downtime.
|
||||||
|
|
||||||
|
## Destinations
|
||||||
|
|
||||||
|
$DEST_LIST_MD
|
||||||
|
## Schedule
|
||||||
|
|
||||||
|
$SCHED_LABEL — keeps the latest $KEEP_LATEST snapshots (plus 7 daily / 4
|
||||||
|
weekly / 3 monthly).
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
sudo $WORKER # back up now
|
||||||
|
sudo $WORKER snapshots # list all snapshots
|
||||||
|
sudo $WORKER policy # show retention policy
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Restore — interactive, one service at a time
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
sudo $RESTORE
|
||||||
|
sudo $RESTORE --list
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Disaster recovery — unattended, every service, for a cold spare box
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
sudo $DR_BRINGUP # restore + start everything
|
||||||
|
sudo $DR_BRINGUP --list # list what's restorable
|
||||||
|
sudo $DR_BRINGUP --dry-run # preview, touch nothing
|
||||||
|
sudo $DR_BRINGUP --service NAME # just one service
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
A single service failing to restore or start does not stop the rest of the
|
||||||
|
batch — it's logged and skipped so the run maximizes what actually comes
|
||||||
|
back up. The exit code is only non-zero if nothing came up at all.
|
||||||
|
|
||||||
|
**On the spare box**, \`dr_bringup.sh\` needs \`backup.conf\` from this
|
||||||
|
directory to connect to the repo — see the DR spare sync section below.
|
||||||
|
|
||||||
|
## Disaster-recovery spare sync
|
||||||
|
|
||||||
|
$DR_SYNC_MD
|
||||||
|
|
||||||
|
Requires passwordless SSH (key-based) from this box to the spare — since the
|
||||||
|
backup timer runs as root, generate/authorize a key for root:
|
||||||
|
\`ssh-keygen\`, then \`ssh-copy-id\` to the spare.
|
||||||
|
|
||||||
|
## Offsite mirror
|
||||||
|
|
||||||
|
$OFFSITE_MD
|
||||||
|
|
||||||
|
## Backup test — stop / restore / compare / restore-back
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
sudo $TEST_SCRIPT # test most recent backup, all services
|
||||||
|
sudo $TEST_SCRIPT --list # list testable services
|
||||||
|
sudo $TEST_SCRIPT --service NAME
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- \`backup.conf\` — destinations, passwords, retention, DR-sync/offsite settings (chmod 600)
|
||||||
|
- \`backup_kopia.sh\` — the worker the systemd timer runs
|
||||||
|
- \`restore_kopia.sh\` — interactive restore
|
||||||
|
- \`dr_bringup.sh\` — unattended full-stack restore + start
|
||||||
|
- \`test_backup_kopia.sh\` / \`test_backup.sh\` — automated restore tests
|
||||||
|
|
||||||
|
**Save the passwords in \`backup.conf\` somewhere safe** — without them the
|
||||||
|
encrypted repos cannot be restored.
|
||||||
|
MD
|
||||||
|
|
||||||
# ── 12. Optional first run ────────────────────────────────────────────────
|
# ── 12. Optional first run ────────────────────────────────────────────────
|
||||||
echo ""
|
echo ""
|
||||||
local _now=""
|
local _now=""
|
||||||
@@ -693,10 +851,24 @@ SVCEOF
|
|||||||
echo " sudo $WORKER back up now"
|
echo " sudo $WORKER back up now"
|
||||||
echo " sudo $WORKER snapshots list all snapshots"
|
echo " sudo $WORKER snapshots list all snapshots"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Restore:"
|
echo " Restore (interactive, one service at a time):"
|
||||||
echo " sudo $RESTORE"
|
echo " sudo $RESTORE"
|
||||||
echo " sudo $RESTORE --list"
|
echo " sudo $RESTORE --list"
|
||||||
echo ""
|
echo ""
|
||||||
|
echo " Disaster recovery (unattended, every service — for a cold spare box):"
|
||||||
|
echo " sudo $DR_BRINGUP restore + start everything"
|
||||||
|
echo " sudo $DR_BRINGUP --list list what's restorable"
|
||||||
|
echo " sudo $DR_BRINGUP --dry-run preview, touch nothing"
|
||||||
|
if [ -n "${DR_SYNC_HOST:-}" ]; then
|
||||||
|
echo " backup.conf + README.md sync to $DR_SYNC_HOST after every backup — the"
|
||||||
|
echo " spare stays ready with no manual copy step."
|
||||||
|
else
|
||||||
|
echo " Copy backup.conf to the spare box first — it holds the repo path(s)"
|
||||||
|
echo " and password(s) this needs to connect."
|
||||||
|
fi
|
||||||
|
echo ""
|
||||||
|
echo " Full docs: $DIR/README.md"
|
||||||
|
echo ""
|
||||||
echo " Backup test (stop/restore/compare/restore-back):"
|
echo " Backup test (stop/restore/compare/restore-back):"
|
||||||
echo " sudo $TEST_SCRIPT test most recent backup (all services)"
|
echo " sudo $TEST_SCRIPT test most recent backup (all services)"
|
||||||
echo " sudo $TEST_SCRIPT --list list testable services"
|
echo " sudo $TEST_SCRIPT --list list testable services"
|
||||||
|
|||||||
@@ -0,0 +1,385 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# services/coturn.sh — Shared TURN/STUN relay (coturn) for WebRTC-capable services.
|
||||||
|
# Part of the modular post-install system (sourced by setup.sh).
|
||||||
|
#
|
||||||
|
# Can also be run standalone on any machine:
|
||||||
|
# sudo bash coturn.sh
|
||||||
|
# (Docker must already be installed when run standalone)
|
||||||
|
#
|
||||||
|
# One coturn instance, shared by every service that needs TURN (Asterisk,
|
||||||
|
# Mattermost, and anything added later) instead of each service running its
|
||||||
|
# own — which used to mean N containers all on network_mode: host fighting
|
||||||
|
# over relay port ranges (confirmed live: Asterisk's default range and
|
||||||
|
# Mattermost's default range overlapped by ~100 ports before this existed).
|
||||||
|
#
|
||||||
|
# Runs in long-term-credential mode (--lt-cred-mech) with a SQLite user
|
||||||
|
# database instead of a single static user — every consumer registers its
|
||||||
|
# own dedicated username/password via ensure_coturn_user() (lib/common.sh),
|
||||||
|
# so credentials are per-service and one consumer being compromised or
|
||||||
|
# reconfigured doesn't affect any other's TURN access.
|
||||||
|
#
|
||||||
|
# Deliberately NOT --use-auth-secret (the HMAC/REST-API mode Mattermost's
|
||||||
|
# Calls plugin also supports): coturn does not support both auth mechanisms
|
||||||
|
# on one running instance at once — turning on --use-auth-secret silently
|
||||||
|
# overrides --lt-cred-mech server-wide, which would break every
|
||||||
|
# static-credential consumer (Asterisk's PJSIP TURN client wants a fixed
|
||||||
|
# long-lived username/password, not a periodically-regenerated HMAC one).
|
||||||
|
# lt-cred-mech supports any number of named users out of the box, which is
|
||||||
|
# exactly the shared-multi-consumer shape this needs — no tradeoff either
|
||||||
|
# way. Mattermost's Calls plugin is configured with a static username/
|
||||||
|
# credential pair too (its "ICE Servers Configurations" field), not its
|
||||||
|
# "TURN Static Auth Secret" field, so both consumers use the same mechanism.
|
||||||
|
|
||||||
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||||
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||||
|
# Sets up helpers and globals, then defers execution until after the function
|
||||||
|
# definition at the bottom of this file.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||||
|
|
||||||
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||||
|
|
||||||
|
if [[ -f "$_COMMON" ]]; then
|
||||||
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$_COMMON"
|
||||||
|
else
|
||||||
|
# One-off copy — inline minimal stubs so the script works without the repo
|
||||||
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||||
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||||
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||||
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
require_docker() {
|
||||||
|
command -v docker &>/dev/null || {
|
||||||
|
log_error "Docker not found. Install it first:"
|
||||||
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
docker compose version &>/dev/null || {
|
||||||
|
log_error "Docker Compose plugin missing:"
|
||||||
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_dir_ownership() {
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||||
|
prompt_text() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_yn() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_reinstall_mode() {
|
||||||
|
local _var="$1" _r
|
||||||
|
if [[ "${UNATTENDED:-false}" == "true" ]]; then eval "$_var='cancel'"; return; fi
|
||||||
|
echo " Existing install detected. Choose:"
|
||||||
|
echo " r) Reinstall in place — refresh vendor files, keep existing settings"
|
||||||
|
echo " f) Full install — re-run every prompt from scratch"
|
||||||
|
echo " c) Cancel — leave everything as-is [default]"
|
||||||
|
read -r -p " Choice [r/f/c, Enter=cancel]: " _r
|
||||||
|
case "${_r,,}" in
|
||||||
|
r) eval "$_var='update'" ;;
|
||||||
|
f) eval "$_var='fresh'" ;;
|
||||||
|
*) eval "$_var='cancel'" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
write_readme() {
|
||||||
|
local _dir="$1"; shift
|
||||||
|
mkdir -p "$_dir"
|
||||||
|
cat > "$_dir/README.md"
|
||||||
|
chown "$ACTUAL_USER:$ACTUAL_USER" "$_dir/README.md" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_password() {
|
||||||
|
local _len="${1:-32}"
|
||||||
|
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_ufw_enabled() {
|
||||||
|
command -v ufw &>/dev/null || return 0
|
||||||
|
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||||
|
ufw status 2>/dev/null | grep -q "Status: active" && return 0
|
||||||
|
local _ssh_port
|
||||||
|
_ssh_port="$(grep -iE '^[[:space:]]*Port[[:space:]]+[0-9]+' /etc/ssh/sshd_config 2>/dev/null | tail -1 | awk '{print $2}')"
|
||||||
|
ufw allow "${_ssh_port:-22}/tcp" comment 'SSH' >/dev/null 2>&1
|
||||||
|
ufw --force enable >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||||
|
# ($HOME under sudo is /root, not the real user's home)
|
||||||
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||||
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||||
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||||
|
DRY_RUN="${DRY_RUN:-false}"
|
||||||
|
UNATTENDED="${UNATTENDED:-false}"
|
||||||
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||||
|
|
||||||
|
register_service() { :; } # no-op — no wizard to register into
|
||||||
|
_RUN_STANDALONE=1
|
||||||
|
fi
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
register_service coturn homelab "Shared TURN/STUN relay (coturn) for Asterisk, Mattermost, and other WebRTC-capable services" 3478
|
||||||
|
|
||||||
|
install_coturn() {
|
||||||
|
require_docker || return 1
|
||||||
|
|
||||||
|
local DIR="$DOCKER_DIR/coturn"
|
||||||
|
local ENV_FILE="$DIR/.env"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "╔═══════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ Shared coturn (TURN/STUN relay) ║"
|
||||||
|
echo "╚═══════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo " One TURN server, shared by every service that needs one (Asterisk,"
|
||||||
|
echo " Mattermost Calls, anything added later) — each gets its own"
|
||||||
|
echo " dedicated username/password, registered automatically the first"
|
||||||
|
echo " time that service is installed. You normally don't run this"
|
||||||
|
echo " directly; another service's installer chains into it."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would create $DIR with docker-compose.yml + .env"
|
||||||
|
echo "[DRY-RUN] Would run coturn in --lt-cred-mech mode with a SQLite user database"
|
||||||
|
echo "[DRY-RUN] Would open UFW: 3478/udp+tcp, and the relay port range udp"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Update vs. fresh reinstall ─────────────────────────────────────────────
|
||||||
|
# "update" only refreshes the image/compose shape — realm, host, port
|
||||||
|
# range, and every registered consumer's credentials are left exactly as
|
||||||
|
# they are. Rotating any of those here would silently break TURN for
|
||||||
|
# every service already relying on this instance (Asterisk phones,
|
||||||
|
# Mattermost Calls) without those services knowing to reconfigure.
|
||||||
|
local MODE="fresh"
|
||||||
|
if [[ -f "$DIR/docker-compose.yml" && -f "$ENV_FILE" ]]; then
|
||||||
|
prompt_reinstall_mode MODE
|
||||||
|
case "$MODE" in
|
||||||
|
update)
|
||||||
|
log_info "Refreshing the coturn image/compose only — realm, host, port range, and"
|
||||||
|
log_info "every registered consumer's credentials are left exactly as they are."
|
||||||
|
;;
|
||||||
|
cancel)
|
||||||
|
log_info "Leaving the existing coturn install as-is."
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
fresh)
|
||||||
|
echo ""
|
||||||
|
log_warning "A full reinstall regenerates nothing destructive by itself, but if you"
|
||||||
|
log_warning "change the host/port/realm below, every already-registered consumer"
|
||||||
|
log_warning "(Asterisk, Mattermost, ...) keeps pointing at the OLD values in its own"
|
||||||
|
log_warning ".env until you re-run that service's installer too."
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DIR/db" "$DIR/users"
|
||||||
|
ensure_docker_dir_ownership "$DIR"
|
||||||
|
cd "$DIR" || return 1
|
||||||
|
|
||||||
|
local COTURN_REALM="" COTURN_HOST="" COTURN_PORT="3478"
|
||||||
|
local COTURN_MIN_PORT="49152" COTURN_MAX_PORT="49452"
|
||||||
|
|
||||||
|
if [ "$MODE" = "update" ]; then
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$ENV_FILE"
|
||||||
|
else
|
||||||
|
local _default_realm="${SITE_DOMAIN:-localhost}"
|
||||||
|
prompt_text " Realm (usually your domain, or 'localhost' for LAN-only):" "$_default_realm" COTURN_REALM
|
||||||
|
|
||||||
|
local _detected_ip
|
||||||
|
_detected_ip="$(curl -fsS --max-time 3 https://ifconfig.me 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}')"
|
||||||
|
prompt_text " Public hostname/IP TURN clients should connect to:" "$_detected_ip" COTURN_HOST
|
||||||
|
|
||||||
|
prompt_text " Listening port:" "3478" COTURN_PORT
|
||||||
|
prompt_text " Relay port range — min:" "49152" COTURN_MIN_PORT
|
||||||
|
prompt_text " Relay port range — max (each concurrent relayed call needs ~1 port; 300 ports is generous for a homelab):" "49452" COTURN_MAX_PORT
|
||||||
|
fi
|
||||||
|
|
||||||
|
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
|
||||||
|
cat > docker-compose.yml << 'EOF'
|
||||||
|
name: coturn
|
||||||
|
|
||||||
|
services:
|
||||||
|
coturn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: coturn
|
||||||
|
network_mode: host
|
||||||
|
user: root
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./db:/var/lib/coturn
|
||||||
|
command:
|
||||||
|
- -n
|
||||||
|
- --listening-port=${COTURN_PORT:-3478}
|
||||||
|
- --listening-ip=0.0.0.0
|
||||||
|
- --fingerprint
|
||||||
|
- --lt-cred-mech
|
||||||
|
- --userdb=/var/lib/coturn/turndb
|
||||||
|
- --realm=${COTURN_REALM:-localhost}
|
||||||
|
- --min-port=${COTURN_MIN_PORT:-49152}
|
||||||
|
- --max-port=${COTURN_MAX_PORT:-49452}
|
||||||
|
- --no-tls
|
||||||
|
- --no-dtls
|
||||||
|
- --no-cli
|
||||||
|
- --no-multicast-peers
|
||||||
|
- --log-file=stdout
|
||||||
|
restart: unless-stopped
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > "$ENV_FILE" << ENVEOF
|
||||||
|
TZ=$TZ_VAL
|
||||||
|
|
||||||
|
# ── Identity — read by lib/common.sh's ensure_coturn_user() ────────────────
|
||||||
|
# Changing these after consumers already registered breaks TURN for them
|
||||||
|
# until each one is reconfigured — see the warning above before editing.
|
||||||
|
COTURN_REALM=$COTURN_REALM
|
||||||
|
COTURN_HOST=$COTURN_HOST
|
||||||
|
COTURN_PORT=$COTURN_PORT
|
||||||
|
COTURN_MIN_PORT=$COTURN_MIN_PORT
|
||||||
|
COTURN_MAX_PORT=$COTURN_MAX_PORT
|
||||||
|
ENVEOF
|
||||||
|
chmod 600 "$ENV_FILE"
|
||||||
|
chown "$ACTUAL_USER:$ACTUAL_USER" docker-compose.yml "$ENV_FILE"
|
||||||
|
|
||||||
|
log_success "coturn configured at $DIR"
|
||||||
|
|
||||||
|
# ── Firewall ──────────────────────────────────────────────────────────────
|
||||||
|
if command -v ufw &>/dev/null; then
|
||||||
|
ufw allow "${COTURN_PORT}/udp" comment 'coturn TURN/STUN' >/dev/null 2>&1
|
||||||
|
ufw allow "${COTURN_PORT}/tcp" comment 'coturn TURN/STUN' >/dev/null 2>&1
|
||||||
|
ufw allow "${COTURN_MIN_PORT}:${COTURN_MAX_PORT}/udp" comment 'coturn relay' >/dev/null 2>&1
|
||||||
|
log_success "UFW: opened ${COTURN_PORT}/udp+tcp and ${COTURN_MIN_PORT}-${COTURN_MAX_PORT}/udp"
|
||||||
|
ensure_ufw_enabled
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Admin helper: list/add/remove consumers without touching compose ───────
|
||||||
|
cat > coturn_user.sh << 'USEREOF'
|
||||||
|
#!/bin/bash
|
||||||
|
# ~/docker/coturn/coturn_user.sh — manage TURN users in the shared coturn's
|
||||||
|
# SQLite user database. Most services register themselves automatically via
|
||||||
|
# ensure_coturn_user() (lib/common.sh) at install time — this is for manual
|
||||||
|
# inspection/cleanup.
|
||||||
|
#
|
||||||
|
# sudo ./coturn_user.sh list
|
||||||
|
# sudo ./coturn_user.sh add <name> <password>
|
||||||
|
# sudo ./coturn_user.sh remove <name>
|
||||||
|
set -uo pipefail
|
||||||
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
# shellcheck source=/dev/null
|
||||||
|
source "$HERE/.env"
|
||||||
|
|
||||||
|
case "${1:-}" in
|
||||||
|
list)
|
||||||
|
docker exec coturn turnadmin -l -b /var/lib/coturn/turndb
|
||||||
|
;;
|
||||||
|
add)
|
||||||
|
[ -n "${2:-}" ] && [ -n "${3:-}" ] || { echo "Usage: $0 add <name> <password>"; exit 1; }
|
||||||
|
docker exec coturn turnadmin -a -u "$2" -p "$3" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \
|
||||||
|
&& echo "Added: $2" \
|
||||||
|
|| echo "Failed to add $2 — is the coturn container running?"
|
||||||
|
;;
|
||||||
|
remove)
|
||||||
|
[ -n "${2:-}" ] || { echo "Usage: $0 remove <name>"; exit 1; }
|
||||||
|
docker exec coturn turnadmin -d -u "$2" -r "$COTURN_REALM" -b /var/lib/coturn/turndb \
|
||||||
|
&& { echo "Removed: $2"; rm -f "$HERE/users/$2.env"; } \
|
||||||
|
|| echo "Failed to remove $2"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 {list|add <name> <password>|remove <name>}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
USEREOF
|
||||||
|
chmod +x coturn_user.sh
|
||||||
|
chown "$ACTUAL_USER:$ACTUAL_USER" coturn_user.sh
|
||||||
|
|
||||||
|
write_readme "$DIR" << MD
|
||||||
|
# coturn — shared TURN/STUN relay
|
||||||
|
|
||||||
|
One coturn instance shared by every service on this box that needs TURN
|
||||||
|
(Asterisk, Mattermost Calls, anything added later) — instead of each running
|
||||||
|
its own and fighting over host ports for the relay range.
|
||||||
|
|
||||||
|
Runs in long-term-credential mode with a SQLite user database. Each
|
||||||
|
consumer gets its own dedicated username/password, registered automatically
|
||||||
|
by that service's installer via \`ensure_coturn_user()\` — you don't
|
||||||
|
normally need to touch this directly.
|
||||||
|
|
||||||
|
## Identity
|
||||||
|
- Realm: \`$COTURN_REALM\`
|
||||||
|
- Host clients connect to: \`$COTURN_HOST\`
|
||||||
|
- Listening port: \`$COTURN_PORT\`
|
||||||
|
- Relay port range: \`$COTURN_MIN_PORT-$COTURN_MAX_PORT\` (udp)
|
||||||
|
|
||||||
|
**Changing any of the above breaks TURN for every already-registered
|
||||||
|
consumer until that service's installer is re-run** — they cache the host/
|
||||||
|
port/credentials in their own \`.env\` at registration time, not read live.
|
||||||
|
|
||||||
|
## Manage users
|
||||||
|
\`\`\`bash
|
||||||
|
sudo ./coturn_user.sh list
|
||||||
|
sudo ./coturn_user.sh add <name> <password>
|
||||||
|
sudo ./coturn_user.sh remove <name>
|
||||||
|
\`\`\`
|
||||||
|
Per-consumer credentials are also cached in \`users/<name>.env\` (chmod 600)
|
||||||
|
so a service re-running its own installer reuses the same credential
|
||||||
|
instead of silently minting a new one and orphaning the old.
|
||||||
|
|
||||||
|
## Manage the container
|
||||||
|
\`\`\`bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose down
|
||||||
|
docker compose logs -f
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Adding a new service that needs TURN
|
||||||
|
In that service's \`install_<name>()\`, after \`require_docker\`:
|
||||||
|
\`\`\`bash
|
||||||
|
ensure_coturn_user "my-service"
|
||||||
|
if [ -n "\$COTURN_HOST" ]; then
|
||||||
|
# COTURN_HOST / COTURN_PORT / COTURN_USERNAME / COTURN_PASSWORD are set
|
||||||
|
# (not local — read them after the call returns, same convention as
|
||||||
|
# configure_caddy_for_service's CADDY_SERVICE_* out-params)
|
||||||
|
else
|
||||||
|
# coturn unavailable — degrade gracefully (no TURN, or prompt to run
|
||||||
|
# `sudo ./setup.sh coturn` first)
|
||||||
|
fi
|
||||||
|
\`\`\`
|
||||||
|
MD
|
||||||
|
|
||||||
|
local START=""
|
||||||
|
prompt_yn "Start coturn now? (y/n):" "y" START
|
||||||
|
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
|
||||||
|
docker compose up -d \
|
||||||
|
&& log_success "coturn started" \
|
||||||
|
|| log_warning "Start failed — check: docker compose logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Realm: $COTURN_REALM Host: $COTURN_HOST Port: $COTURN_PORT"
|
||||||
|
echo " Relay range: $COTURN_MIN_PORT-$COTURN_MAX_PORT/udp"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run immediately when executed directly (deferred until after function definition)
|
||||||
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_coturn
|
||||||
+6
-1
@@ -286,8 +286,13 @@ install_immich() {
|
|||||||
cd "$IMMICH_DIR" || return 1
|
cd "$IMMICH_DIR" || return 1
|
||||||
|
|
||||||
# ── Generate DB password ────────────────────────────────────────────────
|
# ── Generate DB password ────────────────────────────────────────────────
|
||||||
|
# Reused across reruns if already set — the Postgres volume keeps the
|
||||||
|
# password from its first init, so a fresh random one on every rerun
|
||||||
|
# would lock Immich out of its own database.
|
||||||
local DB_PASS TZ_VAL
|
local DB_PASS TZ_VAL
|
||||||
DB_PASS=$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
DB_PASS=""
|
||||||
|
[ -f ".env" ] && DB_PASS="$(grep '^DB_PASSWORD=' .env | cut -d= -f2-)"
|
||||||
|
[ -n "$DB_PASS" ] || DB_PASS="$(openssl rand -base64 32 | tr -dc 'a-zA-Z0-9' | head -c 32)"
|
||||||
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
|
||||||
# ── Write docker-compose.yml ────────────────────────────────────────────
|
# ── Write docker-compose.yml ────────────────────────────────────────────
|
||||||
|
|||||||
+6
-2
@@ -197,8 +197,12 @@ install_joplin() {
|
|||||||
ensure_docker_dir_ownership "$JOPLIN_DIR"
|
ensure_docker_dir_ownership "$JOPLIN_DIR"
|
||||||
cd "$JOPLIN_DIR" || return 1
|
cd "$JOPLIN_DIR" || return 1
|
||||||
|
|
||||||
local DB_PASS
|
# Reused across reruns if already set — the Postgres volume keeps the
|
||||||
DB_PASS="$(generate_password 32)"
|
# password from its first init, so a fresh random one on every rerun
|
||||||
|
# would lock Joplin out of its own database.
|
||||||
|
local DB_PASS=""
|
||||||
|
[ -f ".env" ] && DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)"
|
||||||
|
[ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)"
|
||||||
local BASE_URL="https://joplin.${SITE_DOMAIN}"
|
local BASE_URL="https://joplin.${SITE_DOMAIN}"
|
||||||
|
|
||||||
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
||||||
|
|||||||
+20
-3
@@ -336,10 +336,27 @@ install_koha() {
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Passwords ─────────────────────────────────────────────────────────────
|
# ── Passwords ─────────────────────────────────────────────────────────────
|
||||||
|
# Reused across reruns if already set — the MariaDB volume keeps
|
||||||
|
# DB_PASS/DB_ROOT_PASS from first init, and an already-created Koha admin
|
||||||
|
# account keeps its own KOHA_ADMIN_PASS; regenerating any of these on a
|
||||||
|
# rerun would lock the reinstall out of both the database and
|
||||||
|
# post-setup.sh's REST API login.
|
||||||
local DB_PASS DB_ROOT_PASS RABBIT_PASS
|
local DB_PASS DB_ROOT_PASS RABBIT_PASS
|
||||||
DB_PASS="$(generate_password 24)"
|
local _existing_koha_conf="$KOHA_DIR/config-main.env"
|
||||||
DB_ROOT_PASS="$(generate_password 24)"
|
if [ -f "$_existing_koha_conf" ]; then
|
||||||
RABBIT_PASS="$(generate_password 24)"
|
DB_PASS="$(grep '^DB_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||||||
|
DB_ROOT_PASS="$(grep '^DB_ROOT_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||||||
|
RABBIT_PASS="$(grep '^RABBIT_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||||||
|
local _existing_admin_pass
|
||||||
|
_existing_admin_pass="$(grep '^KOHA_ADMIN_PASS=' "$_existing_koha_conf" | cut -d= -f2-)"
|
||||||
|
if [ -n "$_existing_admin_pass" ]; then
|
||||||
|
KOHA_ADMIN_PASS="$_existing_admin_pass"
|
||||||
|
log_info "Existing install detected — reusing its DB/admin passwords instead of what was just entered above, so this rebuild doesn't lock out the already-initialized database and admin account."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[ -n "${DB_PASS:-}" ] || DB_PASS="$(generate_password 24)"
|
||||||
|
[ -n "${DB_ROOT_PASS:-}" ] || DB_ROOT_PASS="$(generate_password 24)"
|
||||||
|
[ -n "${RABBIT_PASS:-}" ] || RABBIT_PASS="$(generate_password 24)"
|
||||||
|
|
||||||
# ── Create directories ────────────────────────────────────────────────────
|
# ── Create directories ────────────────────────────────────────────────────
|
||||||
mkdir -p "$KOHA_DIR/data"
|
mkdir -p "$KOHA_DIR/data"
|
||||||
|
|||||||
@@ -207,9 +207,18 @@ install_mail-archiver() {
|
|||||||
ensure_docker_dir_ownership "$MA_DIR"
|
ensure_docker_dir_ownership "$MA_DIR"
|
||||||
cd "$MA_DIR" || return 1
|
cd "$MA_DIR" || return 1
|
||||||
|
|
||||||
|
# Reused across reruns if already set — the Postgres volume keeps
|
||||||
|
# DB_PASS from its first init, and an already-created admin account
|
||||||
|
# keeps ADMIN_PASS; regenerating either on a rerun would lock the
|
||||||
|
# reinstall out of the database and the app's own admin login.
|
||||||
local DB_PASS ADMIN_PASS TZ_VAL
|
local DB_PASS ADMIN_PASS TZ_VAL
|
||||||
DB_PASS=$(generate_password 32)
|
DB_PASS="" ADMIN_PASS=""
|
||||||
ADMIN_PASS=$(generate_password 24)
|
if [ -f ".env" ]; then
|
||||||
|
DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env | cut -d= -f2-)"
|
||||||
|
ADMIN_PASS="$(grep '^Authentication__Password=' .env | cut -d= -f2-)"
|
||||||
|
fi
|
||||||
|
[ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)"
|
||||||
|
[ -n "$ADMIN_PASS" ] || ADMIN_PASS="$(generate_password 24)"
|
||||||
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
|
||||||
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
# Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh):
|
||||||
|
|||||||
+255
-65
@@ -208,40 +208,129 @@ CBLOCK
|
|||||||
fi
|
fi
|
||||||
# ─────────────────────────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost + coturn)" 8065
|
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost; TURN via the shared coturn service); supports multiple isolated instances" 8065
|
||||||
|
|
||||||
install_mattermost() {
|
install_mattermost() {
|
||||||
require_docker || return 1
|
require_docker || return 1
|
||||||
log_info "Installing Mattermost + coturn..."
|
|
||||||
|
|
||||||
|
# ── Instance selection ──────────────────────────────────────────────────
|
||||||
|
# First instance keeps the plain "mattermost" name/paths/ports exactly as
|
||||||
|
# before (zero behavior change for anyone with a single instance). Only
|
||||||
|
# asking to add a second one introduces the suffixed naming.
|
||||||
local DIR="$DOCKER_DIR/mattermost"
|
local DIR="$DOCKER_DIR/mattermost"
|
||||||
|
local INSTANCE_SUFFIX="" PROJECT="mattermost"
|
||||||
|
local MM_CONTAINER="mattermost" DB_CONTAINER="mattermost-db"
|
||||||
|
local WEB_PORT="8065" CALLS_UDP_PORT="8443"
|
||||||
|
local COTURN_CONSUMER="mattermost"
|
||||||
|
|
||||||
|
if [ -d "$DIR" ]; then
|
||||||
|
echo ""
|
||||||
|
echo " Mattermost is already installed at $DIR."
|
||||||
|
echo " 1) Manage that install (update / full reinstall / cancel)"
|
||||||
|
echo " 2) Add a NEW, separate Mattermost instance alongside it (its own"
|
||||||
|
echo " server, database, and TURN credential — full isolation, not Teams)"
|
||||||
|
echo ""
|
||||||
|
local _TOP_CHOICE=""
|
||||||
|
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
|
||||||
|
if [ "$_TOP_CHOICE" = "2" ]; then
|
||||||
|
local _suffix=""
|
||||||
|
while true; do
|
||||||
|
prompt_text " Short name for the new instance (letters/numbers/hyphens, e.g. 'team-b'):" "" _suffix
|
||||||
|
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
|
||||||
|
if [ -z "$_suffix" ]; then
|
||||||
|
log_warning "Name can't be empty."; continue
|
||||||
|
fi
|
||||||
|
if [ -d "$DOCKER_DIR/mattermost-$_suffix" ]; then
|
||||||
|
log_warning "mattermost-$_suffix already exists — pick another name."; continue
|
||||||
|
fi
|
||||||
|
break
|
||||||
|
done
|
||||||
|
INSTANCE_SUFFIX="$_suffix"
|
||||||
|
DIR="$DOCKER_DIR/mattermost-$_suffix"
|
||||||
|
PROJECT="mattermost-$_suffix"
|
||||||
|
MM_CONTAINER="mattermost-$_suffix"
|
||||||
|
DB_CONTAINER="mattermost-$_suffix-db"
|
||||||
|
COTURN_CONSUMER="mattermost-$_suffix"
|
||||||
|
|
||||||
|
# Free-port scan — same pattern services/asterisk.sh uses for its
|
||||||
|
# web admin port. WEB_PORT is also set as Mattermost's own
|
||||||
|
# internal ListenAddress below (not just the host publish side),
|
||||||
|
# so configure_caddy_for_service's single upstream "name:port"
|
||||||
|
# string works unmodified in both local and remote-Caddy mode —
|
||||||
|
# it assumes host-published-port == container-internal-port,
|
||||||
|
# true for every other service in this repo and made true here
|
||||||
|
# too rather than special-casing the shared helper for one caller.
|
||||||
|
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
WEB_PORT=$((WEB_PORT + 1))
|
||||||
|
done
|
||||||
|
while ss -ulnH "sport = :${CALLS_UDP_PORT}" 2>/dev/null | grep -q .; do
|
||||||
|
CALLS_UDP_PORT=$((CALLS_UDP_PORT + 1))
|
||||||
|
done
|
||||||
|
log_info "New instance: $DIR (web port $WEB_PORT, Calls UDP port $CALLS_UDP_PORT)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
log_info "Installing Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}..."
|
||||||
|
|
||||||
if [ "$DRY_RUN" = true ]; then
|
if [ "$DRY_RUN" = true ]; then
|
||||||
echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
|
echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
|
||||||
echo "[DRY-RUN] Would write .env with DB and Mattermost secrets"
|
echo "[DRY-RUN] Would write .env with DB and Mattermost secrets"
|
||||||
echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories"
|
echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories"
|
||||||
echo "[DRY-RUN] Would open UFW ports 8443/udp, 3479, 49153:49352/udp"
|
echo "[DRY-RUN] Would register a TURN user with the shared coturn service for '$COTURN_CONSUMER'"
|
||||||
|
echo "[DRY-RUN] (falling back to a dedicated coturn if the shared service is unavailable)"
|
||||||
|
echo "[DRY-RUN] Would open UFW ports ${WEB_PORT}/tcp, ${CALLS_UDP_PORT}/udp"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Existing install (this exact instance)? Offer update-in-place ───────
|
||||||
|
local MODE="fresh"
|
||||||
|
local _HAD_EMBEDDED_COTURN=false
|
||||||
|
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
|
||||||
|
prompt_reinstall_mode MODE
|
||||||
|
grep -q '^ coturn:' "$DIR/docker-compose.yml" 2>/dev/null && _HAD_EMBEDDED_COTURN=true
|
||||||
|
case "$MODE" in
|
||||||
|
cancel)
|
||||||
|
log_info "Leaving the existing install as-is."
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
fresh)
|
||||||
|
if [ "$_HAD_EMBEDDED_COTURN" = true ]; then
|
||||||
|
echo ""
|
||||||
|
log_warning "This install has its own dedicated coturn. Continuing may switch it to"
|
||||||
|
log_warning "the shared coturn service — the Calls plugin's TURN config in System"
|
||||||
|
log_warning "Console will need updating to the new credentials afterward (see below)."
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
mkdir -p "$DIR"
|
mkdir -p "$DIR"
|
||||||
ensure_docker_dir_ownership "$DIR"
|
ensure_docker_dir_ownership "$DIR"
|
||||||
cd "$DIR" || return 1
|
cd "$DIR" || return 1
|
||||||
|
|
||||||
local DB_PASS
|
# Reuse existing secrets on update — Postgres's volume keeps the password
|
||||||
local MM_SECRET
|
# from its first init, so overwriting .env with a fresh one locks
|
||||||
DB_PASS=$(generate_password 32)
|
# Mattermost out of its own database. Confirmed this was previously
|
||||||
MM_SECRET=$(generate_password 48)
|
# unconditional (regenerated every single rerun, silently breaking the DB
|
||||||
|
# connection) — fixed here as part of adding proper update detection.
|
||||||
|
local DB_PASS="" MM_SECRET=""
|
||||||
|
if [ "$MODE" = "update" ]; then
|
||||||
|
DB_PASS="$(grep '^POSTGRES_PASSWORD=' .env 2>/dev/null | cut -d= -f2-)"
|
||||||
|
[ "$_HAD_EMBEDDED_COTURN" = true ] && MM_SECRET="$(grep '^COTURN_SECRET=' .env 2>/dev/null | cut -d= -f2-)"
|
||||||
|
fi
|
||||||
|
[ -n "$DB_PASS" ] || DB_PASS=$(generate_password 32)
|
||||||
|
|
||||||
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
local UID_VAL GID_VAL
|
local UID_VAL GID_VAL
|
||||||
UID_VAL=$(id -u "$ACTUAL_USER")
|
UID_VAL=$(id -u "$ACTUAL_USER")
|
||||||
GID_VAL=$(id -g "$ACTUAL_USER")
|
GID_VAL=$(id -g "$ACTUAL_USER")
|
||||||
|
|
||||||
# Compute SITE_URL
|
# Compute SITE_URL — extra instances default to a distinct subdomain so
|
||||||
local SITE_URL="http://localhost:8065"
|
# they don't collide with the first instance's.
|
||||||
|
local _default_subdomain="mattermost${INSTANCE_SUFFIX:+-$INSTANCE_SUFFIX}"
|
||||||
|
local SITE_URL="http://localhost:${WEB_PORT}"
|
||||||
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
||||||
SITE_URL="https://mattermost.${SITE_DOMAIN}"
|
SITE_URL="https://${_default_subdomain}.${SITE_DOMAIN}"
|
||||||
fi
|
fi
|
||||||
local CONFIGURED_SITEURL=""
|
local CONFIGURED_SITEURL=""
|
||||||
prompt_text "Mattermost site URL [$SITE_URL]:" "$SITE_URL" CONFIGURED_SITEURL
|
prompt_text "Mattermost site URL [$SITE_URL]:" "$SITE_URL" CONFIGURED_SITEURL
|
||||||
@@ -270,45 +359,34 @@ networks:
|
|||||||
"
|
"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > docker-compose.yml << EOF
|
# ── TURN: shared coturn preferred, dedicated coturn as fallback ─────────
|
||||||
name: mattermost
|
# See services/coturn.sh's header for why one shared TURN server beats
|
||||||
|
# every service (Asterisk, each Mattermost instance, ...) running its
|
||||||
|
# own and fighting over host relay ports.
|
||||||
|
local USE_EMBEDDED_COTURN=true
|
||||||
|
local TURN_HOST_VAL="" TURN_PORT_VAL="" TURN_USERNAME_VAL="" TURN_PASSWORD_VAL=""
|
||||||
|
|
||||||
services:
|
if [ "$MODE" = "update" ] && [ "$_HAD_EMBEDDED_COTURN" = true ]; then
|
||||||
db:
|
USE_EMBEDDED_COTURN=true # preserve exactly — never switch on update
|
||||||
image: postgres:15-alpine
|
else
|
||||||
container_name: mattermost-db
|
ensure_coturn_user "$COTURN_CONSUMER"
|
||||||
hostname: mattermost-db
|
if [ -n "${COTURN_HOST:-}" ]; then
|
||||||
restart: unless-stopped
|
USE_EMBEDDED_COTURN=false
|
||||||
env_file: .env
|
TURN_HOST_VAL="$COTURN_HOST"; TURN_PORT_VAL="$COTURN_PORT"
|
||||||
volumes:
|
TURN_USERNAME_VAL="$COTURN_USERNAME"; TURN_PASSWORD_VAL="$COTURN_PASSWORD"
|
||||||
- ./db:/var/lib/postgresql/data
|
log_success "Using the shared coturn service — TURN username '$COTURN_USERNAME'."
|
||||||
${_CADDY_NET_BLOCK} healthcheck:
|
else
|
||||||
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
|
log_info "Shared coturn unavailable — this instance will run its own dedicated coturn."
|
||||||
interval: 10s
|
fi
|
||||||
timeout: 5s
|
fi
|
||||||
retries: 5
|
[ -n "$MM_SECRET" ] || MM_SECRET=$(generate_password 48)
|
||||||
|
|
||||||
mattermost:
|
local _COTURN_SERVICE=""
|
||||||
image: mattermost/mattermost-team-edition:latest
|
if [ "$USE_EMBEDDED_COTURN" = true ]; then
|
||||||
container_name: mattermost
|
_COTURN_SERVICE="
|
||||||
hostname: mattermost
|
|
||||||
restart: unless-stopped
|
|
||||||
env_file: .env
|
|
||||||
depends_on:
|
|
||||||
db:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./data:/mattermost/data
|
|
||||||
- ./logs:/mattermost/logs
|
|
||||||
- ./config:/mattermost/config
|
|
||||||
- ./plugins:/mattermost/plugins
|
|
||||||
ports:
|
|
||||||
- "8065:8065"
|
|
||||||
- "8443:8443/udp"
|
|
||||||
${_CADDY_NET_BLOCK}
|
|
||||||
coturn:
|
coturn:
|
||||||
image: coturn/coturn:latest
|
image: coturn/coturn:latest
|
||||||
container_name: mattermost-coturn
|
container_name: ${MM_CONTAINER}-coturn
|
||||||
network_mode: host
|
network_mode: host
|
||||||
user: root
|
user: root
|
||||||
command:
|
command:
|
||||||
@@ -327,7 +405,45 @@ ${_CADDY_NET_BLOCK}
|
|||||||
- --no-multicast-peers
|
- --no-multicast-peers
|
||||||
- --log-file=stdout
|
- --log-file=stdout
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
${_CADDY_NET_SECTION}
|
"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > docker-compose.yml << EOF
|
||||||
|
name: ${PROJECT}
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: ${DB_CONTAINER}
|
||||||
|
hostname: ${DB_CONTAINER}
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./db:/var/lib/postgresql/data
|
||||||
|
${_CADDY_NET_BLOCK} healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U \${POSTGRES_USER} -d \${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
mattermost:
|
||||||
|
image: mattermost/mattermost-team-edition:latest
|
||||||
|
container_name: ${MM_CONTAINER}
|
||||||
|
hostname: ${MM_CONTAINER}
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./data:/mattermost/data
|
||||||
|
- ./logs:/mattermost/logs
|
||||||
|
- ./config:/mattermost/config
|
||||||
|
- ./plugins:/mattermost/plugins
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT}:${WEB_PORT}"
|
||||||
|
- "${CALLS_UDP_PORT}:8443/udp"
|
||||||
|
${_CADDY_NET_BLOCK}${_COTURN_SERVICE}${_CADDY_NET_SECTION}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
@@ -341,15 +457,24 @@ POSTGRES_PASSWORD=$DB_PASS
|
|||||||
|
|
||||||
# Mattermost
|
# Mattermost
|
||||||
MM_SQLSETTINGS_DRIVERNAME=postgres
|
MM_SQLSETTINGS_DRIVERNAME=postgres
|
||||||
MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10
|
MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@${DB_CONTAINER}:5432/mattermost?sslmode=disable&connect_timeout=10
|
||||||
MM_SERVICESETTINGS_SITEURL=$SITE_URL
|
MM_SERVICESETTINGS_SITEURL=$SITE_URL
|
||||||
|
MM_SERVICESETTINGS_LISTENADDRESS=:${WEB_PORT}
|
||||||
MM_SERVICESETTINGS_ENABLELOCALMODE=true
|
MM_SERVICESETTINGS_ENABLELOCALMODE=true
|
||||||
MM_FILESETTINGS_DRIVERNAME=local
|
MM_FILESETTINGS_DRIVERNAME=local
|
||||||
MM_PLUGINSETTINGS_ENABLE=true
|
MM_PLUGINSETTINGS_ENABLE=true
|
||||||
|
|
||||||
# coturn HMAC secret for Mattermost Calls plugin
|
# ── TURN/STUN (Calls plugin) ─────────────────────────────────
|
||||||
|
$( [ "$USE_EMBEDDED_COTURN" = true ] \
|
||||||
|
&& echo "# This instance runs its own dedicated coturn (the coturn: service in docker-compose.yml)." \
|
||||||
|
|| echo "# Using the shared coturn service — see ~/docker/coturn/README.md." )
|
||||||
|
# coturn HMAC secret — only used if this instance runs its own dedicated coturn.
|
||||||
COTURN_SECRET=$MM_SECRET
|
COTURN_SECRET=$MM_SECRET
|
||||||
MM_REALM=${SITE_DOMAIN:-localhost}
|
MM_REALM=${SITE_DOMAIN:-localhost}
|
||||||
|
TURN_HOST=$TURN_HOST_VAL
|
||||||
|
TURN_PORT=$TURN_PORT_VAL
|
||||||
|
TURN_USERNAME=$TURN_USERNAME_VAL
|
||||||
|
TURN_PASSWORD=$TURN_PASSWORD_VAL
|
||||||
|
|
||||||
# PUID/PGID for file ownership
|
# PUID/PGID for file ownership
|
||||||
PUID=$UID_VAL
|
PUID=$UID_VAL
|
||||||
@@ -360,34 +485,99 @@ EOF
|
|||||||
mkdir -p data logs config plugins db
|
mkdir -p data logs config plugins db
|
||||||
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
||||||
|
|
||||||
# Open required firewall ports
|
# ── Firewall ─────────────────────────────────────────────────────────────
|
||||||
if command -v ufw &>/dev/null; then
|
if command -v ufw &>/dev/null; then
|
||||||
ufw allow 8443/udp comment "Mattermost Calls RTC"
|
ufw allow "${WEB_PORT}/tcp" comment "Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}"
|
||||||
ufw allow 3479/udp; ufw allow 3479/tcp
|
ufw allow "${CALLS_UDP_PORT}/udp" comment "Mattermost Calls RTC${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}"
|
||||||
ufw allow 49153:49352/udp comment "Mattermost coturn relay"
|
if [ "$USE_EMBEDDED_COTURN" = true ]; then
|
||||||
|
ufw allow 3479/udp; ufw allow 3479/tcp
|
||||||
|
ufw allow 49153:49352/udp comment "Mattermost coturn relay"
|
||||||
|
fi
|
||||||
|
# Shared coturn opens its own ports once, at its own install time.
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
log_success "Mattermost configured at $DIR"
|
log_success "Mattermost configured at $DIR"
|
||||||
|
|
||||||
configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost"
|
configure_caddy_for_service "Mattermost${INSTANCE_SUFFIX:+ ($INSTANCE_SUFFIX)}" "${MM_CONTAINER}:${WEB_PORT}" "$_default_subdomain"
|
||||||
|
|
||||||
|
# Exact ICEServersConfigs JSON to paste into System Console — verified
|
||||||
|
# against the Calls plugin's actual config schema (plugin.json): this
|
||||||
|
# field takes a fixed username/credential pair, which is what a
|
||||||
|
# --lt-cred-mech coturn (shared or dedicated) expects, as opposed to the
|
||||||
|
# "TURN Static Auth Secret" field (HMAC/REST-API mode, which coturn
|
||||||
|
# cannot run at the same time as --lt-cred-mech on one instance).
|
||||||
|
local _ICE_JSON _turn_config_md
|
||||||
|
if [ "$USE_EMBEDDED_COTURN" = true ]; then
|
||||||
|
_ICE_JSON="[{\"urls\":[\"turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp\"],\"username\":\"static\",\"credential\":\"see COTURN_SECRET below — this dedicated coturn uses use-auth-secret/HMAC, not a fixed credential\"}]"
|
||||||
|
_turn_config_md="This instance runs its own dedicated coturn (HMAC/REST-API auth):
|
||||||
|
- TURN Server URI: \`turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp\`
|
||||||
|
- System Console → Plugins → Calls → **TURN Static Auth Secret**: value of \`COTURN_SECRET\` in \`.env\`"
|
||||||
|
else
|
||||||
|
_ICE_JSON="[{\"urls\":[\"turn:${TURN_HOST_VAL}:${TURN_PORT_VAL}?transport=udp\"],\"username\":\"${TURN_USERNAME_VAL}\",\"credential\":\"${TURN_PASSWORD_VAL}\"}]"
|
||||||
|
_turn_config_md="This instance uses the shared coturn service (fixed username/credential, not HMAC):
|
||||||
|
- System Console → Plugins → Calls → **ICE Servers Configurations** — paste:
|
||||||
|
\`\`\`json
|
||||||
|
$_ICE_JSON
|
||||||
|
\`\`\`
|
||||||
|
- Leave **TURN Static Auth Secret** empty — that field is for the OTHER auth
|
||||||
|
mode coturn supports and doesn't apply here."
|
||||||
|
fi
|
||||||
|
[ "${CALLS_UDP_PORT}" != "8443" ] && _turn_config_md="$_turn_config_md
|
||||||
|
- System Console → Plugins → Calls → **ICE Host Port Override**: \`${CALLS_UDP_PORT}\` (this instance publishes Calls RTC on a non-default port)"
|
||||||
|
|
||||||
write_readme "$DIR" << MD
|
write_readme "$DIR" << MD
|
||||||
# Mattermost
|
# Mattermost${INSTANCE_SUFFIX:+ — $INSTANCE_SUFFIX}
|
||||||
|
|
||||||
Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay.
|
Team messaging with voice/video calls. PostgreSQL backend.
|
||||||
|
$( [ -n "$INSTANCE_SUFFIX" ] && echo "
|
||||||
|
|
||||||
|
This is a separate, fully isolated instance (own server, own database, own
|
||||||
|
TURN credential) — not a Team within another instance. See that instance's
|
||||||
|
own README for its own access details." )
|
||||||
|
|
||||||
## Access
|
## Access
|
||||||
- URL: $SITE_URL (or http://localhost:8065)
|
- URL: $SITE_URL (or http://localhost:${WEB_PORT})
|
||||||
- First run: create admin account at the URL above
|
- First run: create admin account at the URL above — this account becomes
|
||||||
|
System Admin automatically.
|
||||||
|
|
||||||
|
## Teams
|
||||||
|
Team Edition (the free edition this installer uses) includes multiple Teams
|
||||||
|
natively — separate spaces (their own channels, their own members) on the
|
||||||
|
same server, same database, same login. No Enterprise license needed; this
|
||||||
|
is the resource-efficient alternative to running a second Mattermost
|
||||||
|
instance for a second group.
|
||||||
|
|
||||||
|
Create one:
|
||||||
|
- Click the **+** at the bottom of the team sidebar (the narrow column on
|
||||||
|
the far left) → **Create a new team**, or
|
||||||
|
- System Console → User Management → Teams → **Create Team**
|
||||||
|
|
||||||
|
Add people to a team:
|
||||||
|
- Team name (top-left) → **Invite People** → share the invite link, or send
|
||||||
|
email invites (requires SMTP — System Console → Environment → SMTP), or
|
||||||
|
- System Console → User Management → Teams → the team → **Add Members**
|
||||||
|
(adds existing server accounts directly, no invite flow)
|
||||||
|
|
||||||
|
Users can belong to more than one team and switch between them via the team
|
||||||
|
sidebar icons. By default any user can create a team — restrict that at
|
||||||
|
System Console → User Management → Permissions if you only want admins
|
||||||
|
creating them.
|
||||||
|
|
||||||
|
By default, Direct Messages ignore team boundaries — anyone on the server
|
||||||
|
can DM anyone else regardless of shared team membership. To limit the DM
|
||||||
|
picker to teammates only: **System Console → Site Configuration → Users and
|
||||||
|
Teams → "Enable users to open Direct Message channels with" → Any member of
|
||||||
|
the team** (free in Team Edition, no license needed). This is a UI filter,
|
||||||
|
not a hard boundary — it doesn't hide DM channels that already exist, and a
|
||||||
|
user in multiple teams can still DM anyone across all of them, not just the
|
||||||
|
team currently open. If you need real isolation between groups rather than
|
||||||
|
a tidier picker, that means separate Mattermost instances, not this setting.
|
||||||
|
|
||||||
## Voice/Video Calls (Calls plugin)
|
## Voice/Video Calls (Calls plugin)
|
||||||
Port 8443/udp must be open on your router/firewall.
|
Port ${CALLS_UDP_PORT}/udp must be open on your router/firewall.
|
||||||
coturn relay runs on port 3479 (HMAC secret in .env).
|
|
||||||
|
|
||||||
Configure in Mattermost: System Console → Plugins → Calls:
|
${_turn_config_md}
|
||||||
- TURN Server URI: turn:YOUR_DOMAIN_OR_IP:3479?transport=udp
|
|
||||||
- TURN Credentials: use static-auth-secret (see .env COTURN_SECRET)
|
|
||||||
|
|
||||||
## Manage
|
## Manage
|
||||||
\`\`\`bash
|
\`\`\`bash
|
||||||
@@ -413,9 +603,9 @@ MD
|
|||||||
echo ""
|
echo ""
|
||||||
echo " Access at: $SITE_URL"
|
echo " Access at: $SITE_URL"
|
||||||
echo " First run: open the URL above and create your admin account."
|
echo " First run: open the URL above and create your admin account."
|
||||||
echo " Calls plugin: System Console → Plugins → Calls to configure coturn."
|
echo " Teams: team sidebar '+' → Create a new team (see README.md — no"
|
||||||
echo " TURN URI: turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp"
|
echo " Enterprise license needed, Team Edition includes this)."
|
||||||
echo " Auth secret: see COTURN_SECRET in $DIR/.env"
|
echo " Calls plugin TURN config: see README.md (System Console → Plugins → Calls)."
|
||||||
echo ""
|
echo ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -190,10 +190,18 @@ install_nextcloud() {
|
|||||||
ensure_docker_dir_ownership "$DIR"
|
ensure_docker_dir_ownership "$DIR"
|
||||||
cd "$DIR" || return 1
|
cd "$DIR" || return 1
|
||||||
|
|
||||||
local DB_PASS
|
# Reused across reruns if already set — the MariaDB volume keeps DB_PASS
|
||||||
DB_PASS=$(generate_password 32)
|
# from its first init (regenerating it would lock Nextcloud out of its
|
||||||
local NC_ADMIN_PASS
|
# own database), and NEXTCLOUD_ADMIN_USER/PASSWORD are only consulted by
|
||||||
NC_ADMIN_PASS=$(generate_password 16)
|
# the container on its very first boot to create the admin account —
|
||||||
|
# printing a fresh NC_ADMIN_PASS on every rerun would silently show a
|
||||||
|
# password that was never actually applied to the existing account.
|
||||||
|
local DB_PASS=""
|
||||||
|
[ -f ".env" ] && DB_PASS="$(grep '^MYSQL_PASSWORD=' .env | cut -d= -f2-)"
|
||||||
|
[ -n "$DB_PASS" ] || DB_PASS="$(generate_password 32)"
|
||||||
|
local NC_ADMIN_PASS=""
|
||||||
|
[ -f ".env" ] && NC_ADMIN_PASS="$(grep '^NEXTCLOUD_ADMIN_PASSWORD=' .env | cut -d= -f2-)"
|
||||||
|
[ -n "$NC_ADMIN_PASS" ] || NC_ADMIN_PASS="$(generate_password 16)"
|
||||||
local TZ_VAL="${SITE_TZ:-UTC}"
|
local TZ_VAL="${SITE_TZ:-UTC}"
|
||||||
|
|
||||||
# ── Dockerfile ──────────────────────────────────────────────────────────
|
# ── Dockerfile ──────────────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user