From 5aa4a8c91ed2ac9e4ef380b898c4121679478232 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:34 +0000 Subject: [PATCH 01/27] services: add standalone bootstrap to arm, authelia, backup, borg-backup, caddy, ntfy Each service can now be run directly with sudo bash .sh on any machine with Docker installed, without needing the full post-install repo. Uses the shared bootstrap pattern from docs/standalone-template.sh. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/arm.sh | 154 ++++++++++++++++++++++++++++++++++++++ services/authelia.sh | 157 ++++++++++++++++++++++++++++++++++++++- services/backup.sh | 159 ++++++++++++++++++++++++++++++++++++++++ services/borg-backup.sh | 159 ++++++++++++++++++++++++++++++++++++++++ services/caddy.sh | 154 ++++++++++++++++++++++++++++++++++++++ services/ntfy.sh | 154 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 936 insertions(+), 1 deletion(-) diff --git a/services/arm.sh b/services/arm.sh index 6614c4e..c2b68e1 100644 --- a/services/arm.sh +++ b/services/arm.sh @@ -2,10 +2,161 @@ # services/arm.sh — Automatic Ripping Machine: rip DVDs, Blu-rays, CDs. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash arm.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- A.R.M. ----). # Own ~/docker/arm/ with a standalone docker-compose.yml + .env. Detects # optical drives at install time; add more /dev/srN entries manually after. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service arm media "Automatic Ripping Machine — rip DVDs, Blu-rays, CDs" 8080 install_arm() { @@ -140,3 +291,6 @@ MD echo " Complete setup in browser on first visit." echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_arm diff --git a/services/authelia.sh b/services/authelia.sh index b11e138..dddb59a 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -1,7 +1,159 @@ #!/bin/bash # services/authelia.sh — Authelia SSO + 2FA portal (forward-auth for Caddy). -# Ported from the authelia-setup repo / the monolith's working block. # Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash authelia.sh +# (Docker must already be installed when run standalone) +# +# Ported from the authelia-setup repo / the monolith's working block. + +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── register_service authelia homelab "SSO + 2FA auth portal (Authelia)" 9091 @@ -315,3 +467,6 @@ README_MD echo " README: $AUTHELIA_DIR/README.md" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_authelia diff --git a/services/backup.sh b/services/backup.sh index dd8d0d5..66fae1c 100644 --- a/services/backup.sh +++ b/services/backup.sh @@ -2,6 +2,10 @@ # services/backup.sh — Full Docker-service backup via Kopia. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash backup.sh +# (Docker must already be installed when run standalone) +# # Backs up each entire ~/docker// directory (compose file, config, data, # databases — everything needed to restore from nothing). Per-service behaviour: # Minecraft instances — flush world to disk (save-all), snapshot, no downtime @@ -15,6 +19,158 @@ # backup_kopia.sh worker (run directly or via systemd timer) # restore_kopia.sh interactive restore helper +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service backup backup "Encrypted backup of all Docker services (full restore)" install_backup() { @@ -519,3 +675,6 @@ SVCEOF echo "" log_success "Backup configured." } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_backup diff --git a/services/borg-backup.sh b/services/borg-backup.sh index a45c430..d2d1efb 100644 --- a/services/borg-backup.sh +++ b/services/borg-backup.sh @@ -2,6 +2,10 @@ # services/borg-backup.sh — Full Docker-service backup via Borg. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash borg-backup.sh +# (Docker must already be installed when run standalone) +# # Backs up each entire ~/docker// directory (compose file, config, # data, databases — everything needed to restore from nothing). # Minecraft instances: flush world (save-all), snapshot, no downtime @@ -15,6 +19,158 @@ # backup_borg.sh worker (run directly or via systemd timer) # restore_borg.sh interactive restore helper +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service borg-backup backup "Encrypted backup of all Docker services via Borg" install_borg_backup() { @@ -468,3 +624,6 @@ SVCEOF echo " on this machine (e.g. USB drive, password manager, offsite)." echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_borg_backup diff --git a/services/caddy.sh b/services/caddy.sh index 69e5606..9f1e348 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -2,6 +2,10 @@ # services/caddy.sh — Caddy reverse proxy + automatic HTTPS. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash caddy.sh +# (Docker must already be installed when run standalone) +# # Caddy is the front door for the homelab: it terminates TLS (automatic # Let's Encrypt certificates), reverse-proxies to your other services, and # writes JSON access logs that CrowdSec reads for intrusion prevention. @@ -10,6 +14,153 @@ # configure_caddy_for_service helper does this automatically), then Caddy is # reloaded without downtime. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service caddy homelab "Reverse proxy + automatic HTTPS (Caddy)" 443 install_caddy() { @@ -239,3 +390,6 @@ CADDY_README echo " - Configure services you want to expose" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_caddy diff --git a/services/ntfy.sh b/services/ntfy.sh index 060f433..8ab453f 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -1,6 +1,157 @@ #!/bin/bash # services/ntfy.sh — ntfy self-hosted push notification server. # Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash ntfy.sh +# (Docker must already be installed when run standalone) + +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── register_service ntfy utilities "Self-hosted push notifications (ntfy)" 8090 @@ -95,3 +246,6 @@ MD echo " Subscribe on phone: ntfy app → Add subscription → localhost:8090/mytopic" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_ntfy From 4b7a2c050bbf07f563caf2ddc840cc062820c99c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:35 +0000 Subject: [PATCH 02/27] services: add standalone bootstrap to crowdsec, ddclient, emby, fmd, frigate-audio, frigate-notify Each service can now be run directly with sudo bash .sh on any machine with Docker installed, without needing the full post-install repo. Uses the shared bootstrap pattern from docs/standalone-template.sh. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/crowdsec.sh | 70 ++++++++++++++++ services/ddclient.sh | 154 +++++++++++++++++++++++++++++++++++ services/emby.sh | 154 +++++++++++++++++++++++++++++++++++ services/fmd.sh | 154 +++++++++++++++++++++++++++++++++++ services/frigate-audio.sh | 159 +++++++++++++++++++++++++++++++++++++ services/frigate-notify.sh | 154 +++++++++++++++++++++++++++++++++++ 6 files changed, 845 insertions(+) diff --git a/services/crowdsec.sh b/services/crowdsec.sh index f4b384d..e35a8d2 100644 --- a/services/crowdsec.sh +++ b/services/crowdsec.sh @@ -2,6 +2,10 @@ # services/crowdsec.sh — CrowdSec intrusion prevention (fail2ban successor). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash crowdsec.sh +# (Docker must already be installed when run standalone) +# # CrowdSec is a SYSTEM install (apt repo + agent), NOT a docker-compose service: # • Installs the CrowdSec agent and the iptables firewall bouncer (enforces bans). # • Installs detection collections for SSH, Linux, Caddy and base HTTP scenarios. @@ -12,6 +16,69 @@ # There is no ~/docker/crowdsec compose; we only create a docs-only folder there # with a README pointing at the real config under /etc/crowdsec. +# ── 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; } + + 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}'" + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service crowdsec homelab "Intrusion prevention: bans + geo + IP reputation (CrowdSec)" install_crowdsec() { @@ -219,3 +286,6 @@ CROWDSEC_README echo " Show metrics: sudo cscli metrics" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_crowdsec diff --git a/services/ddclient.sh b/services/ddclient.sh index 3d8aef5..0057e7d 100644 --- a/services/ddclient.sh +++ b/services/ddclient.sh @@ -2,11 +2,162 @@ # services/ddclient.sh — Dynamic DNS updater (ddclient). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash ddclient.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- DDCLIENT ----). # Own ~/docker/ddclient/ with a standalone docker-compose.yml + config. # Supports Cloudflare, DuckDNS, No-IP, and many other providers. # Edit config/ddclient.conf before starting — no web UI. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service ddclient utilities "Dynamic DNS updater — keep your domain pointing at your home IP (ddclient)" install_ddclient() { @@ -129,3 +280,6 @@ MD echo " Docs: https://ddclient.net/" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_ddclient diff --git a/services/emby.sh b/services/emby.sh index 96095da..fbce917 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -2,11 +2,162 @@ # services/emby.sh — Media server for movies, TV, and music (Emby). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash emby.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- EMBY ----). # Own ~/docker/emby/ with a standalone docker-compose.yml + .env. Hardware # transcoding is left commented in the compose (uncomment the /dev/dri block # once you've confirmed your GPU) to match the original behavior. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service emby media "Media server — movies, TV, music (Emby)" 8096 install_emby() { @@ -113,3 +264,6 @@ MD echo " Access at: http://localhost:8096" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_emby diff --git a/services/fmd.sh b/services/fmd.sh index db11e5f..8787d23 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -2,10 +2,161 @@ # services/fmd.sh — FindMyDevice server for Android device tracking (FMD). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash fmd.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- FINDMYDEVICE ----). # Own ~/docker/fmd/ with a standalone docker-compose.yml + .env. # Mobile app: "FindMyDevice" on F-Droid — not the Play Store version. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service fmd utilities "Android device tracking — alternative to Google Find My Device (FMD)" 8084 install_fmd() { @@ -102,3 +253,6 @@ MD echo " Mobile app: FindMyDevice on F-Droid" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_fmd diff --git a/services/frigate-audio.sh b/services/frigate-audio.sh index 3bca1fb..4c1449c 100644 --- a/services/frigate-audio.sh +++ b/services/frigate-audio.sh @@ -3,6 +3,10 @@ # full-stack with audio support and push notifications via ntfy. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash frigate-audio.sh +# (Docker must already be installed when run standalone) +# # Based on outis1one/frigate_w_audio. This is the full stack: # Frigate 0.17 NVR, face recognition, LPR, motion detection # Mosquitto MQTT broker (events bus between Frigate and notify) @@ -20,6 +24,158 @@ # • audio-ready camera config template # • Frigate 0.17 schema with face recognition + LPR pre-configured +# ── 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 + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'a-zA-Z0-9' < /dev/urandom | head -c "$_len" + } + + # 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service frigate-audio cameras "Frigate NVR + MQTT + push notifications (audio-ready stack)" 8971 install_frigate-audio() { @@ -562,3 +718,6 @@ FNEOF fi echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_frigate-audio diff --git a/services/frigate-notify.sh b/services/frigate-notify.sh index 4ffed19..81c75c0 100644 --- a/services/frigate-notify.sh +++ b/services/frigate-notify.sh @@ -2,11 +2,162 @@ # services/frigate-notify.sh — Push notification sidecar for Frigate events. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash frigate-notify.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- FRIGATE-NOTIFY ----). # Own ~/docker/frigate-notify/ with a standalone docker-compose.yml + config.yml. # Supports ntfy, Pushover, Discord, Gotify, Telegram, and more. No web UI. # Auto-detects local Frigate and ntfy installs to pre-fill config defaults. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service frigate-notify cameras "Push alerts for Frigate detection events (Frigate-Notify)" install_frigate-notify() { @@ -140,3 +291,6 @@ MD echo " Docs: https://frigate-notify.0x2142.com" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_frigate-notify From 5749c7adb30ae3f08d7c9e350898380aaaaf54ac Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:35 +0000 Subject: [PATCH 03/27] =?UTF-8?q?Add=20tools/rsync-backup.sh=20=E2=80=94?= =?UTF-8?q?=20interactive=20rsync=20mirror=20backup=20with=20versioned=20d?= =?UTF-8?q?eletes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dest/current/ stays a plain mirror; deleted/overwritten files are moved to dest/versions/YYYY-MM-DD/ so accidental deletions are recoverable while intentional --delete still propagates. Unchanged files in version folders are hardlinked via --link-dest to avoid extra disk cost. Supports local and remote (SSH) source/destination paths, saved jobs, and a cron hint. https://claude.ai/code/session_015SmW4EAD6mMLZGVCygy3GR --- tools/rsync-backup.sh | 378 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 378 insertions(+) create mode 100755 tools/rsync-backup.sh diff --git a/tools/rsync-backup.sh b/tools/rsync-backup.sh new file mode 100755 index 0000000..b32c696 --- /dev/null +++ b/tools/rsync-backup.sh @@ -0,0 +1,378 @@ +#!/usr/bin/env bash +# tools/rsync-backup.sh — Interactive rsync mirror backup with versioned deletes. +# +# Usage: +# bash rsync-backup.sh +# +# How it works: +# - dest/current/ is always a plain mirror of the source (any file browser works) +# - dest/versions/YYYY-MM-DD/ holds files that were deleted or overwritten that day +# - Unchanged files in versions/ are hardlinked (no extra disk cost) +# - --delete is active, so intentional source deletions propagate; accidental +# deletes are safe because the file lands in today's versions/ folder first +# +# Requirements: +# - rsync installed on both source and destination hosts +# - Passwordless SSH access already configured (ssh-copy-id or equivalent) +# Run: ssh-copy-id user@remotehost before using this script for remote jobs. + +set -euo pipefail + +# ── Colours ─────────────────────────────────────────────────────────────────── +info() { printf '\033[0;34m[INFO]\033[0m %s\n' "$*"; } +ok() { printf '\033[0;32m[OK]\033[0m %s\n' "$*"; } +warn() { printf '\033[1;33m[WARN]\033[0m %s\n' "$*"; } +err() { printf '\033[0;31m[ERROR]\033[0m %s\n' "$*" >&2; } + +# ── Config file ─────────────────────────────────────────────────────────────── +CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/rsync-backup" +CONFIG_FILE="$CONFIG_DIR/jobs.conf" + +save_job() { + # save_job NAME SOURCE DEST EXCLUDES + mkdir -p "$CONFIG_DIR" + # Remove any existing entry for this name + if [[ -f "$CONFIG_FILE" ]]; then + grep -v "^JOB_${1}=" "$CONFIG_FILE" > "${CONFIG_FILE}.tmp" && mv "${CONFIG_FILE}.tmp" "$CONFIG_FILE" || true + fi + printf 'JOB_%s=%s\n' "$1" "$(printf '%q %q %q' "$2" "$3" "$4")" >> "$CONFIG_FILE" + chmod 600 "$CONFIG_FILE" +} + +load_jobs() { + [[ -f "$CONFIG_FILE" ]] || return 0 + # shellcheck source=/dev/null + source "$CONFIG_FILE" +} + +list_jobs() { + [[ -f "$CONFIG_FILE" ]] || return 0 + grep '^JOB_' "$CONFIG_FILE" | sed 's/^JOB_/ /' | sed 's/=.*//' +} + +# ── Helpers ─────────────────────────────────────────────────────────────────── +ask() { + # ask PROMPT DEFAULT VARNAME + local _prompt="$1" _default="$2" _var="$3" _input="" + read -r -p " $_prompt${_default:+ [$_default]}: " _input + printf -v "$_var" '%s' "${_input:-$_default}" +} + +ask_yn() { + # ask_yn PROMPT DEFAULT(y/n) VARNAME + local _prompt="$1" _default="$2" _var="$3" _input="" + read -r -p " $_prompt [${_default^^}/${_default,,}]: " _input + _input="${_input:-$_default}" + printf -v "$_var" '%s' "${_input,,}" +} + +require_rsync() { + command -v rsync &>/dev/null && return 0 + err "rsync is not installed. Install with: sudo apt install rsync" + exit 1 +} + +test_ssh() { + local host="$1" + info "Testing SSH connection to $host ..." + if ssh -o BatchMode=yes -o ConnectTimeout=5 "$host" true 2>/dev/null; then + ok "SSH connection OK" + return 0 + else + err "Cannot connect to $host via SSH without a password." + echo "" + echo " Passwordless SSH is required. Set it up with:" + echo " ssh-copy-id $host" + echo "" + return 1 + fi +} + +is_remote() { + # returns true if path contains user@host: or host: + [[ "$1" == *:* ]] +} + +remote_host() { + echo "${1%%:*}" +} + +# ── Core backup logic ───────────────────────────────────────────────────────── +run_backup() { + local name="$1" source="$2" dest="$3" excludes="$4" + + local today; today="$(date +%Y-%m-%d)" + local current="${dest%/}/current" + local versions_today="${dest%/}/versions/${today}" + local versions_prev="${dest%/}/versions/$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo 'prev')" + + echo "" + info "Job: $name" + info "Source : $source" + info "Dest : $current" + info "Versions → $versions_today" + echo "" + + # Build exclude args + local -a excl_args=() + if [[ -n "$excludes" ]]; then + IFS=',' read -ra _excl_list <<< "$excludes" + for _e in "${_excl_list[@]}"; do + excl_args+=(--exclude="${_e// /}") + done + fi + + # Build rsync command + local -a cmd=( + rsync + -avh + --delete + --backup + --backup-dir="$versions_today" + --progress + --stats + "${excl_args[@]}" + ) + + # Add --link-dest if yesterday's versions folder exists (saves space via hardlinks) + if is_remote "$dest"; then + local rhost; rhost="$(remote_host "$dest")" + local remote_prev="${dest#*:}" + remote_prev="${remote_prev%/}/versions/$(date -d 'yesterday' +%Y-%m-%d 2>/dev/null || date -v-1d +%Y-%m-%d 2>/dev/null || echo 'prev')" + if ssh "$rhost" "[ -d '$remote_prev' ]" 2>/dev/null; then + cmd+=(--link-dest="$remote_prev") + fi + else + if [[ -d "$versions_prev" ]]; then + cmd+=(--link-dest="$versions_prev") + fi + fi + + cmd+=("${source%/}/" "$current/") + + info "Running: ${cmd[*]}" + echo "" + + if "${cmd[@]}"; then + echo "" + ok "Backup complete: $name" + ok "Mirror : $current" + ok "Changed/deleted files saved to: $versions_today" + else + local rc=$? + # rsync exit 24 = vanished files (harmless) + if [[ $rc -eq 24 ]]; then + warn "Some files vanished during sync (exit 24) — this is usually harmless." + ok "Backup finished: $name" + else + err "rsync exited with code $rc — check output above." + return $rc + fi + fi +} + +# ── Prune old versions ──────────────────────────────────────────────────────── +prune_versions() { + local dest="$1" keep_days="$2" + + if is_remote "$dest"; then + local rhost; rhost="$(remote_host "$dest")" + local rpath="${dest#*:}" + local versions_dir="${rpath%/}/versions" + info "Pruning remote versions older than $keep_days days from $rhost:$versions_dir ..." + ssh "$rhost" "find '$versions_dir' -maxdepth 1 -mindepth 1 -type d -mtime +${keep_days} -exec rm -rf {} + 2>/dev/null; echo done" \ + && ok "Prune complete" || warn "Prune failed (non-fatal)" + else + local versions_dir="${dest%/}/versions" + if [[ -d "$versions_dir" ]]; then + info "Pruning local versions older than $keep_days days from $versions_dir ..." + find "$versions_dir" -maxdepth 1 -mindepth 1 -type d -mtime "+${keep_days}" \ + -exec rm -rf {} + 2>/dev/null || true + ok "Prune complete" + fi + fi +} + +# ── Wizard: create / edit a job ─────────────────────────────────────────────── +wizard_job() { + echo "" + echo " ── Job Configuration ─────────────────────────────────────" + echo "" + echo " Paths can be local (/home/user/photos) or remote (user@host:/path)." + echo " Remote paths require passwordless SSH (ssh-copy-id user@host)." + echo "" + + local name="" source="" dest="" excludes="" save="" + + ask "Job name (letters/numbers/hyphens)" "my-backup" name + name="${name//[^a-zA-Z0-9_-]/-}" + + ask "Source path" "" source + [[ -z "$source" ]] && { warn "Source cannot be empty."; return 1; } + + ask "Destination base path (current/ and versions/ created here)" "" dest + [[ -z "$dest" ]] && { warn "Destination cannot be empty."; return 1; } + + ask "Exclude patterns, comma-separated (e.g. *.tmp,Thumbs.db) or leave blank" "" excludes + + echo "" + # Test SSH if remote is involved + if is_remote "$source"; then + test_ssh "$(remote_host "$source")" || return 1 + fi + if is_remote "$dest"; then + test_ssh "$(remote_host "$dest")" || return 1 + fi + + echo "" + ask_yn "Save this job for future runs?" "y" save + if [[ "$save" == "y" ]]; then + save_job "$name" "$source" "$dest" "$excludes" + ok "Job '$name' saved to $CONFIG_FILE" + fi + + echo "" + local run_now="" + ask_yn "Run the backup now?" "y" run_now + if [[ "$run_now" == "y" ]]; then + run_backup "$name" "$source" "$dest" "$excludes" + + echo "" + local do_prune="" + ask_yn "Prune old version folders now?" "y" do_prune + if [[ "$do_prune" == "y" ]]; then + local keep="" + ask "Keep versions for how many days?" "30" keep + prune_versions "$dest" "$keep" + fi + fi +} + +# ── Run a saved job ─────────────────────────────────────────────────────────── +run_saved_job() { + load_jobs + + local names=() + while IFS= read -r line; do + [[ -n "$line" ]] && names+=("$line") + done < <(list_jobs) + + if [[ ${#names[@]} -eq 0 ]]; then + warn "No saved jobs found." + return 0 + fi + + echo "" + echo " Saved jobs:" + echo "" + local i=1 + for n in "${names[@]}"; do + printf " %2d) %s\n" "$i" "$n" + i=$(( i + 1 )) + done + echo "" + + local choice="" + read -r -p " Run job number [1]: " choice + choice="${choice:-1}" + [[ ! "$choice" =~ ^[0-9]+$ ]] && { warn "Invalid."; return 1; } + + local idx=$(( choice - 1 )) + [[ "$idx" -lt 0 || "$idx" -ge ${#names[@]} ]] && { warn "Invalid selection."; return 1; } + + local job_name="${names[$idx]// /}" + local var="JOB_${job_name}" + local job_val="${!var:-}" + [[ -z "$job_val" ]] && { err "Could not load job '$job_name'."; return 1; } + + # Parse the three quoted fields back out + eval "local _parts=($job_val)" + local src="${_parts[0]}" dst="${_parts[1]}" excl="${_parts[2]:-}" + + run_backup "$job_name" "$src" "$dst" "$excl" + + echo "" + local do_prune="" + ask_yn "Prune old version folders now?" "n" do_prune + if [[ "$do_prune" == "y" ]]; then + local keep="" + ask "Keep versions for how many days?" "30" keep + prune_versions "$dst" "$keep" + fi +} + +# ── Cron helper ─────────────────────────────────────────────────────────────── +show_cron_hint() { + echo "" + echo " ── Automate with cron ────────────────────────────────────" + echo "" + echo " To run a saved job automatically, add a line like this to crontab" + echo " (edit with: crontab -e):" + echo "" + echo " # Daily at 2 AM — run job 'my-backup'" + echo " 0 2 * * * bash $PWD/$(basename "$0") --job my-backup >> /var/log/rsync-backup.log 2>&1" + echo "" + echo " Or use a systemd timer — ask the setup wizard for details." + echo "" +} + +# ── Non-interactive job run (--job NAME) ────────────────────────────────────── +run_job_by_name() { + local name="$1" + load_jobs + local var="JOB_${name}" + local job_val="${!var:-}" + [[ -z "$job_val" ]] && { err "No saved job named '$name'. Run without --job to create one."; exit 1; } + eval "local _parts=($job_val)" + local src="${_parts[0]}" dst="${_parts[1]}" excl="${_parts[2]:-}" + run_backup "$name" "$src" "$dst" "$excl" +} + +# ── Main ────────────────────────────────────────────────────────────────────── +main() { + # Non-interactive mode + if [[ "${1:-}" == "--job" ]]; then + require_rsync + run_job_by_name "${2:?--job requires a job name}" + exit $? + fi + + require_rsync + + echo "" + echo "┌──────────────────────────────────────────────────────────┐" + echo "│ rsync Mirror Backup with Versioned Deletes │" + echo "│ │" + echo "│ dest/current/ — plain mirror (always current) │" + echo "│ dest/versions/DATE/ — deleted/changed files by day │" + echo "└──────────────────────────────────────────────────────────┘" + echo "" + echo " NOTE: Passwordless SSH is required for remote paths." + echo " Set up with: ssh-copy-id user@remotehost" + echo "" + + while true; do + echo " What would you like to do?" + echo " 1) Create a new backup job (and optionally run it)" + echo " 2) Run a saved job" + echo " 3) Show cron/automation hint" + echo " 0) Quit" + echo "" + read -r -p " Choice [1]: " action + action="${action:-1}" + echo "" + + case "$action" in + 1) wizard_job ;; + 2) run_saved_job ;; + 3) show_cron_hint ;; + 0|q|Q) break ;; + *) warn "Invalid choice." ;; + esac + echo "" + done + + ok "Done." +} + +main "$@" From 8bc0a95bbb64c10b0bf0f1e6fba4fc7d2c7423cb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:35 +0000 Subject: [PATCH 04/27] Delete tools/additional_directories.sh --- tools/additional_directories.sh | 430 -------------------------------- 1 file changed, 430 deletions(-) delete mode 100644 tools/additional_directories.sh diff --git a/tools/additional_directories.sh b/tools/additional_directories.sh deleted file mode 100644 index 4fed898..0000000 --- a/tools/additional_directories.sh +++ /dev/null @@ -1,430 +0,0 @@ -#!/usr/bin/env bash -# additional_directories.sh — Give FileBrowser users access to extra folders. -# -# Placed in ~/docker/filebrowser/ by the installer. -# Requires: curl, jq, docker (sudo apt install curl jq) -# -# FileBrowser gives each user one root directory (scope). This script lets -# you mount additional folders into that root so the user sees them alongside -# their own files. -# -# IMPORTANT — avoid nested bind-mounts: -# If a user's scope lives inside the main data bind-mount (/data/...) adding -# extra folders would require nesting one bind-mount inside another, which -# Docker does not handle reliably. This script detects that situation and -# migrates the user's scope into the named volume (/srv) instead, where -# additional bind-mounts work cleanly. -# -# Old (broken): scope=/data/users/alice → inside /srv/data bind-mount -# New (correct): scope=/alice → inside fb_users named volume -# Mounts added: /srv/alice/my-files → /host/data/users/alice/ -# /srv/alice/music → /host/data/music/ -# -set -Eeuo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COMPOSE_FILE="$SCRIPT_DIR/docker-compose.yml" -ENV_FILE="$SCRIPT_DIR/.env" -FB_URL="${FB_URL:-http://localhost:8085}" -TOKEN="" - -# ── Output helpers ──────────────────────────────────────────────────────────── -if [[ -t 1 ]]; then - B=$'\e[1m' R=$'\e[0m' GRN=$'\e[32m' RED=$'\e[31m' YEL=$'\e[33m' DIM=$'\e[2m' -else - B="" R="" GRN="" RED="" YEL="" DIM="" -fi - -die() { echo "${RED}ERROR:${R} $*" >&2; exit 1; } -ok() { echo " ${GRN}✓${R} $*"; } -warn() { echo " ${YEL}!${R} $*"; } -info() { echo " $*"; } -errmsg() { echo " ${RED}✗${R} $*" >&2; } -hr() { printf ' %s\n' "────────────────────────────────────────────"; } -banner() { echo; hr; printf " ${B}%-44s${R}\n" "$*"; hr; } - -require_cmds() { - for _c in "$@"; do - command -v "$_c" &>/dev/null || die "'$_c' not found — sudo apt install $_c" - done -} - -[[ -f "$COMPOSE_FILE" ]] || die "docker-compose.yml not found at $COMPOSE_FILE" - -# ── Read FB_PATH from .env or docker-compose.yml ────────────────────────────── -get_fb_path() { - local _p="" - if [[ -f "$ENV_FILE" ]]; then - _p=$(grep '^FB_PATH=' "$ENV_FILE" 2>/dev/null | head -1 | cut -d= -f2-) - fi - if [[ -z "$_p" ]]; then - _p=$(grep -oP '^\s+-\s+\K[^$][^:]+(?=:/srv(/data)?(\s|$))' \ - "$COMPOSE_FILE" 2>/dev/null | head -1) || true - fi - [[ -n "$_p" ]] || die "Cannot determine FB_PATH — check $ENV_FILE" - echo "$_p" -} - -# ── Auth ────────────────────────────────────────────────────────────────────── -ensure_token() { - [[ -n "$TOKEN" ]] && return 0 - echo - echo " ${B}FileBrowser login${R} ${DIM}(${FB_URL})${R}" - local _u _p _payload _tok - read -r -p " Admin username [admin]: " _u - _u="${_u:-admin}" - read -r -s -p " Admin password: " _p; echo - - _payload=$(jq -n --arg u "$_u" --arg p "$_p" '{username:$u,password:$p}') - _tok=$(curl -s -X POST "$FB_URL/api/login" \ - -H "Content-Type: application/json" -d "$_payload") || true - - if [[ -z "$_tok" ]]; then - die "No response from FileBrowser at $FB_URL — is it running?" - elif [[ "$_tok" == *"."*"."* ]]; then - TOKEN="$_tok" - ok "Logged in as $_u" - else - echo " FileBrowser responded: $_tok" >&2 - die "Login failed — wrong credentials or FileBrowser not reachable" - fi -} - -api_get() { curl -sf -X GET "$FB_URL$1" -H "X-Auth: $TOKEN"; } -api_put() { curl -sf -X PUT "$FB_URL$1" -H "X-Auth: $TOKEN" \ - -H "Content-Type: application/json" -d "$2"; } - -find_user() { - api_get "/api/users" | jq -r --arg u "$1" '.[] | select(.username==$u)' -} - -update_user_scope() { - local _username="$1" _new_scope="$2" - local _user _uid _body - _user=$(find_user "$_username") || true - [[ -n "$_user" ]] || { errmsg "User '$_username' not found in FileBrowser."; return 1; } - _uid=$(echo "$_user" | jq -r '.id') - _body=$(echo "$_user" | jq --arg s "$_new_scope" '. + {scope: $s}') - api_put "/api/users/$_uid" "$_body" >/dev/null \ - || { errmsg "API call to update scope failed."; return 1; } -} - -# ── User selection ──────────────────────────────────────────────────────────── -CHOSEN_USER="" -CHOSEN_DIR="" - -pick_user() { - ensure_token - echo - - local _raw - _raw=$(api_get "/api/users" | jq -r '.[] | [.username, .scope] | @tsv') || true - [[ -n "$_raw" ]] || die "No users returned from FileBrowser." - - local -a _users _dirs - local _i=0 - while IFS=$'\t' read -r _u _s; do - [[ "$_s" == /* ]] || _s="/$_s" - _users+=("$_u"); _dirs+=("$_s") - printf " %2d %-20s %s\n" "$((_i+1))" "$_u" "$_s" - ((_i++)) || true - done <<< "$_raw" - echo - - local _pick="" - read -r -p " Select user (number): " _pick - [[ "$_pick" =~ ^[0-9]+$ ]] || { errmsg "Enter a number."; return 1; } - local _idx=$((_pick-1)) - [[ $_idx -ge 0 && $_idx -lt ${#_users[@]} ]] || { errmsg "Out of range."; return 1; } - - CHOSEN_USER="${_users[$_idx]}" - CHOSEN_DIR="${_dirs[$_idx]}" -} - -# ── Scope classification ────────────────────────────────────────────────────── -# Returns true if the scope lives inside the /data bind-mount path. -# Extra mounts for such users would be nested — unreliable. -scope_is_nested() { - [[ "$1" == /data/* || "$1" == "/data" ]] -} - -# Suggest a named-volume scope path from the current scope. -# /data/users/alice → /alice -suggest_volume_scope() { - local _last; _last=$(basename "$1") - echo "/$_last" -} - -# ── Compose file helpers ────────────────────────────────────────────────────── -list_user_mounts() { - local _scope="$1" - # Match lines: - /absolute/path:/srv/something - grep -oP "^\s+-\s+\K/.+:/srv${_scope}/.+" "$COMPOSE_FILE" 2>/dev/null \ - | while IFS=: read -r _host _cont; do - printf " %-30s→ %s\n" "$(basename "$_cont")" "$_host" - done || true -} - -list_user_mounts_raw() { - local _scope="$1" - grep -oP "^\s+-\s+\K/.+:/srv${_scope}/.+" "$COMPOSE_FILE" 2>/dev/null || true -} - -add_volume_entry() { - local _host_path="$1" _container_path="$2" - - if grep -qF "${_host_path}:${_container_path}" "$COMPOSE_FILE" 2>/dev/null; then - warn "'$(basename "$_container_path")' already in docker-compose.yml." - return 0 - fi - - local _bk="$SCRIPT_DIR/docker-compose.yml.bak.$(date +%Y%m%d-%H%M%S)" - cp "$COMPOSE_FILE" "$_bk" - - if grep -q 'settings.json' "$COMPOSE_FILE"; then - sed -i "/settings\.json/a\\ - ${_host_path}:${_container_path}" "$COMPOSE_FILE" - else - sed -i "/^\s*ports:/i\\ - ${_host_path}:${_container_path}" "$COMPOSE_FILE" - fi - - ok "Mounted: $(basename "$_host_path") → $_container_path" - echo " ${DIM}Backup: $(basename "$_bk")${R}" -} - -remove_volume_entry() { - local _container_path="$1" - local _bk="$SCRIPT_DIR/docker-compose.yml.bak.$(date +%Y%m%d-%H%M%S)" - cp "$COMPOSE_FILE" "$_bk" - local _escaped; _escaped=$(printf '%s' "$_container_path" | sed 's|/|\\/|g') - sed -i "/[[:space:]]-[[:space:]].*:${_escaped}/d" "$COMPOSE_FILE" - ok "Removed: $_container_path" - echo " ${DIM}Backup: $(basename "$_bk")${R}" -} - -# ── Restart ─────────────────────────────────────────────────────────────────── -restart_container() { - echo - warn "Container restart required for changes to take effect." - local _r="" - read -r -p " Restart FileBrowser now? [y/N]: " _r - [[ "${_r,,}" == "y" ]] || { - echo " Run later: docker compose down && docker compose up -d" - return 0 - } - cd "$SCRIPT_DIR" - docker compose down - docker compose up -d - echo - ok "FileBrowser restarted." -} - -# ── Migrate scope from /data/... to named volume ────────────────────────────── -migrate_scope() { - local _username="$1" _old_scope="$2" _fb_path="$3" - local _c; _c=$(get_container_name) - - echo - echo " ${B}Scope migration required${R}" - echo - info " ${CHOSEN_USER}'s scope ($_old_scope) is inside the /srv/data bind-mount." - info " Extra mounts nested inside a bind-mount are unreliable in Docker." - info " We'll move the scope to the named volume so mounts work cleanly." - echo - - # Suggest new scope name - local _suggested; _suggested=$(suggest_volume_scope "$_old_scope") - local _new_scope="" - read -r -p " New scope path [${_suggested}]: " _new_scope - _new_scope="${_new_scope:-$_suggested}" - [[ "$_new_scope" == /* ]] || _new_scope="/$_new_scope" - - # Refuse if new scope is still inside /data - if scope_is_nested "$_new_scope"; then - errmsg "New scope '$_new_scope' is still inside /data — choose a path like $_suggested" - return 1 - fi - - # Check not already used as a mount - if grep -qF ":/srv${_new_scope}" "$COMPOSE_FILE" 2>/dev/null; then - errmsg "'/srv${_new_scope}' is already used in docker-compose.yml" - return 1 - fi - - echo - # Create the scope directory in the named volume via docker exec - if ! docker exec "$_c" test -d "/srv${_new_scope}" 2>/dev/null; then - docker exec "$_c" mkdir -p "/srv${_new_scope}" \ - || { errmsg "Could not create '/srv${_new_scope}' in container."; return 1; } - ok "Created /srv${_new_scope} in named volume" - fi - - # Offer to keep personal files accessible as a sub-folder - local _personal_host="$_fb_path/${_old_scope#/data/}" - if [[ -d "$_personal_host" ]]; then - echo - info " Personal files found at: $_personal_host" - local _pname="" - read -r -p " Mount them as [my-files]: " _pname - _pname="${_pname:-my-files}" - add_volume_entry "$_personal_host" "/srv${_new_scope}/${_pname}" - fi - - # Update scope in FileBrowser via API - update_user_scope "$_username" "$_new_scope" \ - || { errmsg "Scope update failed — change it manually in the FileBrowser web UI."; } - ok "Scope updated in FileBrowser: $_old_scope → $_new_scope" - - # Return new scope for caller to use - CHOSEN_DIR="$_new_scope" -} - -# ── Container name ──────────────────────────────────────────────────────────── -get_container_name() { - local _name - _name=$(grep 'container_name:' "$COMPOSE_FILE" | head -1 | awk '{print $2}') - echo "${_name:-filebrowser}" -} - -# ── Add flow ────────────────────────────────────────────────────────────────── -do_add() { - pick_user || return 1 - - local _user_dir="$CHOSEN_DIR" - - if [[ "$_user_dir" == "/" || "$_user_dir" == "/data" ]]; then - echo - info "$CHOSEN_USER has full access — no extras needed." - return 0 - fi - - local _fb_path; _fb_path=$(get_fb_path) - - # Migrate if scope is nested inside the data bind-mount - if scope_is_nested "$_user_dir"; then - migrate_scope "$CHOSEN_USER" "$_user_dir" "$_fb_path" || return 1 - _user_dir="$CHOSEN_DIR" # updated by migrate_scope - fi - - banner "Add directory — $CHOSEN_USER (${_user_dir})" - - # Show already-mounted extras - local _cur; _cur=$(list_user_mounts "$_user_dir") - if [[ -n "$_cur" ]]; then - info "Already added:" - echo "$_cur" - echo - fi - - # List available source folders on the host - info "Available folders in ${_fb_path}:" - local _avail=() - while IFS= read -r -d '' _d; do - local _name; _name=$(basename "$_d") - [[ "$_name" == .* ]] && continue - _avail+=("$_name") - printf " %s\n" "$_name" - done < <(find "$_fb_path" -maxdepth 1 -mindepth 1 -type d -print0 2>/dev/null | sort -z) - - [[ ${#_avail[@]} -gt 0 ]] || { info "(none found)"; return 0; } - echo - - local _changed=false - while true; do - local _src="" - read -r -p " Folder to add [done]: " _src - [[ -n "$_src" ]] || break - - _src="${_src#/}"; _src="${_src%/}" - [[ -n "$_src" ]] || continue - - local _host_path="$_fb_path/$_src" - if [[ ! -d "$_host_path" ]]; then - errmsg "'$_src' not found in $_fb_path" - continue - fi - - # Ask what name to show in FileBrowser (default: same as folder) - local _display="" - read -r -p " Show as [${_src}]: " _display - _display="${_display:-$_src}" - _display="${_display#/}"; _display="${_display%/}" - - local _container_path="/srv${_user_dir}/${_display}" - - add_volume_entry "$_host_path" "$_container_path" && _changed=true || true - done - - [[ "$_changed" == true ]] && restart_container || true -} - -# ── Remove flow ─────────────────────────────────────────────────────────────── -do_remove() { - pick_user || return 1 - - local _user_dir="$CHOSEN_DIR" - banner "Remove directory — $CHOSEN_USER (${_user_dir})" - - local _extras; _extras=$(list_user_mounts_raw "$_user_dir") - if [[ -z "$_extras" ]]; then - info "No extra directories configured for $CHOSEN_USER." - return 0 - fi - - local -a _lines - local _i=0 - while IFS= read -r _line; do - _lines+=("$_line") - local _cpath="${_line##*:}" - printf " %2d %s\n" "$((_i+1))" "$(basename "$_cpath")" - ((_i++)) || true - done <<< "$_extras" - echo - - local _pick="" - read -r -p " Select entry to remove (number): " _pick - [[ "$_pick" =~ ^[0-9]+$ ]] || { errmsg "Enter a number."; return 1; } - local _idx=$((_pick-1)) - [[ $_idx -ge 0 && $_idx -lt ${#_lines[@]} ]] || { errmsg "Out of range."; return 1; } - - local _cpath="${_lines[$_idx]##*:}" - remove_volume_entry "$_cpath" - restart_container -} - -# ── Show flow ───────────────────────────────────────────────────────────────── -do_show() { - pick_user || return 1 - banner "Extra directories — $CHOSEN_USER (${CHOSEN_DIR})" - local _mounts; _mounts=$(list_user_mounts "$CHOSEN_DIR") - if [[ -n "$_mounts" ]]; then - echo "$_mounts" - else - info "(none configured)" - fi - echo -} - -# ── Main ────────────────────────────────────────────────────────────────────── -require_cmds curl jq docker - -while true; do - banner "FileBrowser — Extra Directories" - echo " ${DIM}Manage extra folder access for users.${R}" - echo " ${DIM}Create/delete users in the FileBrowser web UI.${R}" - echo - echo " 1 Add extra folders to a user" - echo " 2 Remove an extra folder from a user" - echo " 3 Show a user's extra folders" - echo " 0 Exit" - echo - _ch="" - read -r -p " Choice: " _ch - - case "$_ch" in - 1) do_add || true ;; - 2) do_remove || true ;; - 3) do_show || true ;; - 0) echo; echo " Goodbye."; echo; exit 0 ;; - *) errmsg "Invalid choice." ;; - esac -done From 92396d37bd3a4835171deedcff6e48ba10492a1d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:35 +0000 Subject: [PATCH 05/27] services: add standalone bootstrap to frigate, gaming-backup, gatus, homeassistant, js99er, lyrion Each service can now be run directly with sudo bash .sh on any machine with Docker installed, without needing the full post-install repo. Uses the shared bootstrap pattern from docs/standalone-template.sh. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/frigate.sh | 154 ++++++++++++++++++++++++++++++++++++++ services/gaming-backup.sh | 69 +++++++++++++++++ services/gatus.sh | 154 ++++++++++++++++++++++++++++++++++++++ services/homeassistant.sh | 148 ++++++++++++++++++++++++++++++++++++ services/js99er.sh | 148 ++++++++++++++++++++++++++++++++++++ services/lyrion.sh | 154 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 827 insertions(+) diff --git a/services/frigate.sh b/services/frigate.sh index ef6321a..e5da4a0 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -2,11 +2,162 @@ # services/frigate.sh — AI-powered NVR for security cameras (Frigate). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash frigate.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- FRIGATE NVR ----). # Own ~/docker/frigate/ with a standalone docker-compose.yml + .env + config.yml. # Auto-enables /dev/dri/renderD128 for hardware detection (Intel/AMD) when present. # YOU MUST edit config/config.yml to add your camera RTSP streams before starting. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service frigate cameras "AI-powered NVR — object detection on security cameras (Frigate)" 5000 install_frigate() { @@ -176,3 +327,6 @@ MD echo " Config: $FRIGATE_DIR/config/config.yml (add cameras here)" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_frigate diff --git a/services/gaming-backup.sh b/services/gaming-backup.sh index 6399b0f..6db2b1b 100644 --- a/services/gaming-backup.sh +++ b/services/gaming-backup.sh @@ -2,6 +2,10 @@ # services/gaming-backup.sh — Frequent gaming-saves backup (no service downtime). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash gaming-backup.sh +# (Docker must already be installed when run standalone) +# # Backs up the things you can't re-download — progress, saved games, user data: # • Minecraft worlds / player data (every /data instance under $DOCKER_DIR) # • Emulator saves & save states ($GAME_STORAGE_DIR/saves) [gaming box] @@ -18,6 +22,68 @@ # Safe to re-run: it reconnects to an existing repository and refreshes the # config, policies, worker script and timer. +# ── 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; } + + 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}'" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service gaming-backup backup "Gaming saves backup (Minecraft worlds, emulator saves, Steam)" install_gaming_backup() { @@ -418,3 +484,6 @@ UNITEOF echo "" log_success "Gaming backup configured." } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_gaming_backup diff --git a/services/gatus.sh b/services/gatus.sh index 4bc9a86..3c10186 100644 --- a/services/gatus.sh +++ b/services/gatus.sh @@ -2,9 +2,160 @@ # services/gatus.sh — Gatus status/uptime monitoring page. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash gatus.sh +# (Docker must already be installed when run standalone) +# # Gatus polls endpoints (HTTP, TCP, DNS, ICMP) on a schedule and shows a # clean status dashboard. Config is hot-reloaded from gatus_config/config.yaml. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service gatus utilities "Status & uptime monitoring page (Gatus)" 8086 install_gatus() { @@ -157,3 +308,6 @@ MD echo " Config: $GATUS_DIR/gatus_config/config.yaml (hot-reloaded)" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_gatus diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 360c958..7087d15 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -1,6 +1,151 @@ #!/bin/bash # services/homeassistant.sh — Home Assistant home-automation hub. # Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash homeassistant.sh +# (Docker must already be installed when run standalone) + +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── register_service homeassistant homelab "Home automation hub (Home Assistant)" 8123 @@ -108,3 +253,6 @@ HA_CONFIG echo " Note: first startup can take a minute while HA initializes." echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_homeassistant diff --git a/services/js99er.sh b/services/js99er.sh index 7cd1e9f..5377027 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -2,10 +2,155 @@ # services/js99er.sh — Self-hosted TI-99/4A emulator (js99er.net). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash js99er.sh +# (Docker must already be installed when run standalone) +# # Builds the js99er-angular source into a static site (multi-stage Docker # build) with an offline Google Fonts fix, served by nginx. Each service lives # in its own folder with its own standalone docker-compose.yml. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service js99er gaming "Self-hosted TI-99/4A emulator (js99er.net)" 8099 install_js99er() { @@ -304,3 +449,6 @@ COMPOSE echo " Online alternative (no install needed): https://js99er.net" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_js99er diff --git a/services/lyrion.sh b/services/lyrion.sh index 0a901ee..466573f 100644 --- a/services/lyrion.sh +++ b/services/lyrion.sh @@ -2,10 +2,161 @@ # services/lyrion.sh — Lyrion Music Server for Squeezebox devices, apps, Chromecast. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash lyrion.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- LYRION MUSIC SERVER ----). # Uses network_mode: host so UDP discovery (Chromecast, Squeezebox) works without # manual port-forwarding. Own ~/docker/lyrion/ with compose + .env. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service lyrion media "Music streaming server — Squeezebox, Chromecast (Lyrion)" 9000 install_lyrion() { @@ -113,3 +264,6 @@ MD echo " Note: uses host networking for Chromecast/Squeezebox UDP discovery" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_lyrion From 23318e77ed29038a80c5ea22df320395bbea1e4a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:36 +0000 Subject: [PATCH 06/27] services: add standalone bootstrap to magicmirror, mail-archiver, meshcentral, minecraft, rustdesk, silent-send Each service can now be run directly with sudo bash .sh on any machine with Docker installed, without needing the full post-install repo. Uses the shared bootstrap pattern from docs/standalone-template.sh. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/magicmirror.sh | 154 ++++++++++++++++++++++++++++++++++++++ services/mail-archiver.sh | 151 +++++++++++++++++++++++++++++++++++++ services/meshcentral.sh | 154 ++++++++++++++++++++++++++++++++++++++ services/minecraft.sh | 77 +++++++++++++++++++ services/rustdesk.sh | 83 ++++++++++++++++++++ services/silent-send.sh | 66 ++++++++++++++++ 6 files changed, 685 insertions(+) diff --git a/services/magicmirror.sh b/services/magicmirror.sh index afdff96..ab78ba2 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -2,11 +2,162 @@ # services/magicmirror.sh — Modular smart mirror / info dashboard (MagicMirror²). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash magicmirror.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- MAGIC MIRROR ----). # Supports 1-3 instances (ports 8081-8083) each in ~/docker/magicmirror//. # If you provide an existing config.js, third-party MMM-* modules are detected # and cloned from GitHub automatically. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service magicmirror utilities "Modular smart mirror / info dashboard (MagicMirror²)" 8081 install_magicmirror() { @@ -176,3 +327,6 @@ MD echo " MagicMirror config: $MM_BASE//config/config.js" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_magicmirror diff --git a/services/mail-archiver.sh b/services/mail-archiver.sh index fc845b5..2f4701b 100644 --- a/services/mail-archiver.sh +++ b/services/mail-archiver.sh @@ -2,10 +2,158 @@ # services/mail-archiver.sh — Mail Archiver (IMAP email archive & search). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash mail-archiver.sh +# (Docker must already be installed when run standalone) +# # Self-hosted email archive — connects to IMAP accounts, indexes messages, # and provides full-text search. No big-tech email required. # Image: s1t5/mailarchiver DB: postgres:17-alpine +# ── 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 + } + + 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}'" + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service mail-archiver utilities "IMAP email archive & search (Mail Archiver)" 5000 install_mail-archiver() { @@ -158,3 +306,6 @@ MD echo " Add IMAP accounts via the web UI after starting." echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_mail-archiver diff --git a/services/meshcentral.sh b/services/meshcentral.sh index c9f6a50..42ceff1 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -2,10 +2,161 @@ # services/meshcentral.sh — Self-hosted remote device management server (MeshCentral). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash meshcentral.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- MESHCENTRAL SERVER ----). # Own ~/docker/meshcentral/ with a standalone docker-compose.yml + .env. # HTTPS on port 4430, agent listener on 4433. First visit: create admin account. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service meshcentral utilities "Self-hosted remote device management server (MeshCentral)" 4430 install_meshcentral() { @@ -122,3 +273,6 @@ MD echo " First visit: create your admin account" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_meshcentral diff --git a/services/minecraft.sh b/services/minecraft.sh index 037c8f6..a855b56 100644 --- a/services/minecraft.sh +++ b/services/minecraft.sh @@ -3,6 +3,10 @@ # multi-instance, mod & datapack pickers, playit.gg tunnel, client-mods page. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash minecraft.sh +# (Docker must already be installed when run standalone) +# # Ported from the standalone setupminecraft.sh. Converted to the per-service # folder model: each instance lives in its OWN folder under $DOCKER_DIR with its # OWN standalone docker-compose.yml (no shared compose, no python insert logic). @@ -15,6 +19,76 @@ # lib/common.sh — do NOT redefine them here. No `set -e`: this file is sourced # into a long-running dispatcher, so we use explicit checks + `|| return 1`. +# ── 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}'" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service minecraft gaming "Minecraft server (Fabric/Quilt/Paper, multi-instance)" 25565 install_minecraft() { @@ -2458,3 +2532,6 @@ NETEOF fi echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_minecraft diff --git a/services/rustdesk.sh b/services/rustdesk.sh index f4dd000..6de59d2 100644 --- a/services/rustdesk.sh +++ b/services/rustdesk.sh @@ -2,6 +2,10 @@ # services/rustdesk.sh — RustDesk self-hosted remote desktop relay server. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash rustdesk.sh +# (Docker must already be installed when run standalone) +# # RustDesk is an open-source TeamViewer alternative. This installs the # SERVER-SIDE relay/rendezvous daemon — clients still need the RustDesk app. # For cross-VLAN / cross-internet access, point RELAY at this server's FQDN. @@ -14,6 +18,82 @@ # 21118 TCP — WebSocket (browser client support) # 21119 TCP — WebSocket HTTPS (browser client support) +# ── 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}'" + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service rustdesk utilities "Self-hosted remote desktop relay (RustDesk)" 21117 install_rustdesk() { @@ -159,3 +239,6 @@ MD echo " Ports 21115-21119 must be open in your firewall/router." echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_rustdesk diff --git a/services/silent-send.sh b/services/silent-send.sh index 76b6330..b6076a7 100644 --- a/services/silent-send.sh +++ b/services/silent-send.sh @@ -2,6 +2,10 @@ # services/silent-send.sh — Silent Send browser extension (PII redaction for AI chat). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash silent-send.sh +# (Docker must already be installed when run standalone) +# # NON-DOCKER module. Silent Send is a browser extension (Chrome/Brave/Firefox/ # Safari) that intercepts personal info before it's sent to AI chatbots and # swaps in user-defined substitutes — entirely client-side, no server/container. @@ -15,6 +19,65 @@ # # Source: https://github.com/outis1one/silent-send +# ── 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; } + + # 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}'" + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service silent-send extras "Browser extension: redact PII before it reaches AI chatbots" install_silent-send() { @@ -210,3 +273,6 @@ MD echo "" log_success "Silent Send installed. Load it in your browser to start redacting." } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_silent-send From 4dd71c187f0221a5ea8a200ac5942ca3aca2cc94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:36 +0000 Subject: [PATCH 07/27] services: add standalone bootstrap to sky-cam, sync-cc, traccar, unifi, uptimekuma, vaultwarden Each service can now be run directly with sudo bash .sh on any machine with Docker installed, without needing the full post-install repo. Uses the shared bootstrap pattern from docs/standalone-template.sh. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/sky-cam.sh | 69 +++++++++++++++++ services/sync-cc.sh | 63 ++++++++++++++++ services/traccar.sh | 147 ++++++++++++++++++++++++++++++++++++ services/unifi.sh | 89 ++++++++++++++++++++++ services/uptimekuma.sh | 147 ++++++++++++++++++++++++++++++++++++ services/vaultwarden.sh | 160 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 675 insertions(+) diff --git a/services/sky-cam.sh b/services/sky-cam.sh index fa27fba..2ad98e7 100644 --- a/services/sky-cam.sh +++ b/services/sky-cam.sh @@ -2,6 +2,10 @@ # services/sky-cam.sh — Automated sky / timelapse camera scripts. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash sky-cam.sh +# (Docker must already be installed when run standalone) +# # NON-DOCKER module. sky-cam produces: # • Daily sunrise clip — speed-adjusted video, uploaded to Mattermost # • Four Seasons timelapse — daily clips sized to Vivaldi movements' music @@ -12,6 +16,68 @@ # Source: https://github.com/outis1one/sky-cam (cloned via bootstrap.sh) # Installs systemd user timers via sky-cam's install.sh. +# ── 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; } + + run_cmd() { + "$@" + } + + pip_user_install() { + pip3 --user --break-system-packages "$@" 2>/dev/null \ + || pip3 --user "$@" + } + + # 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}'" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service sky-cam cameras "Automated sky / timelapse camera scripts (sky-cam)" install_sky-cam() { @@ -181,3 +247,6 @@ install_sky-cam() { echo " journalctl --user -u sky-cam-sunrise.service -f" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_sky-cam diff --git a/services/sync-cc.sh b/services/sync-cc.sh index 4b3592c..df41e06 100644 --- a/services/sync-cc.sh +++ b/services/sync-cc.sh @@ -2,6 +2,10 @@ # services/sync-cc.sh — Subtitle sync & generation tool (sync_cc). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash sync-cc.sh +# (Docker must already be installed when run standalone) +# # NON-DOCKER module. sync_cc is a Python CLI tool that: # - GENERATE: Whisper AI transcribes video audio → SRT # - SYNC: ffsubsync aligns an existing SRT to the video @@ -18,6 +22,62 @@ # # Source script: extras/sync_cc.py in this repo. +# ── 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; } + + run_cmd() { + "$@" + } + + pip_user_install() { + pip3 --user --break-system-packages "$@" 2>/dev/null \ + || pip3 --user "$@" + } + + # 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}'" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + HERE="${HERE:-$_SELF_DIR/..}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service sync-cc extras "Subtitle sync/generate tool — Whisper + ffsubsync (sync_cc)" install_sync-cc() { @@ -126,3 +186,6 @@ WRAPEOF echo " First run may take a few minutes while the model downloads." echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_sync-cc diff --git a/services/traccar.sh b/services/traccar.sh index 2c03ec8..856ba42 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -2,9 +2,153 @@ # services/traccar.sh — GPS tracking server (Traccar). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash traccar.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- TRACCAR ----). # Own ~/docker/traccar/ with a standalone docker-compose.yml + config XML. +# ── 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_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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service traccar utilities "GPS tracking server — phones, vehicles, assets (Traccar)" 8082 install_traccar() { @@ -109,3 +253,6 @@ MD echo " Default: admin@admin.com / admin (change immediately!)" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_traccar diff --git a/services/unifi.sh b/services/unifi.sh index 6c13b5b..a44315f 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -2,10 +2,96 @@ # services/unifi.sh — UniFi Network Application (Ubiquiti controller). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash unifi.sh +# (Docker must already be installed when run standalone) +# # Two containers: mongo:4 (DB) + linuxserver unifi-network-application (app). # Web UI runs on HTTPS port 8443 — no plain HTTP web interface. # Caddy reverse-proxy wiring uses TLS passthrough or tls_insecure_skip_verify. +# ── 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 + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + echo + } + + # 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}'" + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service unifi utilities "Ubiquiti network controller (UniFi)" 8443 install_unifi() { @@ -210,3 +296,6 @@ MD echo " MongoDB credentials saved to: $UNIFI_DIR/.env" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_unifi diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index e326e8c..b7324c3 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -1,6 +1,150 @@ #!/bin/bash # services/uptimekuma.sh — Uptime Kuma uptime/status monitoring. # Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash uptimekuma.sh +# (Docker must already be installed when run standalone) + +# ── 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_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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── register_service uptimekuma utilities "Uptime/status monitoring (Uptime Kuma)" 3001 @@ -86,3 +230,6 @@ MD echo " Access at: http://localhost:3001" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_uptimekuma diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index acd22cb..7aa8fad 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -2,10 +2,167 @@ # services/vaultwarden.sh — Vaultwarden (self-hosted Bitwarden server). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash vaultwarden.sh +# (Docker must already be installed when run standalone) +# # Vaultwarden is an unofficial, lightweight Bitwarden-compatible server. # All official Bitwarden clients (browser extension, desktop, mobile) work with it. # Requires HTTPS in production — set DOMAIN to your public URL. +# ── 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 + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + echo + } + + # 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service vaultwarden utilities "Bitwarden-compatible password manager (Vaultwarden)" 80 install_vaultwarden() { @@ -163,3 +320,6 @@ MD echo " (Token also saved to $VW_DIR/.env)" echo "" } + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_vaultwarden From ce8f0e516acfaf7085c3f6dcf8fe2574706f0354 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:36 +0000 Subject: [PATCH 08/27] services: add standalone bootstrap to watchtower, watchyourlan, wg-easy, wolf-pair, wolf Each service can now be run directly with sudo bash .sh on any machine with Docker installed, without needing the full post-install repo. Uses the shared bootstrap pattern from docs/standalone-template.sh. All 42 applicable service files now support standalone execution. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/watchtower.sh | 82 +++++++++++++++++++++ services/watchyourlan.sh | 82 +++++++++++++++++++++ services/wg-easy.sh | 153 +++++++++++++++++++++++++++++++++++++++ services/wolf-pair.sh | 145 +++++++++++++++++++++++++++++++++++++ services/wolf.sh | 69 ++++++++++++++++++ 5 files changed, 531 insertions(+) diff --git a/services/watchtower.sh b/services/watchtower.sh index d453872..74694ec 100644 --- a/services/watchtower.sh +++ b/services/watchtower.sh @@ -1,6 +1,86 @@ #!/bin/bash # services/watchtower.sh — Watchtower automatic container update monitoring. # Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash watchtower.sh +# (Docker must already be installed when run standalone) + +# ── 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}'" + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── register_service watchtower utilities "Automatic container updates (Watchtower)" @@ -168,3 +248,5 @@ MD echo " Add label: com.centurylinklabs.watchtower.enable=false" echo "" } + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_watchtower diff --git a/services/watchyourlan.sh b/services/watchyourlan.sh index 5a8193a..38753fd 100644 --- a/services/watchyourlan.sh +++ b/services/watchyourlan.sh @@ -2,10 +2,90 @@ # services/watchyourlan.sh — WatchYourLAN network device tracker. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash watchyourlan.sh +# (Docker must already be installed when run standalone) +# # Continuously scans the network for connected devices, tracks history, # and can alert on new/unknown devices. Uses network_mode: host so it # can see the physical network directly (required for ARP scanning). +# ── 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}'" + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service watchyourlan utilities "Network device tracker (WatchYourLAN)" 8840 install_watchyourlan() { @@ -135,3 +215,5 @@ MD echo " Scanning: interface $SCAN_IFACE" echo "" } + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_watchyourlan diff --git a/services/wg-easy.sh b/services/wg-easy.sh index d3ca608..22ba149 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -2,11 +2,162 @@ # services/wg-easy.sh — WireGuard VPN with a web management UI (wg-easy). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash wg-easy.sh +# (Docker must already be installed when run standalone) +# # Ported from ubuntu-post-install-24.04-crowdsec.sh (# ---- WG-EASY ----). # Own ~/docker/wg-easy/ with a standalone docker-compose.yml + .env. # Requires cap_add: NET_ADMIN + SYS_MODULE and ip_forward sysctl. # Forward UDP 51820 on your router to this server for external VPN access. +# ── 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service wg-easy utilities "WireGuard VPN with web management UI (wg-easy)" 51821 install_wg-easy() { @@ -122,3 +273,5 @@ MD echo " Router: forward UDP 51820 → this server for external VPN access" echo "" } + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wg-easy diff --git a/services/wolf-pair.sh b/services/wolf-pair.sh index 508bb03..e1acf02 100644 --- a/services/wolf-pair.sh +++ b/services/wolf-pair.sh @@ -2,6 +2,10 @@ # services/wolf-pair.sh — Moonlight pairing web UI for Wolf. # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash wolf-pair.sh +# (Docker must already be installed when run standalone) +# # Builds a tiny Python HTTP container (server.py + Dockerfile baked below) # that watches Wolf's docker logs for pairing secrets and serves a PIN entry # form on port 8090. No command line needed: visit the URL, type the PIN. @@ -10,6 +14,145 @@ # Wolf's pairing API at http://localhost:47989 and tail `docker logs wolf` # via the mounted docker socket. +# ── 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service wolf-pair gaming "Moonlight pairing web UI for Wolf" 8090 install_wolf-pair() { @@ -318,3 +461,5 @@ MD echo " When Moonlight shows a PIN, open that URL and enter it." echo "" } + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wolf-pair diff --git a/services/wolf.sh b/services/wolf.sh index cc98b07..0573129 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -2,6 +2,10 @@ # services/wolf.sh — Cloud gaming via Moonlight (Games-on-Whales Wolf). # Part of the modular post-install system (sourced by setup.sh). # +# Can also be run standalone on any machine: +# sudo bash wolf.sh +# (Docker must already be installed when run standalone) +# # Self-hosted Moonlight streaming server. One Wolf container spins up app # containers (ES-DE/RetroArch, Steam, Lutris, Firefox, full desktop) on demand, # with virtual displays and virtual gamepads — no monitor, no dummy plug. @@ -12,6 +16,69 @@ # helper service is dropped (it depended on repo files we don't ship); the # `./manage.sh pin` command replaces it. +# ── 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}'" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + register_service wolf gaming "Cloud gaming via Moonlight (Games-on-Whales Wolf)" 47989 install_wolf() { @@ -862,3 +929,5 @@ PYEOF echo "" log_success "Done. Pair Moonlight and play." } + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_wolf From 21bd9df61475b4224ee3a8915f75157e47a28099 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:36 +0000 Subject: [PATCH 09/27] services: add Syncthing continuous file sync service Docker-based Syncthing with PUID/PGID ownership, caddy_net integration, and standalone bootstrap support. Exposes web UI on 8384 and sync protocol on 22000 (tcp+udp) and discovery on 21027/udp. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- README.md | 2 +- services/syncthing.sh | 258 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 services/syncthing.sh diff --git a/README.md b/README.md index 89b22c7..9a3cfc6 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Update them any time with `sudo ./setup.sh configure`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` | -| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `rustdesk`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/services/syncthing.sh b/services/syncthing.sh new file mode 100644 index 0000000..7c5138d --- /dev/null +++ b/services/syncthing.sh @@ -0,0 +1,258 @@ +#!/bin/bash +# services/syncthing.sh — Continuous file sync between devices (Syncthing). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash syncthing.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service syncthing utilities "Continuous file sync between devices (Syncthing)" 8384 + +install_syncthing() { + require_docker || return 1 + log_info "Installing Syncthing..." + + local DIR="$DOCKER_DIR/syncthing" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" + echo "[DRY-RUN] Would expose web UI on 8384, sync protocol on 22000 (tcp+udp), discovery on 21027/udp" + return 0 + fi + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local PUID PGID + PUID="$(id -u "$ACTUAL_USER")" + PGID="$(id -g "$ACTUAL_USER")" + + cat > docker-compose.yml << EOF +name: syncthing + +services: + syncthing: + image: syncthing/syncthing:latest + container_name: syncthing + hostname: syncthing + restart: unless-stopped + environment: + - PUID=\${PUID:-$PUID} + - PGID=\${PGID:-$PGID} + - TZ=\${SITE_TZ:-UTC} + ports: + - "8384:8384" + - "22000:22000/tcp" + - "22000:22000/udp" + - "21027:21027/udp" + volumes: + - ./config:/var/syncthing/config + - ./data:/var/syncthing + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +EOF + + cat > .env << ENV +CADDY_NET=$SITE_CADDY_NET +PUID=$PUID +PGID=$PGID +SITE_TZ=$SITE_TZ +ENV + + chmod 600 .env + chown "$ACTUAL_USER:$ACTUAL_USER" .env + + configure_caddy_for_service "Syncthing" "syncthing:8384" "sync" + + write_readme "$DIR" << MD +# Syncthing + +Continuous, decentralised file synchronisation between devices. + +## Access +- Web UI: http://localhost:8384 +- First run: go to **Settings → GUI** and set a username and password. + +## Firewall ports (for LAN sync) +Open these on the host firewall so other Syncthing devices can reach this node: +\`\`\` +sudo ufw allow 22000/tcp comment "Syncthing sync protocol" +sudo ufw allow 22000/udp comment "Syncthing sync protocol (QUIC)" +sudo ufw allow 21027/udp comment "Syncthing local discovery" +\`\`\` + +## Manage +\`\`\` +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + local START="" + prompt_yn "Start Syncthing now? (y/n):" "y" START + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "Syncthing started" \ + || log_warning "Start failed — check: docker compose logs" + fi + + echo " Access at: http://localhost:8384" + echo " First run: set a username and password in Settings → GUI" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_syncthing From 67d6ae1f1bfa8593b30086323a3be7ef57f0df9d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:01:36 +0000 Subject: [PATCH 10/27] services: add KDE Connect phone/desktop integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apt-based service for Android/iPhone ↔ Linux integration: shared clipboard, notifications, file transfer, remote input. Works on Ubuntu (GNOME) and Linux Mint Cinnamon. Opens UFW ports 1714-1764 automatically. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- README.md | 2 +- services/kdeconnect.sh | 153 +++++++++++++++++++++++++++++++++++++++++ setup.sh | 1 + 3 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 services/kdeconnect.sh diff --git a/README.md b/README.md index 9a3cfc6..c1013c1 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Update them any time with `sudo ./setup.sh configure`. | `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | -| `extras` | `silent-send`, `sync-cc` | +| `extras` | `kdeconnect`, `silent-send`, `sync-cc` | | `backup` | `backup` — complete recovery: entire `~/docker//` 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) | Run `./setup.sh --list` to see descriptions. diff --git a/services/kdeconnect.sh b/services/kdeconnect.sh new file mode 100644 index 0000000..4d0c7cb --- /dev/null +++ b/services/kdeconnect.sh @@ -0,0 +1,153 @@ +#!/bin/bash +# services/kdeconnect.sh — Phone/desktop integration via KDE Connect. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash kdeconnect.sh +# +# KDE Connect is an APT package — NOT a Docker service. +# It enables Android/iPhone ↔ Linux integration: +# • Shared clipboard • File transfer +# • Phone notifications on desktop • Remote input (trackpad/keyboard) +# • SMS from desktop • Battery status +# +# NOTE: setup.sh is_installed() needs a case entry for this service: +# kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;; + +# ── 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; } + + # 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}'" + } + 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}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service kdeconnect extras "Phone/desktop integration — notifications, clipboard, file transfer (KDE Connect)" + +install_kdeconnect() { + log_info "Installing KDE Connect phone/desktop integration..." + + echo "" + echo "┌─────────────────────────────────────────────────────────────────┐" + echo "│ KDE CONNECT — Phone / Desktop Integration │" + echo "│ Shared clipboard, notifications, file transfer, remote input │" + echo "│ Works with Android (Play Store / F-Droid) and iPhone (App Store)│" + echo "└─────────────────────────────────────────────────────────────────┘" + echo "" + + # ── Already installed? ──────────────────────────────────────────────────── + if command -v kdeconnect &>/dev/null; then + log_info "KDE Connect is already installed — skipping." + return 0 + fi + + # ── DRY-RUN: describe the plan and bail before touching anything real ───── + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would install: kdeconnect" + if dpkg -l gnome-shell 2>/dev/null | grep -q ^ii; then + echo "[DRY-RUN] Would install: indicator-kdeconnect (GNOME/Ubuntu detected)" + fi + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then + echo "[DRY-RUN] Would open UFW ports 1714:1764/tcp and 1714:1764/udp" + fi + return 0 + fi + + # ── 1. Install kdeconnect ───────────────────────────────────────────────── + log_info "Installing kdeconnect package..." + if apt-get install -y kdeconnect; then + log_success "kdeconnect installed" + else + log_error "Failed to install kdeconnect — check apt output above" + return 1 + fi + + # ── 2. GNOME indicator (Ubuntu/GNOME only) ──────────────────────────────── + if dpkg -l gnome-shell 2>/dev/null | grep -q ^ii; then + log_info "GNOME detected — installing indicator-kdeconnect for system tray support..." + if apt-get install -y indicator-kdeconnect; then + log_success "indicator-kdeconnect installed" + else + log_warning "Could not install indicator-kdeconnect — continuing without it" + fi + fi + + # ── 3. Open UFW firewall ports (KDE Connect port range) ─────────────────── + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then + log_info "Opening UFW ports 1714:1764/tcp and 1714:1764/udp (KDE Connect)..." + ufw allow 1714:1764/tcp + ufw allow 1714:1764/udp + log_success "UFW ports 1714-1764 opened" + else + log_info "UFW not active — skipping firewall rules" + echo " If you enable UFW later, run:" + echo " sudo ufw allow 1714:1764/tcp" + echo " sudo ufw allow 1714:1764/udp" + fi + + # ── 4. Usage instructions ───────────────────────────────────────────────── + echo "" + echo " ┌─ Next steps ────────────────────────────────────────────────────┐" + echo " │ 1. Install KDE Connect on your phone: │" + echo " │ Android: Play Store or F-Droid → search 'KDE Connect' │" + echo " │ iPhone: App Store → search 'KDE Connect' │" + echo " │ │" + echo " │ 2. Ensure your phone and computer are on the same WiFi network. │" + echo " │ │" + echo " │ 3. Open KDE Connect on your phone — your computer should │" + echo " │ appear automatically. Tap it and accept the pairing request │" + echo " │ on both devices. │" + echo " │ │" + echo " │ Linux Mint Cinnamon: a system tray applet is available — │" + echo " │ right-click the desktop → Applets → search 'KDE Connect' │" + echo " │ and add it to your panel. │" + echo " └──────────────────────────────────────────────────────────────────┘" + echo "" + + log_success "KDE Connect installation complete" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_kdeconnect diff --git a/setup.sh b/setup.sh index 27b8a7b..15e2d98 100755 --- a/setup.sh +++ b/setup.sh @@ -79,6 +79,7 @@ is_installed() { base) command -v ncdu >/dev/null 2>&1 ;; glow) command -v glow >/dev/null 2>&1 ;; crowdsec) command -v cscli >/dev/null 2>&1 ;; + kdeconnect) command -v kdeconnect >/dev/null 2>&1 ;; silent-send) [ -d "$ACTUAL_HOME/silent-send/.git" ] ;; sync-cc) [ -f "$ACTUAL_HOME/sync-cc/sync_cc.py" ] ;; sky-cam) [ -d "$ACTUAL_HOME/sky-cam/.git" ] ;; From 95ac16efbd674abb4d0af97cfe3950b1d1236e0a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:04:50 +0000 Subject: [PATCH 11/27] services: add btop to base essential packages btop is a modern resource monitor (CPU, memory, disk, network) with a clean interactive UI. Useful on both servers and desktops. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/base.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/base.sh b/services/base.sh index a2875e6..7d351f2 100644 --- a/services/base.sh +++ b/services/base.sh @@ -2,7 +2,7 @@ # services/base.sh — essential CLI packages installed on every box. # Part of the modular post-install system (sourced by setup.sh). -register_service base base "Essential CLI packages (net-tools, git, htop, glow, …)" +register_service base base "Essential CLI packages (net-tools, git, htop, btop, glow, …)" install_base() { log_info "Installing essential packages..." @@ -10,7 +10,7 @@ install_base() { # Core utilities present on every install. run_cmd apt-get install -y \ - net-tools ncdu git curl wget htop tree zip unzip \ + net-tools ncdu git curl wget htop btop tree zip unzip \ ca-certificates gnupg jq rsync || log_warning "Some essential packages failed to install" # glow — terminal markdown reader (charmbracelet). Not in Ubuntu repos, From 9f667705ae8e8620a1367803c635e70d5dff87db Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:30:58 +0000 Subject: [PATCH 12/27] Add Easy Asterisk PBX service with self-hosted coturn TURN server Integrates https://github.com/outis1one/easy-asterisk into the post-install system. Downloads the management script and coturn entrypoint at install time, generates docker-compose.yml with host-networking Asterisk + coturn, writes a randomised TURN password, and opens UFW ports for SIP/RTP/TURN. Interactive FQDN setup chooses between LAN-only (UDP, no TLS) and FQDN mode (TLS+SRTP+TURN) and prints required router port-forward instructions. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- README.md | 4 +- services/asterisk.sh | 475 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 services/asterisk.sh diff --git a/README.md b/README.md index c1013c1..5b7615d 100644 --- a/README.md +++ b/README.md @@ -66,8 +66,8 @@ Update them any time with `sudo ./setup.sh configure`. | 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) | -| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` | -| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` | +| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | diff --git a/services/asterisk.sh b/services/asterisk.sh new file mode 100644 index 0000000..e68d092 --- /dev/null +++ b/services/asterisk.sh @@ -0,0 +1,475 @@ +#!/bin/bash +# services/asterisk.sh — Easy Asterisk PBX with self-hosted coturn TURN server. +# Part of the modular post-install system (sourced by setup.sh). +# +# Based on https://github.com/outis1one/easy-asterisk +# Personal/home-lab use only. Not for commercial or emergency services. +# +# Can also be run standalone on any machine: +# sudo bash asterisk.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (home intercom/VoIP)" 5061 + +install_asterisk() { + require_docker || return 1 + log_info "Installing Easy Asterisk PBX..." + + local EA_DIR="$DOCKER_DIR/asterisk" + local EA_REPO="https://github.com/outis1one/easy-asterisk" + local EA_SCRIPT_URL="https://raw.githubusercontent.com/outis1one/easy-asterisk/main/easy-asterisk-v0.10.0.sh" + local EA_COTURN_URL="https://raw.githubusercontent.com/outis1one/easy-asterisk/main/docker/coturn-entrypoint.sh" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $EA_DIR" + echo "[DRY-RUN] Would download management script and coturn entrypoint" + echo "[DRY-RUN] Would write docker-compose.yml, .env, Dockerfile" + echo "[DRY-RUN] Would open UFW ports for SIP/RTP/TURN" + return 0 + fi + + mkdir -p "$EA_DIR/docker" + ensure_docker_dir_ownership "$EA_DIR" + cd "$EA_DIR" || return 1 + + # ── Download management script ──────────────────────────────────────────── + log_info "Downloading Easy Asterisk management script..." + if curl -fsSL "$EA_SCRIPT_URL" -o "$EA_DIR/easy-asterisk.sh"; then + chmod 750 "$EA_DIR/easy-asterisk.sh" + chown "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR/easy-asterisk.sh" + log_success "Management script saved to $EA_DIR/easy-asterisk.sh" + else + log_warning "Could not download management script — check network or fetch manually from $EA_REPO" + fi + + # ── Download coturn custom entrypoint ───────────────────────────────────── + if curl -fsSL "$EA_COTURN_URL" -o "$EA_DIR/docker/coturn-entrypoint.sh"; then + chmod 755 "$EA_DIR/docker/coturn-entrypoint.sh" + else + log_warning "Could not download coturn-entrypoint.sh — coturn may fail to start" + fi + + # ── FQDN setup ──────────────────────────────────────────────────────────── + echo "" + echo " Easy Asterisk requires a domain name (FQDN) that points to this" + echo " server's public IP. SIP clients connect to this domain over TLS." + echo "" + echo " For LAN-only use without a domain, leave this blank." + echo " (LAN mode uses UDP — no TLS, no coturn needed.)" + echo "" + + local DOMAIN_NAME="" + prompt_text "FQDN for this server (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank for LAN-only]:" "" DOMAIN_NAME + + local LAN_ONLY=false + if [[ -z "$DOMAIN_NAME" ]]; then + LAN_ONLY=true + log_info "LAN/VPN-only mode — TLS and TURN disabled." + else + log_info "FQDN mode: $DOMAIN_NAME" + echo "" + echo " Required router port forwards:" + echo " 5061/tcp → SIP TLS signaling" + echo " 3478/udp+tcp → STUN/TURN (NAT traversal)" + echo " 10000-20000/udp → RTP media" + echo " 49152-49252/udp → TURN relay range" + echo "" + fi + + # ── Generate passwords ──────────────────────────────────────────────────── + local TURN_PASSWORD + TURN_PASSWORD="$(openssl rand -base64 18 2>/dev/null || tr -dc 'A-Za-z0-9' Dockerfile << 'DOCKERFILE' +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && apt-get install -y \ + asterisk \ + asterisk-core-sounds-en-gsm \ + asterisk-modules \ + ca-certificates \ + openssl \ + curl \ + wget \ + tcpdump \ + sngrep \ + net-tools \ + iproute2 \ + iputils-ping \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +# Management scripts (bind-mounted at runtime from host) +COPY easy-asterisk.sh /usr/local/bin/easy-asterisk +RUN chmod +x /usr/local/bin/easy-asterisk + +EXPOSE 5060/udp 5060/tcp 5061/tcp +EXPOSE 8080/tcp 8088/tcp 8089/tcp +EXPOSE 3478/udp +EXPOSE 10000-10100/udp + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD asterisk -rx "core show version" 2>/dev/null | grep -q "Asterisk" || exit 1 +DOCKERFILE + + # ── docker-compose.yml ──────────────────────────────────────────────────── + cat > docker-compose.yml << COMPOSE +# Easy Asterisk — generated by ubuntu-post-install +# Manage: docker exec -it easy-asterisk easy-asterisk +# Source: $EA_REPO + +services: + + asterisk: + build: . + container_name: easy-asterisk + # Host networking: required for RTP (10000-20000/udp) and proper NAT detection + network_mode: host + depends_on: + coturn: + condition: service_started + volumes: + - asterisk-config:/etc/asterisk + - easy-asterisk-config:/etc/easy-asterisk + - asterisk-logs:/var/log/asterisk + - asterisk-spool:/var/spool/asterisk + - asterisk-lib:/var/lib/asterisk + - ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro + environment: + - DOMAIN_NAME=\${DOMAIN_NAME} + - ENABLE_TLS=\${ENABLE_TLS:-y} + - PUBLIC_IP=\${PUBLIC_IP:-} + - LOCAL_CIDR=\${LOCAL_CIDR:-} + - HAS_VLANS=\${HAS_VLANS:-n} + - VLAN_SUBNETS=\${VLAN_SUBNETS:-} + - TURN_ENABLED=\${TURN_ENABLED:-y} + - TURN_SERVER=\${DOMAIN_NAME}:\${TURN_PORT:-3478} + - TURN_USERNAME=\${TURN_USERNAME:-easyasterisk} + - TURN_PASSWORD=\${TURN_PASSWORD} + - RTP_START=\${RTP_START:-10000} + - RTP_END=\${RTP_END:-20000} + - WEB_ADMIN_PORT=\${WEB_ADMIN_PORT:-8080} + - WEB_ADMIN_AUTH_DISABLED=\${WEB_ADMIN_AUTH_DISABLED:-false} + restart: unless-stopped + + coturn: + image: coturn/coturn:latest + container_name: easy-asterisk-coturn + network_mode: host + user: root + entrypoint: ["/coturn-entrypoint.sh"] + volumes: + - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro + environment: + - PUBLIC_IP=\${PUBLIC_IP:-} + 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=\${TURN_RELAY_MIN:-49152} + - --max-port=\${TURN_RELAY_MAX:-49252} + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + restart: unless-stopped + +volumes: + asterisk-config: + easy-asterisk-config: + asterisk-logs: + asterisk-spool: + asterisk-lib: +COMPOSE + + # ── .env ───────────────────────────────────────────────────────────────── + cat > .env << ENV +# Easy Asterisk — environment configuration +# Edit and restart: docker compose down && docker compose up -d + +# FQDN pointing to this server's public IP (required for remote/TLS mode) +DOMAIN_NAME=$DOMAIN_NAME + +# Public IP — leave empty to auto-detect +PUBLIC_IP= + +# TLS — set to 'n' for LAN-only mode +ENABLE_TLS=$( [[ "$LAN_ONLY" == "true" ]] && echo "n" || echo "y" ) + +# Local network CIDR — auto-detected if empty +LOCAL_CIDR= + +# Additional subnets for site-to-site VPNs (WireGuard, Tailscale mesh) +# NOT needed for client-side VPNs (Proton, NordVPN) — TURN handles those +HAS_VLANS=n +VLAN_SUBNETS= + +# TURN/STUN credentials (coturn) +# Generate new password: openssl rand -base64 18 +TURN_USERNAME=easyasterisk +TURN_PASSWORD=$TURN_PASSWORD + +# TURN port (default 3478 — change if conflicting with UniFi controller) +TURN_PORT=3478 + +# TURN relay port range (forward this range on your router) +TURN_RELAY_MIN=49152 +TURN_RELAY_MAX=49252 + +# RTP media port range +RTP_START=10000 +RTP_END=20000 + +# Web admin interface port +WEB_ADMIN_PORT=8080 +WEB_ADMIN_AUTH_DISABLED=false +ENV + + chmod 600 .env + chown "$ACTUAL_USER:$ACTUAL_USER" .env + + # ── UFW firewall rules ──────────────────────────────────────────────────── + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + log_info "Opening UFW ports for Asterisk..." + ufw allow 5060/udp comment "Asterisk SIP UDP" + ufw allow 5060/tcp comment "Asterisk SIP TCP" + ufw allow 5061/tcp comment "Asterisk SIP TLS" + ufw allow 8080/tcp comment "Asterisk web admin" + ufw allow 3478/udp comment "coturn STUN/TURN" + ufw allow 3478/tcp comment "coturn STUN/TURN TCP" + ufw allow 10000:20000/udp comment "Asterisk RTP media" + ufw allow 49152:49252/udp comment "coturn TURN relay" + log_success "UFW rules added" + else + log_info "UFW not active — open these ports manually if needed:" + log_info " 5060/udp+tcp, 5061/tcp, 3478/udp+tcp, 10000-20000/udp, 49152-49252/udp" + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR" + + # ── Caddy for web admin ─────────────────────────────────────────────────── + configure_caddy_for_service "Asterisk Web Admin" "localhost:8080" "asterisk" + + # ── README ──────────────────────────────────────────────────────────────── + write_readme "$EA_DIR" << MD +# Easy Asterisk PBX + +Home intercom / VoIP system built on Asterisk with self-hosted coturn TURN server. +Personal/home-lab use only. Source: $EA_REPO + +## Access +- Web admin: http://localhost:8080/clients +- FQDN mode: $( [[ -n "$DOMAIN_NAME" ]] && echo "$DOMAIN_NAME" || echo "(LAN-only — no domain configured)" ) + +## Quick start +\`\`\`bash +# Interactive management menu +docker exec -it easy-asterisk easy-asterisk + +# Or run the script directly (requires the container to be running) +sudo bash $EA_DIR/easy-asterisk.sh +\`\`\` + +## Adding devices +Run the management menu and choose "Device Management → Add device". +Each device gets a SIP extension, password, and setup instructions for Linphone or Baresip. + +## Connection types +- **LAN/VPN**: UDP, no encryption — for devices on the local network or WireGuard/Tailscale +- **FQDN**: TLS + SRTP — for devices anywhere on the internet + +## Router port forwards (FQDN mode) +| Port | Protocol | Service | +|------|----------|---------| +| 5061 | TCP | SIP TLS signaling | +| 3478 | UDP+TCP | STUN/TURN | +| 10000-20000 | UDP | RTP media | +| 49152-49252 | UDP | TURN relay | + +## TURN credentials +Username: easyasterisk +Password: (see .env) + +## Manage +\`\`\`bash +cd $EA_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update coturn image +docker compose build --pull && docker compose up -d # rebuild Asterisk image +\`\`\` +MD + + # ── Build and start ─────────────────────────────────────────────────────── + echo "" + local START_EA="" + prompt_yn "Build and start Easy Asterisk now? (y/n):" "y" START_EA + if [[ "$START_EA" =~ ^[Yy]$ ]]; then + log_info "Building Asterisk image (first build takes a few minutes)..." + if docker compose build --pull 2>&1 | tail -5; then + if docker compose up -d; then + log_success "Easy Asterisk started" + echo "" + echo " Web admin: http://localhost:8080/clients" + echo " Management: docker exec -it easy-asterisk easy-asterisk" + echo "" + log_info "Run the management script to add your first device:" + log_info " docker exec -it easy-asterisk easy-asterisk" + else + log_warning "Start failed — check: docker compose logs" + fi + else + log_warning "Build failed — check output above" + fi + fi + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_asterisk From d948e8634127a21dd27aad2007f2940d16df470f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:33:27 +0000 Subject: [PATCH 13/27] Add Nextcloud, OnlyOffice, and Mattermost services Nextcloud: custom Dockerfile (nextcloud:apache + smbclient) for SMB external storage support without AIO. All data uses bind mounts so Kopia/Borg coverage is automatic. Enables files_external app after first boot. OnlyOffice: JWT-secured document server wired to Nextcloud via occ commands and FileBrowser Quantum config.yaml if both are installed. Port 8082. Mattermost: Team Edition + PostgreSQL + dedicated coturn on port 3479 (does not conflict with Easy Asterisk's coturn on 3478). Bind mounts throughout. UFW rules and router port-forward instructions printed at install time. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/mattermost.sh | 431 +++++++++++++++++++++++++++++++++++++++++ services/nextcloud.sh | 362 ++++++++++++++++++++++++++++++++++ services/onlyoffice.sh | 170 ++++++++++++++++ 3 files changed, 963 insertions(+) create mode 100644 services/mattermost.sh create mode 100644 services/nextcloud.sh create mode 100644 services/onlyoffice.sh diff --git a/services/mattermost.sh b/services/mattermost.sh new file mode 100644 index 0000000..b85a58c --- /dev/null +++ b/services/mattermost.sh @@ -0,0 +1,431 @@ +#!/bin/bash +# services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn). +# Part of the modular post-install system (sourced by setup.sh). +# +# Mattermost Team Edition with PostgreSQL and a dedicated coturn TURN server +# (port 3479 — distinct from Easy Asterisk's coturn on 3478). +# +# Can also be run standalone on any machine: +# sudo bash mattermost.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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 + } + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' /dev/null || true + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service mattermost utilities "Team messaging with voice/video calls (Mattermost + coturn)" 8065 + +install_mattermost() { + require_docker || return 1 + log_info "Installing Mattermost Team Edition..." + + local DIR="$DOCKER_DIR/mattermost" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with subdirectories: data logs config plugins db" + echo "[DRY-RUN] Would generate DB password, MM secret key, and TURN secret" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would open UFW ports: 3479/udp+tcp, 49153-49352/udp" + echo "[DRY-RUN] Would configure Caddy reverse proxy for Mattermost" + return 0 + fi + + # ── Create directory structure ──────────────────────────────────────────── + mkdir -p "$DIR"/{data,logs,config,plugins,db} + # Mattermost runs as UID 2000 inside the container + chown -R 2000:2000 "$DIR/data" "$DIR/logs" "$DIR/config" "$DIR/plugins" + ensure_docker_dir_ownership "$DIR/db" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + # ── Generate secrets ────────────────────────────────────────────────────── + local DB_PASS MM_SECRET TURN_SECRET + DB_PASS="$(generate_password 32)" + MM_SECRET="$(generate_password 48)" + TURN_SECRET="$(openssl rand -hex 32 2>/dev/null || generate_password 32)" + + # ── Site URL ────────────────────────────────────────────────────────────── + local SITE_URL="http://localhost:8065" + if [[ -n "$SITE_DOMAIN" && "$SITE_DOMAIN" != "example.com" ]]; then + SITE_URL="https://chat.${SITE_DOMAIN}" + fi + local CONFIGURED_SITEURL="" + prompt_text "Mattermost site URL [${SITE_URL}]:" "$SITE_URL" CONFIGURED_SITEURL + [[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL" + + # ── docker-compose.yml ──────────────────────────────────────────────────── + cat > docker-compose.yml << COMPOSE +# Mattermost Team Edition — generated by ubuntu-post-install +# Manage: docker compose up -d / down / logs -f +# Admin setup: \${MATTERMOST_SITE_URL}/signup_user_complete + +name: mattermost + +services: + + db: + image: postgres:15-alpine + container_name: mattermost-db + restart: unless-stopped + security_opt: + - no-new-privileges:true + pids_limit: 100 + volumes: + - ./db:/var/lib/postgresql/data + environment: + - POSTGRES_USER=mattermost + - POSTGRES_PASSWORD=\${DB_PASS} + - POSTGRES_DB=mattermost + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mattermost"] + interval: 10s + timeout: 5s + retries: 5 + + mattermost: + image: mattermost/mattermost-team-edition:latest + container_name: mattermost + restart: unless-stopped + security_opt: + - no-new-privileges:true + pids_limit: 200 + depends_on: + db: + condition: service_healthy + ports: + - "8065:8065" + volumes: + - ./data:/mattermost/data + - ./logs:/mattermost/logs + - ./config:/mattermost/config + - ./plugins:/mattermost/plugins + environment: + - MM_SQLSETTINGS_DRIVERNAME=postgres + - MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:\${DB_PASS}@db:5432/mattermost?sslmode=disable + - MM_SERVICESETTINGS_SITEURL=\${MATTERMOST_SITE_URL} + - MM_PLUGINSETTINGS_ENABLEUPLOADS=true + - MM_SERVICESETTINGS_ENABLELOCALMODE=true + - TZ=\${TZ} + networks: + - default + - caddy_net + + coturn: + image: coturn/coturn:latest + container_name: mattermost-coturn + restart: unless-stopped + network_mode: host + command: + - -n + - --listening-port=3479 + - --tls-listening-port=5350 + - --listening-ip=0.0.0.0 + - --fingerprint + - --use-auth-secret + - --static-auth-secret=\${TURN_SECRET} + - --realm=\${TURN_REALM} + - --min-port=49153 + - --max-port=49352 + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + +networks: + default: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +COMPOSE + + # ── .env ────────────────────────────────────────────────────────────────── + cat > .env << ENV +# Mattermost — environment configuration +# Edit and restart: docker compose down && docker compose up -d + +# PostgreSQL password (do not change after first start without migrating data) +DB_PASS=$DB_PASS + +# Mattermost secret key (used for signing session tokens) +MM_SECRET=$MM_SECRET + +# Site URL — must match the public URL clients use to access Mattermost +MATTERMOST_SITE_URL=$SITE_URL + +# Timezone +TZ=$SITE_TZ + +# TURN server shared secret for Mattermost Calls plugin +# Generate a new one: openssl rand -hex 32 +TURN_SECRET=$TURN_SECRET + +# TURN realm (typically your domain) +TURN_REALM=${SITE_DOMAIN:-localhost} + +# Caddy network name +CADDY_NET=$SITE_CADDY_NET +ENV + + chmod 600 .env + chown "$ACTUAL_USER:$ACTUAL_USER" .env + + # ── UFW firewall rules ───────────────────────────────────────────────────── + echo "" + log_info "Firewall — Mattermost coturn uses port 3479 (avoiding conflict with Easy Asterisk on 3478)." + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + log_info "Opening UFW ports for Mattermost coturn..." + ufw allow 3479/udp comment "Mattermost coturn STUN/TURN" + ufw allow 3479/tcp comment "Mattermost coturn STUN/TURN TCP" + ufw allow 49153:49352/udp comment "Mattermost coturn relay" + log_success "UFW rules added" + else + log_info "UFW not active — add these rules manually if needed:" + echo " ufw allow 3479/udp comment \"Mattermost coturn STUN/TURN\"" + echo " ufw allow 3479/tcp comment \"Mattermost coturn STUN/TURN TCP\"" + echo " ufw allow 49153:49352/udp comment \"Mattermost coturn relay\"" + fi + + # ── Router port-forward instructions ────────────────────────────────────── + echo "" + echo " ┌─────────────────────────────────────────────────────────────────┐" + echo " │ Router port-forwards needed for Mattermost Calls (external) │" + echo " ├──────────────────┬──────────┬──────────────────────────────────┤" + echo " │ Port(s) │ Protocol │ Service │" + echo " ├──────────────────┼──────────┼──────────────────────────────────┤" + echo " │ 3479 │ UDP+TCP │ coturn STUN/TURN │" + echo " │ 49153–49352 │ UDP │ coturn relay range │" + echo " └──────────────────┴──────────┴──────────────────────────────────┘" + echo "" + + ensure_docker_dir_ownership "$DIR" + + # ── Caddy reverse proxy ─────────────────────────────────────────────────── + configure_caddy_for_service "Mattermost" "mattermost:8065" "chat" + + # ── README ──────────────────────────────────────────────────────────────── + write_readme "$DIR" << MD +# Mattermost + +Team messaging platform with voice/video calls via the Calls plugin and self-hosted coturn TURN server. + +## Access +- Direct: http://localhost:8065 +- Via Caddy: see your configured domain (e.g. https://chat.${SITE_DOMAIN:-example.com}) + +## Initial admin setup +Visit: \`${SITE_URL}/signup_user_complete\` + +The first user to sign up becomes the System Admin. + +## Calls plugin (voice/video) +The Mattermost Calls plugin provides voice/video channels. + +### Enable the plugin +1. Go to **System Console → Plugins → Plugin Management** +2. Enable the **Calls** plugin (pre-installed in Team Edition) + +### Configure TURN server +1. Go to **System Console → Plugins → Calls** +2. Set **TURN server URL**: \`turn::3479\` +3. Set **TURN credentials type**: Static credentials (auth secret) +4. Set **TURN static auth secret**: (see TURN_SECRET in \`$DIR/.env\`) +5. Save and test a call + +Clients outside your LAN need the TURN server to relay media. The coturn +container listens on port 3479 (UDP+TCP) with relay range 49153–49352/UDP. + +## Router port-forwards (for external calls) +| Port(s) | Protocol | Service | +|--------------|----------|--------------------| +| 3479 | UDP+TCP | coturn STUN/TURN | +| 49153–49352 | UDP | coturn relay range | + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # all logs +docker compose logs -f mattermost # app logs only +docker compose logs -f coturn # TURN server logs +docker compose pull && docker compose up -d # update images +\`\`\` + +## Backup +Important paths to back up: +- \`$DIR/data/\` — uploaded files and attachments +- \`$DIR/config/\` — server configuration +- \`$DIR/plugins/\` — installed plugins +- \`$DIR/db/\` — PostgreSQL data directory +- \`$DIR/.env\` — secrets and configuration + +## Configuration +Main config file: \`$DIR/config/config.json\` (created on first start). +Environment variables in \`.env\` override config.json values. +After editing .env: \`docker compose down && docker compose up -d\` +MD + + # ── Start ────────────────────────────────────────────────────────────────── + echo "" + local START="" + prompt_yn "Start Mattermost now? (y/n):" "y" START + if [[ "$START" =~ ^[Yy]$ ]]; then + log_info "Pulling images and starting Mattermost (first start may take a minute)..." + if docker compose pull 2>&1 | tail -3 && docker compose up -d; then + log_success "Mattermost started" + echo "" + echo " App: http://localhost:8065" + echo " Admin setup: ${SITE_URL}/signup_user_complete" + echo "" + log_info "Enable the Calls plugin and configure TURN at:" + log_info " System Console → Plugins → Calls" + log_info " TURN URL: turn::3479" + log_info " TURN secret: (see $DIR/.env → TURN_SECRET)" + else + log_warning "Start failed — check: docker compose logs" + fi + fi + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_mattermost diff --git a/services/nextcloud.sh b/services/nextcloud.sh new file mode 100644 index 0000000..adc6645 --- /dev/null +++ b/services/nextcloud.sh @@ -0,0 +1,362 @@ +#!/bin/bash +# services/nextcloud.sh — Self-hosted cloud storage with SMB/local file access (Nextcloud). +# Part of the modular post-install system (sourced by setup.sh). +# +# Uses a custom Dockerfile (nextcloud:apache + smbclient) so SMB external storage +# works without AIO. All data uses bind mounts under ~/docker/nextcloud/ so that +# Kopia/Borg backup scripts cover everything automatically. +# +# Can also be run standalone on any machine: +# sudo bash nextcloud.sh +# (Docker must already be installed when run standalone) + +# ── 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 + 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 + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + # 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port 8080." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:8080" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service nextcloud utilities "Self-hosted cloud storage with SMB/local file access (Nextcloud)" 8080 + +install_nextcloud() { + require_docker || return 1 + log_info "Installing Nextcloud..." + local DIR="$DOCKER_DIR/nextcloud" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with:" + echo "[DRY-RUN] Dockerfile (nextcloud:apache + smbclient)" + echo "[DRY-RUN] docker-compose.yml (nextcloud + mariadb:10.11)" + echo "[DRY-RUN] .env with generated DB and admin passwords" + echo "[DRY-RUN] Bind-mount directories: html/ db/ config/ custom_apps/" + echo "[DRY-RUN] Would expose Nextcloud on port 8080" + echo "[DRY-RUN] Would enable files_external app via occ after deploy" + return 0 + fi + + mkdir -p "$DIR/html" "$DIR/db" "$DIR/config" "$DIR/custom_apps" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local DB_PASS NC_ADMIN_PASS TZ_VAL + DB_PASS=$(generate_password 32) + NC_ADMIN_PASS=$(generate_password 24) + TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + # ── Dockerfile — adds SMB support to the official apache image ──────────── + cat > Dockerfile << 'DOCKERFILE' +FROM nextcloud:apache + +RUN apt-get update \ + && apt-get install -y --no-install-recommends procps smbclient \ + && rm -rf /var/lib/apt/lists/* +DOCKERFILE + + # ── docker-compose.yml — single-quoted EOF prevents variable expansion ──── + cat > docker-compose.yml << 'EOF' +name: nextcloud + +services: + nextcloud: + build: . + container_name: nextcloud + hostname: nextcloud + restart: unless-stopped + env_file: .env + depends_on: + - db + volumes: + - ./html:/var/www/html + - ./config:/var/www/html/config + - ./custom_apps:/var/www/html/custom_apps + ports: + - "8080:80" + networks: + - caddy_net + + db: + image: mariadb:10.11 + container_name: nextcloud-db + hostname: nextcloud-db + restart: unless-stopped + env_file: .env + volumes: + - ./db:/var/lib/mysql + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +EOF + + # ── .env — actual variable values (NOT inside the compose heredoc) ──────── + cat > .env << NC_ENV +# ── Timezone & network ──────────────────────────────────────────────────────── +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +# ── MariaDB ─────────────────────────────────────────────────────────────────── +MYSQL_ROOT_PASSWORD=$DB_PASS +MYSQL_DATABASE=nextcloud +MYSQL_USER=nextcloud +MYSQL_PASSWORD=$DB_PASS +MARIADB_AUTO_UPGRADE=1 + +# ── Nextcloud bootstrap ─────────────────────────────────────────────────────── +# These are used only on the very first startup to create the admin account +# and wire up the database. They are ignored on subsequent startups. +NEXTCLOUD_ADMIN_USER=admin +NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS +NEXTCLOUD_DB_TYPE=mysql +MYSQL_HOST=db +NC_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + log_success "Nextcloud configured at $DIR" + + configure_caddy_for_service "Nextcloud" "nextcloud:80" "cloud" + + write_readme "$DIR" << MD +# Nextcloud + +Self-hosted cloud storage — files, contacts, calendar, notes, and more. +SMB/local external storage is enabled via a custom Docker image (nextcloud:apache + smbclient). + +## Access +- URL: http://localhost:8080 +- Admin user: \`admin\` +- Admin password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`.env\` + +## Directory layout (all bind-mounted — covered by Kopia/Borg backups) +\`\`\` +$DIR/ + html/ # Nextcloud web root (PHP app + uploaded files) + config/ # config.php and other Nextcloud config files + custom_apps/ # manually installed apps not shipped with Nextcloud + db/ # MariaDB data directory + Dockerfile # custom image definition (adds smbclient) + docker-compose.yml + .env # secrets — chmod 600 +\`\`\` + +## External Storage (SMB / local paths) +The \`files_external\` app is enabled automatically during setup. +Add mounts in the Nextcloud web UI: +**Admin → Administration → External Storage** + +Supported backends: Local, SMB/CIFS, FTP, S3, WebDAV, and more. + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose build --pull && docker compose up -d # rebuild image + update +docker exec --user www-data nextcloud php occ list # occ CLI +\`\`\` + +## Backup note +All data lives under \`$DIR/\` as bind mounts. +Include this directory in your Kopia/Borg backup policy. +Run \`docker compose down\` before a cold backup of \`db/\` for consistency, +or use \`mysqldump\` for a hot backup: +\`\`\`bash +docker exec nextcloud-db mysqldump -u nextcloud -p\$MYSQL_PASSWORD nextcloud > nextcloud_db.sql +\`\`\` +MD + + local START_NC="" + prompt_yn "Start Nextcloud now? (y/n):" "y" START_NC + if [ "$START_NC" = "y" ] || [ "$START_NC" = "Y" ]; then + docker compose up -d \ + && log_success "Nextcloud started — first boot may take 1-2 minutes" \ + || { log_warning "Start failed — check: docker compose logs"; return 1; } + + # Wait for Nextcloud to finish first-boot initialisation before running occ + log_info "Waiting for Nextcloud to finish initialising (up to 90 s)..." + local _waited=0 + until docker exec --user www-data nextcloud php occ status --output=json 2>/dev/null \ + | grep -q '"installed":true'; do + sleep 5 + _waited=$(( _waited + 5 )) + if (( _waited >= 90 )); then + log_warning "Nextcloud did not finish initialising within 90 s." + log_warning "Run the occ command manually once the container is ready:" + log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" + break + fi + done + + if (( _waited < 90 )); then + if docker exec --user www-data nextcloud php occ app:enable files_external; then + log_success "External Storage app enabled" + else + log_warning "Could not enable files_external — run manually:" + log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" + fi + fi + fi + + echo "" + echo " URL: http://localhost:8080" + echo " Admin user: admin" + echo " Admin password: $NC_ADMIN_PASS" + echo " (Credentials also saved to $DIR/.env)" + echo "" + echo " To add SMB or local external storage:" + echo " Nextcloud → Admin → Administration → External Storage" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_nextcloud diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh new file mode 100644 index 0000000..4fb73f0 --- /dev/null +++ b/services/onlyoffice.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server for Nextcloud/FileBrowser. +# Part of the modular post-install system (sourced by setup.sh). +# +# OnlyOffice Document Server provides collaborative editing for Nextcloud and +# other platforms. JWT is enabled to secure the API endpoint. + +register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server for Nextcloud/FileBrowser" 8082 + +install_onlyoffice() { + require_docker || return 1 + log_info "Installing OnlyOffice Document Server..." + local DIR="$DOCKER_DIR/onlyoffice" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" + echo "[DRY-RUN] Would deploy onlyoffice/documentserver:latest on port 8082" + echo "[DRY-RUN] Would generate JWT secret" + echo "[DRY-RUN] Would configure Nextcloud via occ (if $DOCKER_DIR/nextcloud exists)" + echo "[DRY-RUN] Would configure FileBrowser config.yaml (if present)" + return 0 + fi + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local JWT_SECRET + JWT_SECRET=$(generate_password 32) + + cat > docker-compose.yml << 'OO_COMPOSE' +name: onlyoffice + +services: + onlyoffice: + image: onlyoffice/documentserver:latest + container_name: onlyoffice + hostname: onlyoffice + restart: unless-stopped + env_file: .env + ports: + - "8082:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +OO_COMPOSE + + cat > .env << OO_ENV +# ── OnlyOffice Document Server ──────────────────────────────────────────────── +CADDY_NET=$SITE_CADDY_NET + +# JWT authentication — keep JWT_SECRET secret; used by Nextcloud integration +JWT_ENABLED=true +JWT_SECRET=$JWT_SECRET +JWT_HEADER=AuthorizationJwt +OO_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + log_success "OnlyOffice configured at $DIR" + + configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" + + # ── Start container ──────────────────────────────────────────────────────── + local START="" + prompt_yn "Start OnlyOffice now? (y/n):" "y" START + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "OnlyOffice started" \ + || log_warning "Start failed — check: docker compose logs" + fi + + # ── Nextcloud integration ────────────────────────────────────────────────── + if [ -d "$DOCKER_DIR/nextcloud" ]; then + log_info "Nextcloud detected — configuring OnlyOffice integration via occ..." + docker exec --user www-data nextcloud php occ app:enable onlyoffice \ + && log_success "OnlyOffice app enabled in Nextcloud" \ + || log_warning "Could not enable OnlyOffice app — run manually: docker exec --user www-data nextcloud php occ app:enable onlyoffice" + docker exec --user www-data nextcloud php occ config:app:set onlyoffice DocumentServerUrl --value "http://onlyoffice:80/" \ + && log_success "Nextcloud DocumentServerUrl set" \ + || log_warning "Could not set DocumentServerUrl" + docker exec --user www-data nextcloud php occ config:app:set onlyoffice jwt_secret --value "$JWT_SECRET" \ + && log_success "Nextcloud jwt_secret set" \ + || log_warning "Could not set jwt_secret" + docker exec --user www-data nextcloud php occ config:app:set onlyoffice jwt_header --value "AuthorizationJwt" \ + && log_success "Nextcloud jwt_header set" \ + || log_warning "Could not set jwt_header" + else + echo "" + echo " Nextcloud not found. To integrate OnlyOffice with Nextcloud manually:" + echo " 1. Install the OnlyOffice app in Nextcloud (Apps > Office & Text)" + echo " 2. Go to Settings > OnlyOffice and set:" + echo " Document Server URL: http://onlyoffice:80/" + echo " JWT Secret: $JWT_SECRET" + echo " JWT Header: AuthorizationJwt" + echo "" + fi + + # ── FileBrowser integration ──────────────────────────────────────────────── + local FB_CONFIG="$DOCKER_DIR/filebrowser/data/config.yaml" + if [ -f "$FB_CONFIG" ]; then + if command -v yq >/dev/null 2>&1; then + yq e -i '.officeServer = "http://onlyoffice:80/"' "$FB_CONFIG" \ + && log_success "FileBrowser config.yaml updated with officeServer" \ + || log_warning "yq failed to update $FB_CONFIG — set officeServer manually" + else + log_info "yq not found. To enable OnlyOffice in FileBrowser, add to $FB_CONFIG:" + log_info " officeServer: \"http://onlyoffice:80/\"" + fi + fi + + write_readme "$DIR" << MD +# OnlyOffice Document Server + +Self-hosted document editing server. Integrates with Nextcloud and FileBrowser +to provide collaborative editing of ODT, DOCX, XLSX, and PPTX files. + +## JWT Secret +The JWT secret is stored in \`.env\` (chmod 600). If you rotate it, update: +- Nextcloud: Settings > OnlyOffice > JWT Secret +- Any other integrations using this server + +JWT Secret (at install time): see \`JWT_SECRET\` in .env + +## Nextcloud Integration +If Nextcloud was running at install time, the OnlyOffice app was auto-configured. +To reconfigure or verify: +\`\`\`bash +docker exec --user www-data nextcloud php occ config:app:get onlyoffice DocumentServerUrl +docker exec --user www-data nextcloud php occ config:app:get onlyoffice jwt_secret +\`\`\` + +## FileBrowser Integration +Set \`officeServer: "http://onlyoffice:80/"\` in FileBrowser's config.yaml, then +restart FileBrowser. + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + echo "" + echo " OnlyOffice Document Server" + echo " Directory: $DIR" + echo " Port: 8082 (internal: 80)" + echo " JWT Secret: $JWT_SECRET" + echo " (Secret also saved to $DIR/.env)" + echo "" +} + +# ── Standalone bootstrap ─────────────────────────────────────────────────────── +# Run this file directly to install OnlyOffice without the full setup.sh wizard: +# sudo _RUN_STANDALONE=1 bash services/onlyoffice.sh +if [[ "${_RUN_STANDALONE:-0}" == 1 ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # shellcheck source=../lib/common.sh + source "$SCRIPT_DIR/../lib/common.sh" + require_root + load_site_config 2>/dev/null || true + install_onlyoffice +fi From 0d81839c8079ef84b7d07bfccdca8a02de48cdfb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:43:33 +0000 Subject: [PATCH 14/27] Vendor easy-asterisk source files; fix asterisk.sh and onlyoffice.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vendor/easy-asterisk/: All source files from outis1one/easy-asterisk v0.10.0 vendored so the repo is self-contained — no internet required at install time. Includes the real Dockerfile (FROM ubuntu:24.04 + full Asterisk stack), entrypoint.sh (IP detection, TLS cert gen, pjsip/rtp config, web admin), coturn-entrypoint.sh (robust IP detection wrapper), and the management script + diagnostic utilities. services/asterisk.sh: Rewritten to copy from vendor/ instead of downloading at runtime. Uses the upstream Dockerfile verbatim. Symlinks easy-asterisk-v0.10.0.sh → easy-asterisk.sh for build context compatibility. services/onlyoffice.sh: Complete rewrite with correct standalone bootstrap. _ensure_yq() installs yq v4 automatically (arch-aware). JWT secret is preserved across re-runs so rotating is explicit. _wire_nextcloud() and _wire_filebrowser() run on every install invocation (idempotent), skipping gracefully when containers aren't running rather than failing. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/asterisk.sh | 262 +- services/onlyoffice.sh | 348 +- vendor/easy-asterisk/.env.example | 99 + vendor/easy-asterisk/Dockerfile | 103 + .../easy-asterisk/docker/coturn-entrypoint.sh | 26 + vendor/easy-asterisk/docker/entrypoint.sh | 449 ++ vendor/easy-asterisk/easy-asterisk-v0.10.0.sh | 6929 +++++++++++++++++ vendor/easy-asterisk/scripts/dns-whitelist.sh | 280 + .../easy-asterisk/scripts/vpn-diagnostics.sh | 366 + 9 files changed, 8650 insertions(+), 212 deletions(-) create mode 100644 vendor/easy-asterisk/.env.example create mode 100644 vendor/easy-asterisk/Dockerfile create mode 100644 vendor/easy-asterisk/docker/coturn-entrypoint.sh create mode 100644 vendor/easy-asterisk/docker/entrypoint.sh create mode 100644 vendor/easy-asterisk/easy-asterisk-v0.10.0.sh create mode 100644 vendor/easy-asterisk/scripts/dns-whitelist.sh create mode 100644 vendor/easy-asterisk/scripts/vpn-diagnostics.sh diff --git a/services/asterisk.sh b/services/asterisk.sh index e68d092..060bf36 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -3,6 +3,7 @@ # Part of the modular post-install system (sourced by setup.sh). # # Based on https://github.com/outis1one/easy-asterisk +# Source files vendored in vendor/easy-asterisk/ # Personal/home-lab use only. Not for commercial or emergency services. # # Can also be run standalone on any machine: @@ -153,46 +154,64 @@ install_asterisk() { log_info "Installing Easy Asterisk PBX..." local EA_DIR="$DOCKER_DIR/asterisk" - local EA_REPO="https://github.com/outis1one/easy-asterisk" - local EA_SCRIPT_URL="https://raw.githubusercontent.com/outis1one/easy-asterisk/main/easy-asterisk-v0.10.0.sh" - local EA_COTURN_URL="https://raw.githubusercontent.com/outis1one/easy-asterisk/main/docker/coturn-entrypoint.sh" + + # Locate vendored source files (works when sourced by setup.sh or run standalone) + local _script_dir + _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" \ + || _script_dir="$(dirname "$(realpath "$0" 2>/dev/null || echo "$0")")" + local VENDOR_DIR="$_script_dir/../vendor/easy-asterisk" + VENDOR_DIR="$(cd "$VENDOR_DIR" 2>/dev/null && pwd)" || VENDOR_DIR="" + + if [[ -z "$VENDOR_DIR" || ! -f "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ]]; then + log_warning "Vendored easy-asterisk files not found at $VENDOR_DIR" + log_warning "Expected: vendor/easy-asterisk/ alongside services/ directory" + log_error "Cannot install — run from the ubuntu-post-install repo root." + return 1 + fi if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $EA_DIR" - echo "[DRY-RUN] Would download management script and coturn entrypoint" - echo "[DRY-RUN] Would write docker-compose.yml, .env, Dockerfile" + echo "[DRY-RUN] Would copy vendored easy-asterisk files (Dockerfile, scripts, entrypoints)" + echo "[DRY-RUN] Would write docker-compose.yml, .env" echo "[DRY-RUN] Would open UFW ports for SIP/RTP/TURN" return 0 fi - mkdir -p "$EA_DIR/docker" + mkdir -p "$EA_DIR/docker" "$EA_DIR/scripts" ensure_docker_dir_ownership "$EA_DIR" cd "$EA_DIR" || return 1 - # ── Download management script ──────────────────────────────────────────── - log_info "Downloading Easy Asterisk management script..." - if curl -fsSL "$EA_SCRIPT_URL" -o "$EA_DIR/easy-asterisk.sh"; then - chmod 750 "$EA_DIR/easy-asterisk.sh" - chown "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR/easy-asterisk.sh" - log_success "Management script saved to $EA_DIR/easy-asterisk.sh" - else - log_warning "Could not download management script — check network or fetch manually from $EA_REPO" - fi + # ── Copy vendored source files ──────────────────────────────────────────── + log_info "Copying Easy Asterisk source files from vendor/..." - # ── Download coturn custom entrypoint ───────────────────────────────────── - if curl -fsSL "$EA_COTURN_URL" -o "$EA_DIR/docker/coturn-entrypoint.sh"; then - chmod 755 "$EA_DIR/docker/coturn-entrypoint.sh" - else - log_warning "Could not download coturn-entrypoint.sh — coturn may fail to start" - fi + cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" "$EA_DIR/easy-asterisk.sh" + cp "$VENDOR_DIR/Dockerfile" "$EA_DIR/Dockerfile" + cp "$VENDOR_DIR/docker/entrypoint.sh" "$EA_DIR/docker/entrypoint.sh" + cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" + cp "$VENDOR_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/vpn-diagnostics.sh" + cp "$VENDOR_DIR/scripts/dns-whitelist.sh" "$EA_DIR/scripts/dns-whitelist.sh" + + chmod 750 "$EA_DIR/easy-asterisk.sh" + chmod 755 "$EA_DIR/docker/entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" + chmod 755 "$EA_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/dns-whitelist.sh" + + log_success "Source files copied" + + # ── The Dockerfile expects these paths inside the build context ─────────── + # vendor Dockerfile: COPY easy-asterisk-v0.10.0.sh → /usr/local/bin/easy-asterisk + # We copy as easy-asterisk.sh locally, so symlink the expected filename for the build + ln -sf easy-asterisk.sh "$EA_DIR/easy-asterisk-v0.10.0.sh" # ── FQDN setup ──────────────────────────────────────────────────────────── echo "" - echo " Easy Asterisk requires a domain name (FQDN) that points to this" - echo " server's public IP. SIP clients connect to this domain over TLS." + echo " Easy Asterisk can run in two modes:" echo "" - echo " For LAN-only use without a domain, leave this blank." - echo " (LAN mode uses UDP — no TLS, no coturn needed.)" + echo " LAN/VPN — UDP transport, no TLS, no TURN." + echo " Simple setup for devices on your local network or WireGuard/Tailscale." + echo "" + echo " FQDN — TLS + SRTP + coturn TURN relay." + echo " Works from anywhere: LAN, cellular, hotel WiFi, Proton VPN." + echo " Requires a domain name pointing to this server's public IP." echo "" local DOMAIN_NAME="" @@ -206,64 +225,35 @@ install_asterisk() { log_info "FQDN mode: $DOMAIN_NAME" echo "" echo " Required router port forwards:" - echo " 5061/tcp → SIP TLS signaling" - echo " 3478/udp+tcp → STUN/TURN (NAT traversal)" - echo " 10000-20000/udp → RTP media" - echo " 49152-49252/udp → TURN relay range" + printf " %-22s %s\n" "5061/tcp" "SIP TLS signaling" + printf " %-22s %s\n" "3478/udp+tcp" "STUN/TURN (NAT traversal)" + printf " %-22s %s\n" "10000-20000/udp" "RTP media (Asterisk)" + printf " %-22s %s\n" "49152-49252/udp" "TURN relay range (coturn)" echo "" fi - # ── Generate passwords ──────────────────────────────────────────────────── + # ── Generate TURN password ──────────────────────────────────────────────── local TURN_PASSWORD - TURN_PASSWORD="$(openssl rand -base64 18 2>/dev/null || tr -dc 'A-Za-z0-9' Dockerfile << 'DOCKERFILE' -FROM ubuntu:24.04 - -ENV DEBIAN_FRONTEND=noninteractive - -RUN apt-get update && apt-get install -y \ - asterisk \ - asterisk-core-sounds-en-gsm \ - asterisk-modules \ - ca-certificates \ - openssl \ - curl \ - wget \ - tcpdump \ - sngrep \ - net-tools \ - iproute2 \ - iputils-ping \ - python3 \ - && rm -rf /var/lib/apt/lists/* - -# Management scripts (bind-mounted at runtime from host) -COPY easy-asterisk.sh /usr/local/bin/easy-asterisk -RUN chmod +x /usr/local/bin/easy-asterisk - -EXPOSE 5060/udp 5060/tcp 5061/tcp -EXPOSE 8080/tcp 8088/tcp 8089/tcp -EXPOSE 3478/udp -EXPOSE 10000-10100/udp - -HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD asterisk -rx "core show version" 2>/dev/null | grep -q "Asterisk" || exit 1 -DOCKERFILE + TURN_PASSWORD="$(openssl rand -base64 18 2>/dev/null | tr -dc 'a-zA-Z0-9' | head -c 24 \ + || tr -dc 'A-Za-z0-9' docker-compose.yml << COMPOSE -# Easy Asterisk — generated by ubuntu-post-install + # Uses the real upstream Dockerfile (FROM ubuntu:24.04 + full Asterisk install) + # with host networking for RTP/NAT, and the custom coturn entrypoint. + cat > docker-compose.yml << 'COMPOSE_EOF' +# Easy Asterisk — managed by ubuntu-post-install # Manage: docker exec -it easy-asterisk easy-asterisk -# Source: $EA_REPO +# Source: https://github.com/outis1one/easy-asterisk services: asterisk: - build: . + build: + context: . + dockerfile: Dockerfile container_name: easy-asterisk - # Host networking: required for RTP (10000-20000/udp) and proper NAT detection + # Host networking: required for RTP (10000-20000/udp) and proper NAT detection. + # SIP clients connect directly to the host IP; Caddy is only used for the web admin. network_mode: host depends_on: coturn: @@ -274,23 +264,27 @@ services: - asterisk-logs:/var/log/asterisk - asterisk-spool:/var/spool/asterisk - asterisk-lib:/var/lib/asterisk - - ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro environment: - - DOMAIN_NAME=\${DOMAIN_NAME} - - ENABLE_TLS=\${ENABLE_TLS:-y} - - PUBLIC_IP=\${PUBLIC_IP:-} - - LOCAL_CIDR=\${LOCAL_CIDR:-} - - HAS_VLANS=\${HAS_VLANS:-n} - - VLAN_SUBNETS=\${VLAN_SUBNETS:-} - - TURN_ENABLED=\${TURN_ENABLED:-y} - - TURN_SERVER=\${DOMAIN_NAME}:\${TURN_PORT:-3478} - - TURN_USERNAME=\${TURN_USERNAME:-easyasterisk} - - TURN_PASSWORD=\${TURN_PASSWORD} - - RTP_START=\${RTP_START:-10000} - - RTP_END=\${RTP_END:-20000} - - WEB_ADMIN_PORT=\${WEB_ADMIN_PORT:-8080} - - WEB_ADMIN_AUTH_DISABLED=\${WEB_ADMIN_AUTH_DISABLED:-false} + - DOMAIN_NAME=${DOMAIN_NAME} + - ENABLE_TLS=${ENABLE_TLS:-y} + - PUBLIC_IP=${PUBLIC_IP:-} + - LOCAL_CIDR=${LOCAL_CIDR:-} + - HAS_VLANS=${HAS_VLANS:-n} + - VLAN_SUBNETS=${VLAN_SUBNETS:-} + - TURN_ENABLED=${TURN_ENABLED:-y} + - TURN_SERVER=${DOMAIN_NAME}:${TURN_PORT:-3478} + - TURN_USERNAME=${TURN_USERNAME:-easyasterisk} + - TURN_PASSWORD=${TURN_PASSWORD} + - RTP_START=${RTP_START:-10000} + - RTP_END=${RTP_END:-20000} + - WEB_ADMIN_PORT=${WEB_ADMIN_PORT:-8080} + - WEB_ADMIN_AUTH_DISABLED=${WEB_ADMIN_AUTH_DISABLED:-false} restart: unless-stopped + healthcheck: + test: ["CMD", "asterisk", "-rx", "core show version"] + interval: 30s + timeout: 5s + retries: 3 coturn: image: coturn/coturn:latest @@ -301,17 +295,17 @@ services: volumes: - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro environment: - - PUBLIC_IP=\${PUBLIC_IP:-} + - PUBLIC_IP=${PUBLIC_IP:-} command: - -n - - --listening-port=\${TURN_PORT:-3478} + - --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=\${TURN_RELAY_MIN:-49152} - - --max-port=\${TURN_RELAY_MAX:-49252} + - --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD} + - --realm=${DOMAIN_NAME:-localhost} + - --min-port=${TURN_RELAY_MIN:-49152} + - --max-port=${TURN_RELAY_MAX:-49252} - --no-tls - --no-dtls - --no-cli @@ -325,7 +319,7 @@ volumes: asterisk-logs: asterisk-spool: asterisk-lib: -COMPOSE +COMPOSE_EOF # ── .env ───────────────────────────────────────────────────────────────── cat > .env << ENV @@ -338,34 +332,33 @@ DOMAIN_NAME=$DOMAIN_NAME # Public IP — leave empty to auto-detect PUBLIC_IP= -# TLS — set to 'n' for LAN-only mode +# TLS — always 'y' for remote access, 'n' for LAN-only ENABLE_TLS=$( [[ "$LAN_ONLY" == "true" ]] && echo "n" || echo "y" ) # Local network CIDR — auto-detected if empty LOCAL_CIDR= -# Additional subnets for site-to-site VPNs (WireGuard, Tailscale mesh) -# NOT needed for client-side VPNs (Proton, NordVPN) — TURN handles those +# Additional subnets for site-to-site VPNs (WireGuard/Tailscale mesh, NOT client-side) HAS_VLANS=n VLAN_SUBNETS= -# TURN/STUN credentials (coturn) -# Generate new password: openssl rand -base64 18 +# TURN/STUN credentials — must match in both Asterisk and coturn +# Regenerate: openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24 TURN_USERNAME=easyasterisk TURN_PASSWORD=$TURN_PASSWORD -# TURN port (default 3478 — change if conflicting with UniFi controller) +# TURN port (change to 3479 if 3478 conflicts with UniFi controller or Mattermost) TURN_PORT=3478 -# TURN relay port range (forward this range on your router) +# TURN relay port range — forward this range on your router TURN_RELAY_MIN=49152 TURN_RELAY_MAX=49252 -# RTP media port range +# RTP media port range — forward this range on your router RTP_START=10000 RTP_END=20000 -# Web admin interface port +# Web admin interface WEB_ADMIN_PORT=8080 WEB_ADMIN_AUTH_DISABLED=false ENV @@ -376,18 +369,19 @@ ENV # ── UFW firewall rules ──────────────────────────────────────────────────── if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then log_info "Opening UFW ports for Asterisk..." - ufw allow 5060/udp comment "Asterisk SIP UDP" - ufw allow 5060/tcp comment "Asterisk SIP TCP" - ufw allow 5061/tcp comment "Asterisk SIP TLS" - ufw allow 8080/tcp comment "Asterisk web admin" - ufw allow 3478/udp comment "coturn STUN/TURN" - ufw allow 3478/tcp comment "coturn STUN/TURN TCP" - ufw allow 10000:20000/udp comment "Asterisk RTP media" - ufw allow 49152:49252/udp comment "coturn TURN relay" + ufw allow 5060/udp comment "Asterisk SIP UDP" >/dev/null + ufw allow 5060/tcp comment "Asterisk SIP TCP" >/dev/null + ufw allow 5061/tcp comment "Asterisk SIP TLS" >/dev/null + ufw allow 8080/tcp comment "Asterisk web admin" >/dev/null + ufw allow 3478/udp comment "coturn STUN/TURN UDP" >/dev/null + ufw allow 3478/tcp comment "coturn STUN/TURN TCP" >/dev/null + ufw allow 10000:20000/udp comment "Asterisk RTP media" >/dev/null + ufw allow 49152:49252/udp comment "coturn TURN relay" >/dev/null log_success "UFW rules added" else log_info "UFW not active — open these ports manually if needed:" - log_info " 5060/udp+tcp, 5061/tcp, 3478/udp+tcp, 10000-20000/udp, 49152-49252/udp" + log_info " 5060/udp+tcp, 5061/tcp, 8080/tcp, 3478/udp+tcp" + log_info " 10000-20000/udp (RTP), 49152-49252/udp (TURN relay)" fi chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR" @@ -400,28 +394,32 @@ ENV # Easy Asterisk PBX Home intercom / VoIP system built on Asterisk with self-hosted coturn TURN server. -Personal/home-lab use only. Source: $EA_REPO +Personal/home-lab use only. Source: https://github.com/outis1one/easy-asterisk ## Access - Web admin: http://localhost:8080/clients -- FQDN mode: $( [[ -n "$DOMAIN_NAME" ]] && echo "$DOMAIN_NAME" || echo "(LAN-only — no domain configured)" ) +- FQDN: $( [[ -n "$DOMAIN_NAME" ]] && echo "$DOMAIN_NAME" || echo "(LAN-only — no domain)" ) -## Quick start +## Management \`\`\`bash -# Interactive management menu +# Interactive management menu (add devices, provisioning, diagnostics) docker exec -it easy-asterisk easy-asterisk -# Or run the script directly (requires the container to be running) -sudo bash $EA_DIR/easy-asterisk.sh +# VPN diagnostics +docker exec -it easy-asterisk vpn-diagnostics + +# DNS whitelist check +docker exec -it easy-asterisk dns-whitelist \`\`\` ## Adding devices -Run the management menu and choose "Device Management → Add device". -Each device gets a SIP extension, password, and setup instructions for Linphone or Baresip. +Run the management menu → Device Management → Add device. +Each device gets a SIP extension, password, and setup instructions +for Linphone (remote provisioning) or Baresip (manual). -## Connection types -- **LAN/VPN**: UDP, no encryption — for devices on the local network or WireGuard/Tailscale -- **FQDN**: TLS + SRTP — for devices anywhere on the internet +## Connection modes +- **LAN/VPN**: UDP, no encryption — local network or WireGuard/Tailscale +- **FQDN**: TLS + SRTP + coturn TURN relay — works from anywhere ## Router port forwards (FQDN mode) | Port | Protocol | Service | @@ -431,18 +429,19 @@ Each device gets a SIP extension, password, and setup instructions for Linphone | 10000-20000 | UDP | RTP media | | 49152-49252 | UDP | TURN relay | -## TURN credentials -Username: easyasterisk -Password: (see .env) +## TURN credentials (for SIP clients behind strict NAT) +- Server: \${DOMAIN_NAME}:3478 +- Username: easyasterisk +- Password: (see .env → TURN_PASSWORD) ## Manage \`\`\`bash cd $EA_DIR -docker compose up -d # start -docker compose down # stop -docker compose logs -f # logs -docker compose pull && docker compose up -d # update coturn image -docker compose build --pull && docker compose up -d # rebuild Asterisk image +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull # update coturn image +docker compose build --pull && docker compose up -d # rebuild Asterisk image \`\`\` MD @@ -459,8 +458,7 @@ MD echo " Web admin: http://localhost:8080/clients" echo " Management: docker exec -it easy-asterisk easy-asterisk" echo "" - log_info "Run the management script to add your first device:" - log_info " docker exec -it easy-asterisk easy-asterisk" + log_info "Next: add your first device via the management menu." else log_warning "Start failed — check: docker compose logs" fi diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh index 4fb73f0..12206b6 100644 --- a/services/onlyoffice.sh +++ b/services/onlyoffice.sh @@ -1,32 +1,267 @@ #!/bin/bash -# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server for Nextcloud/FileBrowser. +# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server. # Part of the modular post-install system (sourced by setup.sh). # -# OnlyOffice Document Server provides collaborative editing for Nextcloud and -# other platforms. JWT is enabled to secure the API endpoint. +# Can also be run standalone on any machine: +# sudo bash onlyoffice.sh +# (Docker must already be installed when run standalone) -register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server for Nextcloud/FileBrowser" 8082 +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + + generate_password() { + local len="${1:-32}" + tr -dc 'A-Za-z0-9' /dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082 + +# ── Ensure yq v4 is installed ───────────────────────────────────────────────── +_ensure_yq() { + if command -v yq &>/dev/null; then + local major + major=$(yq --version 2>&1 | grep -oP '(?<=v)\d+' | head -1 || echo 0) + [[ "$major" -ge 4 ]] && return 0 + log_info "yq found but version < 4 — reinstalling..." + else + log_info "yq not found — installing..." + fi + local arch + arch=$(uname -m) + local yq_bin="yq_linux_amd64" + [[ "$arch" == "aarch64" || "$arch" == "arm64" ]] && yq_bin="yq_linux_arm64" + if wget -qO /usr/local/bin/yq \ + "https://github.com/mikefarah/yq/releases/latest/download/${yq_bin}" \ + && chmod +x /usr/local/bin/yq; then + log_success "yq installed ($(yq --version 2>&1 | head -1))" + else + log_warning "Could not install yq — FileBrowser config.yaml will need manual update" + return 1 + fi +} + +# ── Wire OnlyOffice into Nextcloud ──────────────────────────────────────────── +_wire_nextcloud() { + local jwt_secret="$1" + local nc_dir="$DOCKER_DIR/nextcloud" + + [[ -d "$nc_dir" ]] || return 0 + + log_info "Nextcloud detected — wiring OnlyOffice integration..." + + if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^nextcloud$"; then + log_warning "Nextcloud container not running — skipping occ wiring." + log_info " Start Nextcloud and re-run: sudo bash $0" + return 0 + fi + + docker exec --user www-data nextcloud php occ app:enable onlyoffice \ + && log_success "OnlyOffice app enabled in Nextcloud" \ + || log_warning "app:enable failed — may already be enabled" + docker exec --user www-data nextcloud php occ \ + config:app:set onlyoffice DocumentServerUrl \ + --value "http://onlyoffice:80/" \ + && log_success "DocumentServerUrl → http://onlyoffice:80/" \ + || log_warning "Could not set DocumentServerUrl" + docker exec --user www-data nextcloud php occ \ + config:app:set onlyoffice jwt_secret \ + --value "$jwt_secret" \ + && log_success "jwt_secret set" \ + || log_warning "Could not set jwt_secret" + docker exec --user www-data nextcloud php occ \ + config:app:set onlyoffice jwt_header \ + --value "AuthorizationJwt" \ + && log_success "jwt_header set" \ + || log_warning "Could not set jwt_header" +} + +# ── Wire OnlyOffice into FileBrowser Quantum ────────────────────────────────── +_wire_filebrowser() { + local fb_config="$DOCKER_DIR/filebrowser/data/config.yaml" + + [[ -f "$fb_config" ]] || return 0 + + log_info "FileBrowser Quantum detected — updating config.yaml..." + + if ! _ensure_yq; then + log_info "Set officeServer manually in $fb_config:" + log_info " officeServer: \"http://onlyoffice:80/\"" + return 0 + fi + + yq e -i '.officeServer = "http://onlyoffice:80/"' "$fb_config" \ + && log_success "FileBrowser config.yaml: officeServer → http://onlyoffice:80/" \ + || log_warning "yq failed — set officeServer manually in $fb_config" + + if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^filebrowser$"; then + docker restart filebrowser >/dev/null 2>&1 \ + && log_info "FileBrowser restarted to pick up config change" \ + || log_warning "Could not restart FileBrowser container" + fi +} install_onlyoffice() { require_docker || return 1 log_info "Installing OnlyOffice Document Server..." + local DIR="$DOCKER_DIR/onlyoffice" if [ "$DRY_RUN" = true ]; then echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" echo "[DRY-RUN] Would deploy onlyoffice/documentserver:latest on port 8082" - echo "[DRY-RUN] Would generate JWT secret" - echo "[DRY-RUN] Would configure Nextcloud via occ (if $DOCKER_DIR/nextcloud exists)" - echo "[DRY-RUN] Would configure FileBrowser config.yaml (if present)" + echo "[DRY-RUN] Would install yq if missing" + echo "[DRY-RUN] Would wire OnlyOffice into Nextcloud (if running)" + echo "[DRY-RUN] Would wire OnlyOffice into FileBrowser Quantum (if present)" return 0 fi + # Always install yq — needed for FBQ config patching + _ensure_yq || true + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - local JWT_SECRET - JWT_SECRET=$(generate_password 32) + # Generate JWT secret (or read existing one so re-runs don't rotate it) + local JWT_SECRET="" + if [[ -f "$DIR/.env" ]]; then + JWT_SECRET=$(grep "^JWT_SECRET=" "$DIR/.env" 2>/dev/null | cut -d= -f2-) + fi + [[ -z "$JWT_SECRET" ]] && JWT_SECRET="$(generate_password 32)" cat > docker-compose.yml << 'OO_COMPOSE' name: onlyoffice @@ -50,10 +285,12 @@ networks: OO_COMPOSE cat > .env << OO_ENV -# ── OnlyOffice Document Server ──────────────────────────────────────────────── +# OnlyOffice Document Server — environment CADDY_NET=$SITE_CADDY_NET -# JWT authentication — keep JWT_SECRET secret; used by Nextcloud integration +# JWT authentication — keep JWT_SECRET private +# If you rotate it, update Nextcloud (occ config:app:set onlyoffice jwt_secret) +# and any other integration that uses this server JWT_ENABLED=true JWT_SECRET=$JWT_SECRET JWT_HEADER=AuthorizationJwt @@ -61,82 +298,43 @@ OO_ENV chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" - log_success "OnlyOffice configured at $DIR" configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" - # ── Start container ──────────────────────────────────────────────────────── local START="" prompt_yn "Start OnlyOffice now? (y/n):" "y" START - if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + if [[ "$START" =~ ^[Yy]$ ]]; then docker compose up -d \ && log_success "OnlyOffice started" \ || log_warning "Start failed — check: docker compose logs" fi - # ── Nextcloud integration ────────────────────────────────────────────────── - if [ -d "$DOCKER_DIR/nextcloud" ]; then - log_info "Nextcloud detected — configuring OnlyOffice integration via occ..." - docker exec --user www-data nextcloud php occ app:enable onlyoffice \ - && log_success "OnlyOffice app enabled in Nextcloud" \ - || log_warning "Could not enable OnlyOffice app — run manually: docker exec --user www-data nextcloud php occ app:enable onlyoffice" - docker exec --user www-data nextcloud php occ config:app:set onlyoffice DocumentServerUrl --value "http://onlyoffice:80/" \ - && log_success "Nextcloud DocumentServerUrl set" \ - || log_warning "Could not set DocumentServerUrl" - docker exec --user www-data nextcloud php occ config:app:set onlyoffice jwt_secret --value "$JWT_SECRET" \ - && log_success "Nextcloud jwt_secret set" \ - || log_warning "Could not set jwt_secret" - docker exec --user www-data nextcloud php occ config:app:set onlyoffice jwt_header --value "AuthorizationJwt" \ - && log_success "Nextcloud jwt_header set" \ - || log_warning "Could not set jwt_header" - else - echo "" - echo " Nextcloud not found. To integrate OnlyOffice with Nextcloud manually:" - echo " 1. Install the OnlyOffice app in Nextcloud (Apps > Office & Text)" - echo " 2. Go to Settings > OnlyOffice and set:" - echo " Document Server URL: http://onlyoffice:80/" - echo " JWT Secret: $JWT_SECRET" - echo " JWT Header: AuthorizationJwt" - echo "" - fi - - # ── FileBrowser integration ──────────────────────────────────────────────── - local FB_CONFIG="$DOCKER_DIR/filebrowser/data/config.yaml" - if [ -f "$FB_CONFIG" ]; then - if command -v yq >/dev/null 2>&1; then - yq e -i '.officeServer = "http://onlyoffice:80/"' "$FB_CONFIG" \ - && log_success "FileBrowser config.yaml updated with officeServer" \ - || log_warning "yq failed to update $FB_CONFIG — set officeServer manually" - else - log_info "yq not found. To enable OnlyOffice in FileBrowser, add to $FB_CONFIG:" - log_info " officeServer: \"http://onlyoffice:80/\"" - fi - fi + # Wire into integrations every run (idempotent) + echo "" + _wire_nextcloud "$JWT_SECRET" + _wire_filebrowser write_readme "$DIR" << MD # OnlyOffice Document Server -Self-hosted document editing server. Integrates with Nextcloud and FileBrowser -to provide collaborative editing of ODT, DOCX, XLSX, and PPTX files. +Self-hosted collaborative editing for DOCX, XLSX, PPTX, and ODT files. +Integrates with Nextcloud and FileBrowser Quantum. +Port: 8082 (internal 80) ## JWT Secret -The JWT secret is stored in \`.env\` (chmod 600). If you rotate it, update: -- Nextcloud: Settings > OnlyOffice > JWT Secret -- Any other integrations using this server +Stored in \`.env\` (chmod 600). If you rotate it: +1. Update \`JWT_SECRET\` in \`.env\` +2. Re-run the installer to re-wire all integrations: \`sudo bash services/onlyoffice.sh\` -JWT Secret (at install time): see \`JWT_SECRET\` in .env - -## Nextcloud Integration -If Nextcloud was running at install time, the OnlyOffice app was auto-configured. -To reconfigure or verify: +## Verify integrations \`\`\`bash +# Nextcloud docker exec --user www-data nextcloud php occ config:app:get onlyoffice DocumentServerUrl docker exec --user www-data nextcloud php occ config:app:get onlyoffice jwt_secret -\`\`\` -## FileBrowser Integration -Set \`officeServer: "http://onlyoffice:80/"\` in FileBrowser's config.yaml, then -restart FileBrowser. +# FileBrowser Quantum +grep officeServer ~/docker/filebrowser/data/config.yaml +\`\`\` ## Manage \`\`\`bash @@ -148,23 +346,13 @@ docker compose pull && docker compose up -d # update \`\`\` MD + log_success "OnlyOffice installed at $DIR" echo "" - echo " OnlyOffice Document Server" - echo " Directory: $DIR" - echo " Port: 8082 (internal: 80)" + echo " Port: http://localhost:8082" echo " JWT Secret: $JWT_SECRET" echo " (Secret also saved to $DIR/.env)" echo "" } -# ── Standalone bootstrap ─────────────────────────────────────────────────────── -# Run this file directly to install OnlyOffice without the full setup.sh wizard: -# sudo _RUN_STANDALONE=1 bash services/onlyoffice.sh -if [[ "${_RUN_STANDALONE:-0}" == 1 ]]; then - SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - # shellcheck source=../lib/common.sh - source "$SCRIPT_DIR/../lib/common.sh" - require_root - load_site_config 2>/dev/null || true - install_onlyoffice -fi +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_onlyoffice diff --git a/vendor/easy-asterisk/.env.example b/vendor/easy-asterisk/.env.example new file mode 100644 index 0000000..332af67 --- /dev/null +++ b/vendor/easy-asterisk/.env.example @@ -0,0 +1,99 @@ +# ================================================================ +# Easy Asterisk - Environment Configuration +# +# Setup: +# 1. cp .env.example .env +# 2. Set DOMAIN_NAME (the only required setting) +# 3. docker compose up -d +# 4. docker exec -it easy-asterisk easy-asterisk +# +# Port forwarding required on your router: +# 5061/tcp → SIP TLS signaling +# 3478/udp+tcp → STUN/TURN (NAT traversal + media relay) +# (change with TURN_PORT if 3478 is taken) +# 10000-20000/udp → RTP media (or your custom range below) +# +# How it works: +# - All SIP clients connect to DOMAIN_NAME:5061 (TLS) +# - coturn handles NAT traversal (STUN) and media relay (TURN) +# - Works from any network: LAN, cellular, Proton VPN, hotel WiFi +# - Set TURN_PASSWORD below (generate one: openssl rand -base64 18) +# ================================================================ + +# ── Domain Name (REQUIRED) ──────────────────────────────────── +# The FQDN that points to this server's public IP. +# This is what SIP clients use to connect. +# Example: asterisk.yourdomain.com +DOMAIN_NAME= + +# ── Public IP ───────────────────────────────────────────────── +# Your server's public IP address. +# Leave empty to auto-detect (uses ifconfig.me). +# Set manually if auto-detection fails (e.g., behind double NAT). +PUBLIC_IP= + +# ── TLS ─────────────────────────────────────────────────────── +# Always "y" for remote access. Self-signed certs are auto-generated. +# For trusted certs (no client warnings), mount your Let's Encrypt +# certs into /etc/asterisk/certs/ via docker compose volumes. +ENABLE_TLS=y + +# ── Local Network ───────────────────────────────────────────── +# Your LAN CIDR. Auto-detected if empty. +# Example: 192.168.1.0/24 +LOCAL_CIDR= + +# ── Additional Subnets (optional) ───────────────────────────── +# Only needed for site-to-site VPNs or VLANs where the server +# has a direct route to client IPs (e.g., WireGuard, Tailscale). +# +# NOT needed for client-side VPNs (Proton, NordVPN, etc.) +# - Those clients appear with random public IPs +# - TURN handles media relay for them automatically +# +# Examples: +# WireGuard: VLAN_SUBNETS=10.8.0.0/24 +# Tailscale: VLAN_SUBNETS=100.64.0.0/10 +# Multiple: VLAN_SUBNETS=10.8.0.0/24 10.10.0.0/24 +HAS_VLANS=n +VLAN_SUBNETS= + +# ── TURN/STUN Settings ────────────────────────────────────── +# Used by coturn for TURN relay authentication. +# If empty, defaults to "changeme" — set a real password for security. +# Generate one with: openssl rand -base64 18 +# +# These credentials are for coturn only. SIP clients that need TURN +# relay (behind strict NAT) must configure the same credentials in +# their SIP app settings. +TURN_USERNAME=easyasterisk +TURN_PASSWORD= + +# ── TURN/STUN Port ────────────────────────────────────────── +# Default: 3478 (standard STUN/TURN port) +# Change if 3478 is already in use (e.g., UniFi controller uses 3478/udp). +# Common alternative: 3479 +TURN_PORT=3478 + +# ── TURN Relay Port Range ───────────────────────────────────── +# Ports coturn uses for media relay. Forward this range on your router. +# Default is 100 ports (enough for ~50 simultaneous relayed calls). +# Most calls use direct paths; TURN relay is the fallback. +TURN_RELAY_MIN=49152 +TURN_RELAY_MAX=49252 + +# ── RTP Port Range ──────────────────────────────────────────── +# Asterisk's own RTP media ports. Forward this range on your router. +# Default: 10000-20000 (10,000 ports) +# For constrained environments: 10000-10200 +RTP_START=10000 +RTP_END=20000 + +# ── Web Admin ───────────────────────────────────────────────── +# HTTP management interface. Access via browser at: +# http://your-server:8080/clients +# +# For HTTPS: put this behind Caddy or nginx reverse proxy, +# then set WEB_ADMIN_AUTH_DISABLED=true (let the proxy handle auth). +WEB_ADMIN_PORT=8080 +WEB_ADMIN_AUTH_DISABLED=false diff --git a/vendor/easy-asterisk/Dockerfile b/vendor/easy-asterisk/Dockerfile new file mode 100644 index 0000000..96a54d5 --- /dev/null +++ b/vendor/easy-asterisk/Dockerfile @@ -0,0 +1,103 @@ +# ================================================================ +# Easy Asterisk - Docker Container +# Asterisk PBX with web admin and optional STUN support +# +# Usage: +# docker compose up -d # Asterisk only +# docker compose --profile stun up -d # Asterisk + self-hosted STUN +# docker exec -it easy-asterisk easy-asterisk # Interactive management +# docker exec -it easy-asterisk vpn-diagnostics # VPN diagnostics +# docker exec -it easy-asterisk dns-whitelist # DNS whitelist check +# ================================================================ + +FROM ubuntu:24.04 + +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 + +# Install Asterisk and all dependencies (matches install_asterisk_packages) +RUN echo "exit 101" > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && \ + apt-get update && \ + apt-get install -y --no-install-recommends \ + asterisk \ + asterisk-core-sounds-en-gsm \ + asterisk-modules \ + ca-certificates \ + openssl \ + curl \ + wget \ + tcpdump \ + sngrep \ + python3 \ + iproute2 \ + net-tools \ + dnsutils \ + iputils-ping \ + procps \ + lsof \ + && rm -rf /var/lib/apt/lists/* \ + && rm -f /usr/sbin/policy-rc.d \ + && ldconfig \ + && update-ca-certificates 2>/dev/null || true + +# NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04 due to +# a packaging bug (Launchpad #2044135). The Digium precompiled binary is ABI-incompatible. +# Opus pass-through (phone-to-phone) still works via res_format_attr_opus.so from +# asterisk-modules. Only Opus<->ulaw transcoding is missing, which is rarely needed +# since modern SIP phones all support the same codecs natively. + +# Create required directories +RUN mkdir -p \ + /etc/easy-asterisk \ + /etc/asterisk/certs \ + /var/lib/asterisk/static-http \ + /var/log/asterisk \ + /var/spool/asterisk \ + /var/run/asterisk \ + && chown -R asterisk:asterisk \ + /etc/asterisk \ + /var/lib/asterisk \ + /var/log/asterisk \ + /var/spool/asterisk \ + /var/run/asterisk + +# Docker detection marker (used by is_docker() in the script) +RUN touch /.dockerenv + +# Copy the main management script +COPY easy-asterisk-v0.10.0.sh /usr/local/bin/easy-asterisk +RUN chmod +x /usr/local/bin/easy-asterisk + +# Copy diagnostic and utility scripts +COPY scripts/vpn-diagnostics.sh /usr/local/bin/vpn-diagnostics +COPY scripts/dns-whitelist.sh /usr/local/bin/dns-whitelist +RUN chmod +x /usr/local/bin/vpn-diagnostics /usr/local/bin/dns-whitelist + +# Copy entrypoint +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# SIP signaling +EXPOSE 5060/udp +EXPOSE 5060/tcp +EXPOSE 5061/tcp + +# Web admin + provisioning +EXPOSE 8080/tcp +EXPOSE 8088/tcp +EXPOSE 8089/tcp + +# STUN (if running coturn in same container; default 3478, configurable via TURN_PORT) +EXPOSE 3478/udp + +# RTP media range (use --network host in production for full range) +# Docker port-mapping 10000 ports is impractical; host networking recommended +EXPOSE 10000-10100/udp + +# Persistent data +VOLUME ["/etc/asterisk", "/etc/easy-asterisk", "/var/log/asterisk"] + +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD asterisk -rx "core show version" >/dev/null 2>&1 || exit 1 + +ENTRYPOINT ["/entrypoint.sh"] diff --git a/vendor/easy-asterisk/docker/coturn-entrypoint.sh b/vendor/easy-asterisk/docker/coturn-entrypoint.sh new file mode 100644 index 0000000..e9aee8e --- /dev/null +++ b/vendor/easy-asterisk/docker/coturn-entrypoint.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# ================================================================ +# Robust coturn entrypoint +# +# The coturn/coturn Docker image's native entrypoint uses: +# exec $(eval "echo $@") +# which is fragile — if DETECT_EXTERNAL_IP's DNS lookup returns empty, +# the eval produces an empty token → "ERROR: CONFIG: Unknown argument:" +# +# This wrapper reuses the image's detect-external-ip script but avoids +# the eval word-splitting issue. If detection fails, we simply omit +# --external-ip rather than passing a blank argument. +# ================================================================ + +# Use explicit PUBLIC_IP if provided, otherwise auto-detect +if [ -z "$PUBLIC_IP" ]; then + PUBLIC_IP=$(detect-external-ip 2>/dev/null || true) +fi + +# Only add --external-ip if we actually have an IP +EXTERNAL_IP_ARG="" +if [ -n "$PUBLIC_IP" ]; then + EXTERNAL_IP_ARG="--external-ip=$PUBLIC_IP" +fi + +exec turnserver "$@" $EXTERNAL_IP_ARG diff --git a/vendor/easy-asterisk/docker/entrypoint.sh b/vendor/easy-asterisk/docker/entrypoint.sh new file mode 100644 index 0000000..da1cfa8 --- /dev/null +++ b/vendor/easy-asterisk/docker/entrypoint.sh @@ -0,0 +1,449 @@ +#!/bin/bash +# ================================================================ +# Easy Asterisk Docker Entrypoint +# +# Fully automated: +# - Detects public IP +# - Generates TURN credentials if not provided +# - Configures Asterisk with FQDN, TLS, ICE, STUN, TURN +# - Starts web admin + Asterisk +# ================================================================ + +set -e + +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +RED='\033[0;31m' +NC='\033[0m' + +log_info() { echo -e "${GREEN}[entrypoint]${NC} $1"; } +log_warn() { echo -e "${YELLOW}[entrypoint]${NC} $1"; } +log_error() { echo -e "${RED}[entrypoint]${NC} $1"; } + +CONFIG_DIR="/etc/easy-asterisk" +CONFIG_FILE="${CONFIG_DIR}/config" +WEB_ADMIN_SCRIPT="/usr/local/bin/easy-asterisk-webadmin" + +# ── Helper: generate random password ───────────────────────── +gen_password() { + openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24 +} + +# ── 1. Ensure asterisk user exists ─────────────────────────── +if ! id asterisk >/dev/null 2>&1; then + useradd -r -s /bin/false -d /var/lib/asterisk asterisk 2>/dev/null || true +fi + +# ── 2. Detect public IP ────────────────────────────────────── +PUBLIC_IP="${PUBLIC_IP:-}" +if [[ -z "$PUBLIC_IP" ]]; then + log_info "Auto-detecting public IP..." + PUBLIC_IP=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || true) + if [[ -z "$PUBLIC_IP" ]]; then + PUBLIC_IP=$(curl -s -4 --connect-timeout 5 icanhazip.com 2>/dev/null || true) + fi + if [[ -z "$PUBLIC_IP" ]]; then + PUBLIC_IP=$(curl -s -4 --connect-timeout 5 api.ipify.org 2>/dev/null || true) + fi +fi + +if [[ -n "$PUBLIC_IP" ]]; then + log_info "Public IP: ${PUBLIC_IP}" +else + log_warn "Could not detect public IP. Set PUBLIC_IP in .env" +fi + +# ── 3. TURN credentials ───────────────────────────────────────── +# The password MUST match what coturn was started with. In Docker, both +# read from the same env-var / .env file, so we use the value as-is. +# Auto-generating a different password here would create a mismatch +# (coturn is already running with ITS copy of the env-var). +TURN_USERNAME="${TURN_USERNAME:-easyasterisk}" +TURN_PASSWORD="${TURN_PASSWORD:-changeme}" +if [[ "${TURN_PASSWORD}" == "changeme" ]]; then + log_warn "TURN password is the default 'changeme' — set TURN_PASSWORD in .env for better security" +fi + +# ── 4. Detect local network ────────────────────────────────── +local_ip=$(hostname -I 2>/dev/null | awk '{print $1}') +raw_cidr=$(ip -o -f inet addr show 2>/dev/null | awk '/scope global/ {print $4}' | head -1) +default_cidr="$raw_cidr" +if [[ "$raw_cidr" =~ \.([0-9]+)/([0-9]+)$ ]]; then + default_cidr="${raw_cidr%.*}.0/${BASH_REMATCH[2]}" +fi + +# ── 5. Generate self-signed certs ────────────────────────────── +# Regenerate if missing OR if existing cert lacks SANs (modern TLS clients require them) +regen_cert=false +if [[ ! -f /etc/asterisk/certs/server.crt ]]; then + regen_cert=true +elif ! openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:"; then + log_info "Existing TLS cert lacks SANs — regenerating for mobile phone compatibility" + regen_cert=true +fi + +if $regen_cert; then + log_info "Generating self-signed TLS certificate..." + mkdir -p /etc/asterisk/certs + cn="${DOMAIN_NAME:-asterisk-local}" + # Include Subject Alternative Names — required by modern TLS clients (iOS/Android SIP apps) + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/asterisk/certs/server.key \ + -out /etc/asterisk/certs/server.crt \ + -subj "/CN=${cn}" \ + -addext "subjectAltName=DNS:${cn}${PUBLIC_IP:+,IP:${PUBLIC_IP}}" \ + 2>/dev/null + chown asterisk:asterisk /etc/asterisk/certs/server.* + chmod 644 /etc/asterisk/certs/server.crt + chmod 600 /etc/asterisk/certs/server.key +fi + +# ── 6. Write config file ───────────────────────────────────── +mkdir -p "$CONFIG_DIR" + +# Determine TURN/STUN server address +turn_server="${TURN_SERVER:-${DOMAIN_NAME:-$local_ip}:${TURN_PORT:-3478}}" + +cat > "$CONFIG_FILE" << EOF +# Easy Asterisk Configuration (Docker) - $(date) +KIOSK_USER="" +KIOSK_UID="" +KIOSK_EXTENSION="" +KIOSK_NAME="" +SIP_PASSWORD="" +ASTERISK_HOST="${DOMAIN_NAME:-$local_ip}" +DOMAIN_NAME="${DOMAIN_NAME:-}" +ENABLE_TLS="${ENABLE_TLS:-y}" +HAS_VLANS="${HAS_VLANS:-n}" +VLAN_SUBNETS="${VLAN_SUBNETS:-}" +CERT_PATH="" +KEY_PATH="" +INSTALLED_SERVER="y" +INSTALLED_CLIENT="n" +CURRENT_PUBLIC_IP="${PUBLIC_IP}" +PTT_DEVICE="" +PTT_KEYCODE="" +LOCAL_CIDR="${LOCAL_CIDR:-$default_cidr}" +WEB_ADMIN_PORT="${WEB_ADMIN_PORT:-8080}" +WEB_ADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" +VPN_ICE_ENABLED="y" +CUSTOM_STUN_SERVER="${turn_server}" +TURN_ENABLED="y" +TURN_SERVER="${turn_server}" +TURN_USERNAME="${TURN_USERNAME}" +TURN_PASSWORD="${TURN_PASSWORD}" +EOF +chmod 644 "$CONFIG_FILE" + +# ── 7. Initialize categories & rooms if missing ────────────── +CATEGORIES_FILE="${CONFIG_DIR}/categories.conf" +if [[ ! -f "$CATEGORIES_FILE" ]]; then + log_info "Creating default device categories..." + cat > "$CATEGORIES_FILE" << 'EOF' +kiosks|Kiosks|yes|Fixed wall-mount tablets & intercoms +mobile|Mobile|no|Phones & tablets (ring normally) +custom|Custom|no|Custom configuration +EOF +fi + +ROOMS_FILE="${CONFIG_DIR}/rooms.conf" +if [[ ! -f "$ROOMS_FILE" ]]; then + cat > "$ROOMS_FILE" << 'EOF' +# ext|name|members|timeout|type +EOF +fi + +# ── 8. Generate Asterisk configs ───────────────────────────── + +# Build local_net entries +all_local_nets="local_net=${LOCAL_CIDR:-$default_cidr}" +if [[ "${HAS_VLANS:-n}" == "y" && -n "${VLAN_SUBNETS:-}" ]]; then + for subnet in $VLAN_SUBNETS; do + all_local_nets="${all_local_nets} +local_net=${subnet}" + done +fi + +# NAT settings - always include external addresses for FQDN mode +nat_settings="" +if [[ -n "$PUBLIC_IP" ]]; then + nat_settings="external_media_address=${PUBLIC_IP} +external_signaling_address=${PUBLIC_IP} +${all_local_nets}" +else + nat_settings="${all_local_nets}" +fi + +# ── pjsip.conf (only if empty/missing - preserves existing devices) ── +if [[ ! -f /etc/asterisk/pjsip.conf ]] || [[ ! -s /etc/asterisk/pjsip.conf ]]; then + log_info "Generating PJSIP configuration..." + cat > /etc/asterisk/pjsip.conf << EOF +; Easy Asterisk (Docker) - FQDN: ${DOMAIN_NAME:-none} +[global] +type=global +user_agent=EasyAsterisk + +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:5060 +; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown} +${nat_settings} + +[transport-tcp] +type=transport +protocol=tcp +bind=0.0.0.0:5060 +; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown} +${nat_settings} + +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:5061 +; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown} +cert_file=/etc/asterisk/certs/server.crt +priv_key_file=/etc/asterisk/certs/server.key +; ca_list_file not set — only needed for verify_client=yes (client cert auth) +method=tlsv1_2 +${nat_settings} + +EOF + chown asterisk:asterisk /etc/asterisk/pjsip.conf +else + # Update NAT settings in existing pjsip.conf transports if public IP changed + if [[ -n "$PUBLIC_IP" ]]; then + current_ext=$(grep "^external_media_address=" /etc/asterisk/pjsip.conf 2>/dev/null | head -1 | cut -d= -f2) + if [[ "$current_ext" != "$PUBLIC_IP" && -n "$current_ext" ]]; then + log_info "Updating public IP in pjsip.conf: ${current_ext} -> ${PUBLIC_IP}" + sed -i "s|external_media_address=.*|external_media_address=${PUBLIC_IP}|g" /etc/asterisk/pjsip.conf + sed -i "s|external_signaling_address=.*|external_signaling_address=${PUBLIC_IP}|g" /etc/asterisk/pjsip.conf + fi + fi +fi + +# ── Sanitize pjsip.conf: remove endpoint-only options from aor sections ── +if [[ -f /etc/asterisk/pjsip.conf ]]; then + # Options that are only valid in [endpoint] sections, not in [aor] sections + endpoint_only_opts="direct_media|rtp_symmetric|force_rport|rewrite_contact|rtp_keepalive|rtp_timeout|rtp_timeout_hold|ice_support|context|disallow|allow|auth|aors|callerid|media_encryption|transport" + current_type="" + needs_fix=false + while IFS= read -r line; do + if [[ "$line" =~ ^type=(.*) ]]; then + current_type="${BASH_REMATCH[1]}" + fi + if [[ "$current_type" == "aor" ]] && echo "$line" | grep -qE "^(${endpoint_only_opts})="; then + needs_fix=true + break + fi + done < /etc/asterisk/pjsip.conf + + if $needs_fix; then + log_info "Sanitizing pjsip.conf (removing misplaced options from aor sections)..." + awk -v opts="$endpoint_only_opts" ' + BEGIN { split(opts, arr, "|"); for (i in arr) bad[arr[i]]=1 } + /^type=/ { current_type = substr($0, 6) } + { + if (current_type == "aor") { + split($0, kv, "=") + if (kv[1] in bad) next + } + print + } + ' /etc/asterisk/pjsip.conf > /tmp/pjsip_sanitized.conf + mv /tmp/pjsip_sanitized.conf /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + fi +fi + +# ── Ensure transport-tls exists in pjsip.conf (upgrade / migration path) ── +# If pjsip.conf was preserved from a pre-TLS config or a non-Docker install it +# will have no [transport-tls] section. Asterisk starts without TLS silently, +# and mobile devices cannot register. Inject the section when it is absent. +if [[ -f /etc/asterisk/pjsip.conf ]] && ! grep -q "^\[transport-tls\]" /etc/asterisk/pjsip.conf; then + log_info "transport-tls missing from pjsip.conf — adding TLS transport (required for mobile registration)..." + cat >> /etc/asterisk/pjsip.conf << EOF + +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:5061 +cert_file=/etc/asterisk/certs/server.crt +priv_key_file=/etc/asterisk/certs/server.key +; ca_list_file not set — only needed for verify_client=yes (client cert auth) +method=tlsv1_2 +${nat_settings} + +EOF + chown asterisk:asterisk /etc/asterisk/pjsip.conf +fi + +# ── rtp.conf (always regenerated) ── +# ICE is enabled so Asterisk participates in ICE negotiation with clients. +# stunaddr/turnaddr are NOT set here because: +# - Asterisk already knows its public IP via external_media_address in pjsip.conf +# - Its RTP ports are port-forwarded, so host candidates are sufficient +# - Setting stunaddr/turnaddr causes STUN/TURN gather timeouts (~27s per call) +# when the STUN/TURN server is unreachable or misconfigured +# coturn is for SIP CLIENTS behind strict NAT — they configure TURN in their +# own app settings, independently of Asterisk's rtp.conf. +log_info "Configuring RTP with ICE support..." +cat > /etc/asterisk/rtp.conf << EOF +[general] +rtpstart=${RTP_START:-10000} +rtpend=${RTP_END:-20000} +strictrtp=yes +icesupport=yes +EOF +chown asterisk:asterisk /etc/asterisk/rtp.conf + +# ── extensions.conf (only if missing) ── +if [[ ! -f /etc/asterisk/extensions.conf ]] || [[ ! -s /etc/asterisk/extensions.conf ]]; then + log_info "Generating dialplan..." + cat > /etc/asterisk/extensions.conf << 'EOF' +[general] +static=yes +writeprotect=no +[default] +exten => _X.,1,Hangup() +[intercom] +EOF + chown asterisk:asterisk /etc/asterisk/extensions.conf +fi + +# ── Other core configs (only if missing) ── +if [[ ! -f /etc/asterisk/asterisk.conf ]]; then + cat > /etc/asterisk/asterisk.conf << 'EOF' +[directories] +[options] +runuser = asterisk +rungroup = asterisk +EOF +fi + +# ── logger.conf (always regenerated - ensures security logging is on) ── +cat > /etc/asterisk/logger.conf << 'EOF' +[general] +[logfiles] +; security level captures TLS handshake failures and auth issues +console => notice,warning,error,security +EOF + +# ── modules.conf (always regenerated - ensures chan_sip stays disabled) ── +cat > /etc/asterisk/modules.conf << 'EOF' +[modules] +autoload=yes +noload => chan_sip.so +noload => chan_iax2.so +; Opus transcoding unavailable on Ubuntu 24.04 (bug #2044135) +; Opus pass-through still works via res_format_attr_opus.so +noload => codec_opus.so +noload => format_ogg_opus.so +load => res_pjsip.so +load => res_pjsip_session.so +load => res_pjsip_logger.so +load => chan_pjsip.so +load => codec_ulaw.so +load => codec_alaw.so +load => codec_g722.so +load => res_rtp_asterisk.so +load => app_dial.so +load => app_page.so +load => pbx_config.so +EOF + +# ── Remove incompatible Digium codec_opus if present on volume ── +# The Digium binary is ABI-incompatible with Ubuntu 24.04's Asterisk and crashes it +MODULES_DIR=$(find /usr/lib -type d -name modules -path "*/asterisk/*" 2>/dev/null | head -1) +if [[ -n "$MODULES_DIR" ]]; then + for bad_module in codec_opus.so format_ogg_opus.so; do + if [[ -f "$MODULES_DIR/$bad_module" ]] && ! dpkg -S "$MODULES_DIR/$bad_module" >/dev/null 2>&1; then + log_warn "Removing incompatible $bad_module (not from Ubuntu package)" + rm -f "$MODULES_DIR/$bad_module" + fi + done +fi + +# ── 9. Fix permissions ─────────────────────────────────────── +chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk 2>/dev/null || true + +# ── 10. Start Web Admin in background ───────────────────────── +# The web admin script is generated by the 'easy-asterisk' management tool. +# On first run: docker exec -it easy-asterisk easy-asterisk → Web Admin menu → Start +if [[ -f "$WEB_ADMIN_SCRIPT" ]]; then + log_info "Starting Web Admin on port ${WEB_ADMIN_PORT:-8080}..." + WEBADMIN_PORT="${WEB_ADMIN_PORT:-8080}" \ + WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \ + python3 "$WEB_ADMIN_SCRIPT" & +fi + +# ── 11. Signal handling for clean shutdown ──────────────────── +cleanup() { + log_info "Shutting down..." + pkill -f "easy-asterisk-webadmin" 2>/dev/null || true + asterisk -rx "core stop now" 2>/dev/null || true + exit 0 +} +trap cleanup SIGTERM SIGINT + +# ── 12. Start Asterisk ─────────────────────────────────────── +log_info "Starting Asterisk PBX..." +echo "" + +# Start Asterisk in the background, then print management info once ready +asterisk -f -U asterisk -G asterisk & +ASTERISK_PID=$! + +# Wait for Asterisk to be ready (up to 60 seconds) +for i in $(seq 1 60); do + if asterisk -rx "core show version" >/dev/null 2>&1; then + break + fi + sleep 1 +done + +# Verify PJSIP transports are listening +tls_ok=false +udp_ok=false +if asterisk -rx "pjsip show transports" 2>/dev/null | grep -q "transport-tls"; then + tls_ok=true +fi +if asterisk -rx "pjsip show transports" 2>/dev/null | grep -q "transport-udp"; then + udp_ok=true +fi + +# Check if port 5061 is actually bound +tls_listen="" +if command -v ss &>/dev/null; then + tls_listen=$(ss -tlnp 2>/dev/null | grep ":5061 " || true) +elif command -v netstat &>/dev/null; then + tls_listen=$(netstat -tlnp 2>/dev/null | grep ":5061 " || true) +fi + +echo "" +echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}" +echo -e "${CYAN} Easy Asterisk (Docker)${NC}" +echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}" +echo -e " FQDN: ${GREEN}${DOMAIN_NAME:-not set}${NC}" +echo -e " Public IP: ${GREEN}${PUBLIC_IP:-unknown}${NC}" +echo -e " TURN/STUN: ${GREEN}${turn_server}${NC}" +if $tls_ok && [[ -n "$tls_listen" ]]; then + echo -e " TLS: ${GREEN}Enabled (port 5061)${NC}" +elif $tls_ok; then + echo -e " TLS: ${YELLOW}Transport loaded but port 5061 not bound — check certs${NC}" +else + echo -e " TLS: ${RED}NOT LOADED — check Asterisk logs${NC}" +fi +echo -e " ICE: ${GREEN}Enabled${NC}" +echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}" +echo -e " SIP clients connect to: ${GREEN}${DOMAIN_NAME:-$local_ip}:5061${NC} (TLS)" +echo -e " Web Admin: ${GREEN}http://${local_ip}:${WEB_ADMIN_PORT:-8080}/clients${NC}" +echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}" +echo -e " Management: ${YELLOW}docker exec -it easy-asterisk easy-asterisk${NC}" +echo -e " Diagnostics: docker exec -it easy-asterisk vpn-diagnostics" +echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}" +echo "" + +# Wait for Asterisk process (keeps container running) +wait $ASTERISK_PID diff --git a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh new file mode 100644 index 0000000..bc63904 --- /dev/null +++ b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh @@ -0,0 +1,6929 @@ +#!/bin/bash +# ================================================================ +# Easy Asterisk - Interactive Installer v0.10.0 +# +# Copyright (C) 2025 Easy Asterisk Contributors +# Licensed under GNU General Public License v3.0 +# See LICENSE file or https://www.gnu.org/licenses/gpl-3.0.html +# +# UPDATES in v0.10.0: +# - FIXED: Extension deletion now properly removes all sections (endpoint, auth, aor) +# - FIXED: Extension renaming now preserves AA tags correctly +# - FIXED: LAN/VPN devices now explicitly use UDP transport (prevents TLS fallback) +# - FIXED: LAN devices now have media_encryption=no to prevent SRTP issues +# - FIXED: VPN subnets now included as local_net in LAN mode (fixes VPN mobile offline) +# - FIXED: One-way audio on WiFi-to-mobile-data handoff (rtp_keepalive + timers) +# - ADDED: Web Admin interface for browser-based client management +# - View device status (online/offline) in real-time +# - Add/delete devices via web interface +# - View rooms and categories +# - HTTP Basic authentication with SHA256 password hashing +# - Access at http://server:8080/clients +# - ADDED: VPN subnet auto-detection (Tailscale, WireGuard, OpenVPN) +# - ADDED: VPN STUN/ICE configuration for third-party VPNs +# - Self-hosted coturn STUN (no external DNS dependencies) +# - Custom STUN server support +# - Per-device ICE for LAN/VPN mode endpoints +# - ADDED: Docker container support (Dockerfile + docker-compose) +# - ADDED: VPN diagnostics tool (vpn-diagnostics) +# - ADDED: DNS whitelist checker for filtered networks (dns-whitelist) +# - IMPROVED: Device deletion uses awk for reliable multi-section removal +# - IMPROVED: Device renaming uses awk to handle all edge cases +# +# PREVIOUS UPDATES (v0.9.9): +# - REMOVED: All COTURN/TURN relay server code (focus on direct connections) +# - ADDED: VLAN subnet configuration to prevent 30-second call drops +# - ADDED: Provisioning Manager (http.conf setup, symlinks, linphone.xml editor) +# - ADDED: Manual Update System for Asterisk with backup/rollback +# - ADDED: Room Directory (visual display of Ring Groups vs Page Groups) +# - ADDED: Split-horizon DNS documentation for VLAN environments +# - IMPROVED: Server IP address documented in transport configurations +# - IMPROVED: Multiple local_net entries for proper VLAN support +# ================================================================ + +set +e + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# Defaults +DEFAULT_SIP_PORT="5060" +DEFAULT_SIPS_PORT="5061" +CONFIG_DIR="/etc/easy-asterisk" +CONFIG_FILE="${CONFIG_DIR}/config" +PTT_CONFIG_FILE="${CONFIG_DIR}/ptt-device" +CATEGORIES_FILE="${CONFIG_DIR}/categories.conf" +ROOMS_FILE="${CONFIG_DIR}/rooms.conf" +PROVISIONING_DIR="/var/lib/asterisk/static-http" +SCRIPT_VERSION="0.10.0" + +# ================================================================ +# 1. CORE HELPER FUNCTIONS +# ================================================================ + +print_header() { + echo -e "\n${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}\n" +} + +print_info() { echo -e "${GREEN}[INFO]${NC} $1"; } +print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; } +print_error() { echo -e "${RED}[ERROR]${NC} $1"; } +print_success() { echo -e "${GREEN}[OK]${NC} $1"; } + +check_root() { + if [[ $EUID -ne 0 ]]; then + print_error "This script must be run as root (use sudo)" + exit 1 + fi +} + +# ── Docker / Container Detection ───────────────────────────── +# Returns 0 (true) if running inside a Docker/container environment + +is_docker() { + [[ -f /.dockerenv ]] || grep -qsE "docker|containerd|lxc" /proc/1/cgroup 2>/dev/null +} + +# Check if Asterisk process is running (works in both Docker and bare metal) +asterisk_running() { + if is_docker; then + pgrep -x asterisk >/dev/null 2>&1 + else + systemctl is-active asterisk >/dev/null 2>&1 + fi +} + +# Start/restart Asterisk (Docker-aware) +restart_asterisk_safe() { + print_info "Restarting Asterisk..." + if is_docker; then + # In Docker: use Asterisk CLI to restart, or restart the process + if pgrep -x asterisk >/dev/null 2>&1; then + asterisk -rx "core restart now" 2>/dev/null || true + sleep 3 + fi + # If not running, start it in the background + if ! pgrep -x asterisk >/dev/null 2>&1; then + rm -f /var/run/asterisk/asterisk.pid 2>/dev/null || true + asterisk -U asterisk -G asterisk & + sleep 3 + fi + if pgrep -x asterisk >/dev/null 2>&1; then + print_success "Asterisk running" + else + print_error "Asterisk failed to start" + fi + else + systemctl stop asterisk 2>/dev/null || true + sleep 2 + pkill -9 -x asterisk 2>/dev/null || true + rm -f /var/run/asterisk/asterisk.pid 2>/dev/null || true + rm -f /var/lib/asterisk/.asterisk_history 2>/dev/null || true + systemctl start asterisk + sleep 3 + if systemctl is-active asterisk >/dev/null; then + print_success "Asterisk running" + else + print_error "Asterisk failed to start" + journalctl -u asterisk -n 15 --no-pager + fi + fi +} + +# Web admin process management (Docker-aware) +webadmin_running() { + pgrep -f "easy-asterisk-webadmin" >/dev/null 2>&1 +} + +start_webadmin() { + load_config + if webadmin_running; then + print_warn "Web admin already running" + return + fi + create_web_admin_script + if [[ ! -f "$WEB_ADMIN_HTPASSWD" ]] && [[ "${WEB_ADMIN_AUTH_DISABLED:-}" != "true" ]]; then + setup_web_admin_auth + fi + WEBADMIN_PORT="${WEB_ADMIN_PORT:-8080}" \ + WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \ + nohup python3 "$WEB_ADMIN_SCRIPT" >/dev/null 2>&1 & + sleep 2 + if webadmin_running; then + print_success "Web Admin started on port ${WEB_ADMIN_PORT}" + else + print_error "Web Admin failed to start" + fi +} + +stop_webadmin() { + if webadmin_running; then + pkill -f "easy-asterisk-webadmin" 2>/dev/null || true + sleep 1 + # Force kill if still running + if webadmin_running; then + pkill -9 -f "easy-asterisk-webadmin" 2>/dev/null || true + sleep 1 + fi + fi + # Also kill anything on the port + local port_pids=$(lsof -ti ":${WEB_ADMIN_PORT}" 2>/dev/null) + if [[ -n "$port_pids" ]]; then + echo "$port_pids" | xargs kill -9 2>/dev/null || true + sleep 1 + fi + if ! webadmin_running; then + print_success "Web Admin stopped" + else + print_error "Web Admin could not be stopped" + fi +} + +restart_webadmin() { + stop_webadmin 2>/dev/null + start_webadmin +} + +generate_password() { + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c 16 +} + +select_user() { + # Scan /home for real users (exclude system accounts) + local -a users=() + local -a user_ids=() + local count=0 + + echo "Scanning for users..." + echo "" + + # Get users from /home with valid shells + while IFS=: read -r username _ uid _ _ homedir shell; do + # Only include users with UID >= 1000 and valid shell + if [[ $uid -ge 1000 && -d "$homedir" && "$shell" != "/usr/sbin/nologin" && "$shell" != "/bin/false" ]]; then + ((count++)) + users+=("$username") + user_ids+=("$uid") + echo " ${count}) ${username} (UID: ${uid}, Home: ${homedir})" + fi + done < /etc/passwd + + # Add option to manually enter username + ((count++)) + echo " ${count}) Enter username manually" + echo "" + + # Suggest default based on SUDO_USER or first user found + local default_choice="" + local default_user="${SUDO_USER:-}" + if [[ -z "$default_user" ]]; then + default_user="${users[0]:-}" + default_choice="1" + else + # Find index of SUDO_USER + for i in "${!users[@]}"; do + if [[ "${users[$i]}" == "$default_user" ]]; then + default_choice=$((i + 1)) + break + fi + done + fi + + if [[ -n "$default_choice" ]]; then + read -p "Select user [${default_choice}]: " choice + choice="${choice:-$default_choice}" + else + read -p "Select user: " choice + fi + + # Validate choice + if [[ "$choice" =~ ^[0-9]+$ && "$choice" -le "${#users[@]}" && "$choice" -gt 0 ]]; then + local idx=$((choice - 1)) + KIOSK_USER="${users[$idx]}" + KIOSK_UID="${user_ids[$idx]}" + echo "" + print_success "Selected user: $KIOSK_USER (UID: $KIOSK_UID)" + return 0 + elif [[ "$choice" == "$count" ]]; then + # Manual entry + echo "" + read -p "Enter username: " KIOSK_USER + if id "$KIOSK_USER" >/dev/null 2>&1; then + KIOSK_UID=$(id -u "$KIOSK_USER") + print_success "Selected user: $KIOSK_USER (UID: $KIOSK_UID)" + return 0 + else + print_error "User '$KIOSK_USER' not found" + return 1 + fi + else + print_error "Invalid selection" + return 1 + fi +} + +load_config() { + if [[ -f "$CONFIG_FILE" ]]; then + source "$CONFIG_FILE" 2>/dev/null || true + fi + INSTALLED_SERVER="${INSTALLED_SERVER:-n}" + INSTALLED_CLIENT="${INSTALLED_CLIENT:-n}" + KIOSK_USER="${KIOSK_USER:-}" + KIOSK_UID="${KIOSK_UID:-}" + HAS_VLANS="${HAS_VLANS:-n}" + VLAN_SUBNETS="${VLAN_SUBNETS:-}" + WEB_ADMIN_PORT="${WEB_ADMIN_PORT:-8080}" + WEB_ADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" + VPN_ICE_ENABLED="${VPN_ICE_ENABLED:-n}" + CUSTOM_STUN_SERVER="${CUSTOM_STUN_SERVER:-}" + TURN_ENABLED="${TURN_ENABLED:-n}" + TURN_SERVER="${TURN_SERVER:-}" + TURN_USERNAME="${TURN_USERNAME:-}" + TURN_PASSWORD="${TURN_PASSWORD:-}" + return 0 +} + +backup_config() { + local file=$1 + if [[ -f "$file" ]]; then + cp "$file" "${file}.backup-$(date +%s)" + ls -tp "${file}.backup-"* 2>/dev/null | tail -n +6 | xargs -I {} rm -- {} 2>/dev/null + fi +} + +save_config() { + mkdir -p "$CONFIG_DIR" + chmod 755 "$CONFIG_DIR" + + cat > "$CONFIG_FILE" << EOF +# Easy Asterisk Configuration - $(date) +KIOSK_USER="$KIOSK_USER" +KIOSK_UID="$KIOSK_UID" +KIOSK_EXTENSION="$KIOSK_EXTENSION" +KIOSK_NAME="$KIOSK_NAME" +SIP_PASSWORD="$SIP_PASSWORD" +ASTERISK_HOST="$ASTERISK_HOST" +DOMAIN_NAME="$DOMAIN_NAME" +ENABLE_TLS="$ENABLE_TLS" +HAS_VLANS="$HAS_VLANS" +VLAN_SUBNETS="$VLAN_SUBNETS" +CERT_PATH="$CERT_PATH" +KEY_PATH="$KEY_PATH" +INSTALLED_SERVER="$INSTALLED_SERVER" +INSTALLED_CLIENT="$INSTALLED_CLIENT" +CURRENT_PUBLIC_IP="$CURRENT_PUBLIC_IP" +PTT_DEVICE="$PTT_DEVICE" +PTT_KEYCODE="$PTT_KEYCODE" +LOCAL_CIDR="$LOCAL_CIDR" +WEB_ADMIN_PORT="$WEB_ADMIN_PORT" +WEB_ADMIN_AUTH_DISABLED="$WEB_ADMIN_AUTH_DISABLED" +VPN_ICE_ENABLED="$VPN_ICE_ENABLED" +CUSTOM_STUN_SERVER="$CUSTOM_STUN_SERVER" +TURN_ENABLED="$TURN_ENABLED" +TURN_SERVER="$TURN_SERVER" +TURN_USERNAME="$TURN_USERNAME" +TURN_PASSWORD="$TURN_PASSWORD" +EOF + chmod 644 "$CONFIG_FILE" + + # Save PTT config separately + if [[ -n "$PTT_DEVICE" ]]; then + cat > "$PTT_CONFIG_FILE" << EOF +PTT_DEVICE="$PTT_DEVICE" +PTT_KEYCODE="$PTT_KEYCODE" +EOF + chmod 644 "$PTT_CONFIG_FILE" + fi +} + +open_firewall_ports() { + if is_docker; then + # In Docker, firewall is managed on the host, not inside the container + # With network_mode: host, all ports are directly accessible + print_info "Docker mode: firewall is managed on the host" + return + fi + print_info "Configuring firewall ports..." + if command -v ufw &>/dev/null; then + if ufw status 2>/dev/null | grep -q "Status: active"; then + ufw allow 5060/udp comment "SIP UDP" 2>/dev/null || true + ufw allow 5061/tcp comment "SIP TLS" 2>/dev/null || true + ufw allow 10000:20000/udp comment "RTP Media" 2>/dev/null || true + ufw allow 8088/tcp comment "HTTP Provisioning" 2>/dev/null || true + ufw allow 8089/tcp comment "HTTPS Provisioning" 2>/dev/null || true + ufw reload 2>/dev/null || true + print_success "UFW firewall ports opened" + fi + fi +} + +# ================================================================ +# 2. UTILITY FUNCTIONS +# ================================================================ + +get_public_ip() { + local ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || curl -s -4 --connect-timeout 5 icanhazip.com 2>/dev/null || echo "") + echo "$ip" +} + +# ================================================================ +# 3. DEVICE MANAGEMENT +# ================================================================ + +initialize_default_categories() { + mkdir -p "$CONFIG_DIR" + if [[ ! -f "$CATEGORIES_FILE" ]]; then + cat > "$CATEGORIES_FILE" << 'EOF' +# Format: id|name|auto_answer(yes/no)|description +kiosk|Kiosks|yes|Fixed auto-answer intercoms +mobile|Mobile Devices|no|Phones and mobile devices +EOF + chmod 600 "$CATEGORIES_FILE" + fi + if [[ ! -f "$ROOMS_FILE" ]]; then + cat > "$ROOMS_FILE" << 'EOF' +# Format: ext|name|members|timeout|type(ring/page) +199|All Kiosks|101,102,103,104,105|60|page +299|All Mobile|201,202,203,204,205|60|ring +EOF + chmod 600 "$ROOMS_FILE" + fi +} + +list_categories() { + initialize_default_categories + local index=1 + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + local auto_text="${RED}Ring${NC}" + [[ "$auto_answer" == "yes" ]] && auto_text="${GREEN}Auto-answer${NC}" + echo -e " ${CYAN}$index)${NC} ${BOLD}$cat_name${NC} ($cat_id) - $auto_text" + ((index++)) + done < "$CATEGORIES_FILE" +} + +get_category_by_index() { + local target_index=$1 + local index=1 + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + if [[ $index -eq $target_index ]]; then + echo "$cat_id|$cat_name|$auto_answer" + return 0 + fi + ((index++)) + done < "$CATEGORIES_FILE" +} + +manage_categories() { + print_header "Manage Categories" + list_categories + echo "" + echo " 1) Add Category" + echo " 2) Rename Category" + echo " 3) Delete Category" + echo " 0) Back" + read -p "Select: " choice + case $choice in + 1) + read -p "ID (lowercase): " cid + read -p "Display Name: " cname + read -p "Auto Answer? [y/N]: " ca + local ans="no" + [[ "$ca" =~ ^[Yy]$ ]] && ans="yes" + echo "${cid}|${cname}|${ans}|Custom category" >> "$CATEGORIES_FILE" + print_success "Category added" + rebuild_dialplan + ;; + 2) + read -p "Number to rename: " num + local data=$(get_category_by_index "$num") + if [[ -z "$data" ]]; then + print_error "Invalid selection" + return + fi + local old_id=$(echo "$data" | cut -d'|' -f1) + local old_name=$(echo "$data" | cut -d'|' -f2) + local auto_answer=$(echo "$data" | cut -d'|' -f3) + + echo "Current: $old_name (ID: $old_id)" + read -p "New display name: " new_name + + if [[ -z "$new_name" ]]; then + print_error "Name cannot be empty" + return + fi + + # Backup + backup_config "$CATEGORIES_FILE" + + # Update category file + sed -i "s/^${old_id}|${old_name}|/${old_id}|${new_name}|/" "$CATEGORIES_FILE" + + print_success "Category renamed: ${old_name} → ${new_name}" + rebuild_dialplan + ;; + 3) + read -p "Number to delete: " num + local data=$(get_category_by_index "$num") + if [[ -z "$data" ]]; then + print_error "Invalid selection" + return + fi + local cid=$(echo "$data" | cut -d'|' -f1) + local cname=$(echo "$data" | cut -d'|' -f2) + + # Count devices in this category + local device_count=$(grep -c "; === Device:.* (${cid})" /etc/asterisk/pjsip.conf 2>/dev/null || echo "0") + + if [[ $device_count -gt 0 ]]; then + echo "" + echo -e "${YELLOW}Warning: This category has ${device_count} device(s)${NC}" + echo "" + echo " 1) Delete category only (reassign devices to 'uncategorized')" + echo " 2) Delete category AND all devices in it" + echo " 0) Cancel" + read -p "Select: " del_choice + + case $del_choice in + 1) + # Ensure uncategorized category exists + if ! grep -q "^uncategorized|" "$CATEGORIES_FILE" 2>/dev/null; then + echo "uncategorized|Uncategorized|no|Default category for orphaned devices" >> "$CATEGORIES_FILE" + fi + + # Reassign all devices to uncategorized + backup_config "/etc/asterisk/pjsip.conf" + sed -i "s/; === Device: \(.*\) (${cid})/; === Device: \1 (uncategorized)/" /etc/asterisk/pjsip.conf + + # Delete the category + sed -i "/^${cid}|/d" "$CATEGORIES_FILE" + + print_success "Category deleted, ${device_count} device(s) moved to 'uncategorized'" + rebuild_dialplan + ;; + 2) + echo "" + echo -e "${RED}WARNING: This will DELETE ${device_count} device(s)!${NC}" + read -p "Type 'DELETE ALL' to confirm: " confirm + + if [[ "$confirm" == "DELETE ALL" ]]; then + backup_config "/etc/asterisk/pjsip.conf" + + # Get all extensions in this category + local exts_to_delete="" + local in_device=0 + local current_ext="" + local current_cat="" + + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + local temp="${line#*; === Device: }" + temp="${temp% ===}" + [[ "$temp" == *"[AA:"* ]] && temp="${temp% \[AA:*\]}" + current_cat="${temp##* (}"; current_cat="${current_cat%)}" + fi + if [[ "$line" =~ ^\[([0-9]+)\] ]]; then + current_ext="${BASH_REMATCH[1]}" + if [[ "$current_cat" == "$cid" ]]; then + exts_to_delete="${exts_to_delete} ${current_ext}" + fi + fi + done < /etc/asterisk/pjsip.conf + + # Delete all device sections for this category + for ext in $exts_to_delete; do + sed -i "/^; === Device:.*${ext}.* (${cid})/,/^$/d" /etc/asterisk/pjsip.conf + sed -i "/^\[${ext}\]/,/^$/d" /etc/asterisk/pjsip.conf + done + + # Delete the category + sed -i "/^${cid}|/d" "$CATEGORIES_FILE" + + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan + print_success "Category and ${device_count} device(s) deleted" + else + print_error "Cancelled" + fi + ;; + 0) + print_error "Cancelled" + return + ;; + esac + else + # No devices, just delete the category + sed -i "/^${cid}|/d" "$CATEGORIES_FILE" + print_success "Category deleted (no devices affected)" + rebuild_dialplan + fi + ;; + esac +} + + +manage_rooms() { + print_header "Manage Rooms" + initialize_default_categories + echo "Current Rooms:" + local index=1 + while IFS='|' read -r rext rname rmem rtime rtype; do + [[ "$rext" =~ ^# ]] && continue + [[ -z "$rext" ]] && continue + local type_text="Ring Group" + [[ "$rtype" == "page" ]] && type_text="${GREEN}PAGE/INTERCOM${NC}" + echo -e " ${CYAN}$index)${NC} ${BOLD}$rname${NC} ($rext) - $type_text" + echo -e " Members: $rmem" + ((index++)) + done < "$ROOMS_FILE" + echo "" + echo " 1) Add Room" + echo " 2) Rename Room" + echo " 3) Edit Room Members" + echo " 4) Delete Room" + echo " 0) Back" + read -p "Select: " choice + case $choice in + 1) + read -p "Room Extension: " new_ext + read -p "Room Name: " new_name + echo " 1) Ring Group (Phones ring)" + echo " 2) Page/Intercom (Auto-answer)" + read -p "Select [1]: " type_sel + local rtype="ring" + [[ "$type_sel" == "2" ]] && rtype="page" + read -p "Members (e.g. 101,102): " members + echo "${new_ext}|${new_name}|${members}|60|${rtype}" >> "$ROOMS_FILE" + rebuild_dialplan + print_success "Room Created" + ;; + 2) + read -p "Select Room #: " rnum + local target_line="" + local count=0 + while IFS= read -r line; do + if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then + ((count++)) + if [[ $count -eq $rnum ]]; then target_line="$line"; break; fi + fi + done < "$ROOMS_FILE" + if [[ -n "$target_line" ]]; then + IFS='|' read -r rext old_name rmem rtime rtype <<< "$target_line" + echo "Current name: $old_name" + read -p "New name: " new_name + + if [[ -z "$new_name" ]]; then + print_error "Name cannot be empty" + return + fi + + backup_config "$ROOMS_FILE" + sed -i "/^${rext}|/d" "$ROOMS_FILE" + echo "${rext}|${new_name}|${rmem}|${rtime}|${rtype}" >> "$ROOMS_FILE" + rebuild_dialplan + print_success "Room renamed: ${old_name} → ${new_name}" + else + print_error "Invalid selection" + fi + ;; + 3) + read -p "Select Room #: " rnum + local target_line="" + local count=0 + while IFS= read -r line; do + if [[ ! "$line" =~ ^# ]] && [[ -n "$line" ]]; then + ((count++)) + if [[ $count -eq $rnum ]]; then target_line="$line"; break; fi + fi + done < "$ROOMS_FILE" + if [[ -n "$target_line" ]]; then + IFS='|' read -r rext rname rmem rtime rtype <<< "$target_line" + echo "Current members: $rmem" + read -p "New members: " new_mem + sed -i "/^${rext}|/d" "$ROOMS_FILE" + echo "${rext}|${rname}|${new_mem}|${rtime}|${rtype}" >> "$ROOMS_FILE" + rebuild_dialplan + print_success "Room Updated" + fi + ;; + 4) + read -p "Select Room #: " rnum + local count=0 + local target_ext="" + local target_name="" + while IFS='|' read -r rext rname rrest; do + if [[ ! "$rext" =~ ^# ]] && [[ -n "$rext" ]]; then + ((count++)) + if [[ $count -eq $rnum ]]; then + target_ext="$rext" + target_name="$rname" + break + fi + fi + done < "$ROOMS_FILE" + if [[ -n "$target_ext" ]]; then + echo "" + echo -e "${YELLOW}Note: Deleting a room only removes the group.${NC}" + echo -e "${YELLOW}Individual devices in this room are NOT deleted.${NC}" + echo "" + read -p "Delete room '${target_name}' (${target_ext})? [y/N]: " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + sed -i "/^${target_ext}|/d" "$ROOMS_FILE" + rebuild_dialplan + print_success "Room deleted (devices unaffected)" + else + print_error "Cancelled" + fi + fi + ;; + esac +} + +add_device_menu() { + print_header "Add Device" + load_config # Load saved configuration to check ENABLE_TLS, DOMAIN_NAME, etc. + list_categories + read -p "Category number: " cat_num + local cat_data=$(get_category_by_index "$cat_num") + if [[ -z "$cat_data" ]]; then print_error "Invalid"; return; fi + local cat_id=$(echo "$cat_data" | cut -d'|' -f1) + local cat_name=$(echo "$cat_data" | cut -d'|' -f2) + local auto_answer=$(echo "$cat_data" | cut -d'|' -f3) + + local start_range=101 end_range=199 + case "$cat_id" in + kiosk) start_range=101; end_range=199 ;; + mobile) start_range=201; end_range=299 ;; + *) start_range=301; end_range=399 ;; + esac + + local suggested_ext="" + for ext in $(seq $start_range $end_range); do + if ! grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + suggested_ext=$ext; break + fi + done + + read -p "Extension [$suggested_ext]: " ext + ext="${ext:-$suggested_ext}" + + if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + print_error "Extension exists!"; return + fi + + read -p "Name: " name + name="${name:-Device $ext}" + local pass=$(generate_password) + + local override_tag="" + if [[ "$auto_answer" == "no" ]]; then + read -p "Force AUTO-ANSWER? [y/N]: " force_aa + [[ "$force_aa" =~ ^[Yy]$ ]] && override_tag="[AA:yes]" && auto_answer="yes" + elif [[ "$auto_answer" == "yes" ]]; then + read -p "Force RING? [y/N]: " force_ring + [[ "$force_ring" =~ ^[Yy]$ ]] && override_tag="[AA:no]" && auto_answer="no" + fi + + # CONNECTION TYPE SELECTION + local conn_type="lan" + local transport_block="" + local encryption_block="" + local ice_block="" + local display_server="" + local display_port="5060" + local display_transport="UDP" + local display_encryption="None" + + # In Docker with FQDN: default to FQDN mode for all devices + if is_docker && [[ -n "$DOMAIN_NAME" ]]; then + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " HOW WILL THIS DEVICE CONNECT?" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo -e " 1) ${CYAN}FQDN (recommended)${NC} - Via ${DOMAIN_NAME} (TLS) - works from any network" + echo -e " 2) ${GREEN}LAN only${NC} - Same local network (UDP)" + echo "" + read -p " Select [1]: " conn_choice + conn_choice="${conn_choice:-1}" + + if [[ "$conn_choice" == "2" ]]; then + transport_block="transport=transport-udp" + encryption_block="media_encryption=no" + display_server="$(hostname -I | awk '{print $1}')" + display_port="5060" + display_transport="UDP" + display_encryption="None" + ice_block="ice_support=yes" + else + conn_type="fqdn" + transport_block="transport=transport-tls" + encryption_block="media_encryption=sdes" + ice_block="ice_support=yes" + display_server="$DOMAIN_NAME" + display_port="5061" + display_transport="TLS" + display_encryption="SRTP (SDES)" + fi + else + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " HOW WILL THIS DEVICE CONNECT?" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo -e " 1) ${GREEN}LAN/VPN${NC} - Same network or VPN tunnel (UDP)" + if [[ "$ENABLE_TLS" == "y" && -n "$DOMAIN_NAME" ]]; then + echo -e " 2) ${CYAN}FQDN${NC} - Internet or cross-VLAN via ${DOMAIN_NAME} (TLS)" + else + echo -e " 2) ${YELLOW}FQDN${NC} - Not configured (run 'Setup Internet Access' first)" + fi + echo "" + read -p " Select [1]: " conn_choice + conn_choice="${conn_choice:-1}" + + if [[ "$conn_choice" == "1" ]]; then + # LAN/VPN - UDP, no encryption (explicit transport prevents TLS fallback) + transport_block="transport=transport-udp" + encryption_block="media_encryption=no" + display_server="$(hostname -I | awk '{print $1}')" + display_port="5060" + display_transport="UDP" + display_encryption="None" + # Enable ICE for VPN devices if VPN ICE mode is active + if [[ "$VPN_ICE_ENABLED" == "y" ]]; then + ice_block="ice_support=yes" + fi + elif [[ "$conn_choice" == "2" ]]; then + if [[ "$ENABLE_TLS" != "y" || -z "$DOMAIN_NAME" ]]; then + print_error "FQDN access not configured. Run 'Setup Internet Access' first." + return + fi + conn_type="fqdn" + transport_block="transport=transport-tls" + encryption_block="media_encryption=sdes" + ice_block="ice_support=yes" + display_server="$DOMAIN_NAME" + display_port="5061" + display_transport="TLS" + display_encryption="SRTP (SDES)" + fi + fi + + backup_config "/etc/asterisk/pjsip.conf" + + # Mobile devices benefit from keepalive to maintain NAT mappings + # during WiFi/mobile data transitions + local keepalive_block="" + if [[ "$cat_id" == "mobile" ]]; then + keepalive_block="rtp_keepalive=15 +rtp_timeout=120 +rtp_timeout_hold=120" + fi + + cat >> /etc/asterisk/pjsip.conf << EOF + +; === Device: $name ($cat_id) $override_tag === +[${ext}] +type=endpoint +context=intercom +${transport_block} +disallow=all +allow=opus +allow=ulaw +allow=alaw +allow=g722 +${encryption_block} +direct_media=no +rtp_symmetric=yes +force_rport=yes +rewrite_contact=yes +${keepalive_block} +${ice_block} +auth=${ext} +aors=${ext} +callerid="${name}" <${ext}> + +[${ext}] +type=auth +auth_type=userpass +username=${ext} +password=${pass} + +[${ext}] +type=aor +max_contacts=5 +remove_existing=yes +qualify_frequency=30 +EOF + + chown -R asterisk:asterisk /etc/asterisk 2>/dev/null || true + asterisk -rx "pjsip reload" >/dev/null 2>&1 + rebuild_dialplan + + # Prepare provisioning URLs if HTTP server is configured + local server_ip=$(hostname -I | awk '{print $1}') + local prov_url_http="" + local prov_url_https="" + if [[ -f /etc/asterisk/http.conf ]] && grep -q "enabled=yes" /etc/asterisk/http.conf 2>/dev/null; then + prov_url_http="http://${server_ip}:8088/static/linphone.xml" + if [[ -n "$DOMAIN_NAME" ]]; then + prov_url_https="https://${DOMAIN_NAME}:8089/static/linphone.xml" + fi + fi + + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo " DEVICE ADDED: $name (Extension $ext)" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo -e " ${BOLD}Server Details:${NC}" + echo " Server: ${display_server}" + echo " Port: ${display_port}" + echo " Transport: ${display_transport}" + echo " Extension: $ext" + echo " Password: $pass" + echo " Encryption: ${display_encryption}" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " ${BOLD}LINPHONE SETUP${NC}" + echo "═══════════════════════════════════════════════════════════════" + echo "" + if [[ -n "$prov_url_http" ]]; then + echo " Remote Provisioning (Recommended):" + echo " 1. In Linphone → Settings → Remote provisioning" + echo " 2. Enter URL:" + echo " ${prov_url_http}" + [[ -n "$prov_url_https" ]] && echo " OR ${prov_url_https}" + echo " 3. Tap 'Fetch' to apply configuration" + echo "" + echo " OR Manual Setup:" + else + echo " Manual Setup:" + fi + echo " 1. Add Account → Use SIP account" + echo " 2. Username: $ext" + echo " 3. Password: $pass" + echo " 4. Domain: ${display_server}" + echo " 5. Transport: ${display_transport}" + echo "" + echo "═══════════════════════════════════════════════════════════════" + echo -e " ${BOLD}BARESIP SETUP (if Linphone has audio issues)${NC}" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo " Baresip often works better on privacy-focused Android ROMs." + echo " Two-step manual configuration required:" + echo "" + echo " Step 1: Add Account" + echo " Menu (☰) → Accounts → Add (+)" + echo " SIP URI: ${ext}@${display_server}" + echo " Save (✓)" + echo "" + echo " Step 2: Edit Account (Complete Config)" + echo " Tap account → Edit" + echo " Auth Username: $ext (JUST the number!)" + echo " Auth Password: $pass" + echo " Outbound Proxy: ${display_server} (JUST the domain!)" + echo " Media Encryption: srtp (select from dropdown)" + echo " Register: ✓ (check box)" + echo " Save (✓)" + echo "" + echo " Verify: Look for green dot or 'Registered' status" + echo " To call: Just dial extension (101, 202, etc.)" + echo "" + echo " For detailed Baresip instructions:" + echo " Server Settings → Provisioning Manager → Create Baresip Config" + echo "" + echo "═══════════════════════════════════════════════════════════════" + + # Show TURN/STUN settings if enabled (for manual SIP app configuration) + if [[ "$TURN_ENABLED" == "y" && -n "$TURN_SERVER" ]]; then + echo "" + echo -e " ${BOLD}STUN/TURN SETTINGS (for NAT traversal)${NC}" + echo "═══════════════════════════════════════════════════════════════" + echo "" + echo " Configure these in your SIP app's Network/ICE settings:" + echo " ICE: Enabled" + echo " STUN server: ${TURN_SERVER}" + echo " TURN server: ${TURN_SERVER}" + echo " TURN username: ${TURN_USERNAME}" + echo " TURN password: ${TURN_PASSWORD}" + echo " TURN transport: UDP" + echo "" + echo " Linphone: Auto-provisioned via XML (no manual setup needed)" + echo " Sipnetic: Settings → Network → ICE/STUN/TURN" + echo " Olinuxino: Settings → Network → ICE/STUN/TURN" + echo "" + echo "═══════════════════════════════════════════════════════════════" + fi + + echo "" + echo " NOTE: These instructions work for most SIP apps (Zoiper," + echo " sipnetic, etc.) - just use the same credentials." + echo "" + echo "═══════════════════════════════════════════════════════════════" +} + +remove_device() { + print_header "Remove Device" + declare -A REMOVE_MAP + declare -A NAME_MAP + local count=1 + local current_name="" + echo "Select device to remove:" + echo "" + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + local temp="${line#*; === Device: }" + temp="${temp% ===}" + temp="${temp% \[AA:*\]}" + current_name="${temp% (*)}" + fi + if [[ "$line" =~ ^\[([0-9]+)\]$ && "$current_name" != "" ]]; then + local ext="${BASH_REMATCH[1]}" + echo " ${count}) Ext ${ext} - ${current_name}" + REMOVE_MAP[$count]=$ext + NAME_MAP[$count]="$current_name" + ((count++)) + current_name="" + fi + done < /etc/asterisk/pjsip.conf + echo "" + echo " 98) DELETE ALL DEVICES" + echo " 0) Cancel" + echo "" + read -p "Select: " choice + + if [[ "$choice" == "98" ]]; then + echo "" + print_warn "This will DELETE ALL DEVICES!" + read -p "Type 'DELETE ALL' to confirm: " confirm + if [[ "$confirm" == "DELETE ALL" ]]; then + backup_config "/etc/asterisk/pjsip.conf" + # Remove all device sections - use awk to properly handle all sections + awk ' + /^; === Device:/ { skip = 1; next } + /^\[[0-9]{3}\]$/ { if (skip) next } + /^type=(endpoint|auth|aor)/ { if (skip) next } + /^$/ { if (skip) { skip = 0; next } } + !skip { print } + ' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp + mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan + print_success "All devices deleted" + else + print_error "Cancelled" + fi + return + fi + + [[ "$choice" == "0" || -z "${REMOVE_MAP[$choice]}" ]] && return + + local ext="${REMOVE_MAP[$choice]}" + local name="${NAME_MAP[$choice]}" + read -p "Confirm removal of $ext ($name)? [y/N]: " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + backup_config "/etc/asterisk/pjsip.conf" + # Use awk to remove the device comment and ALL three sections for this extension + awk -v ext="$ext" ' + BEGIN { skip = 0; found_ext = 0 } + # Match device comment line - start potential skip + /^; === Device:/ { pending_comment = $0; next } + # Check if this is the extension we want to delete + $0 ~ "^\\[" ext "\\]$" { + if (pending_comment != "") { + # This is our device - skip the comment and this section + skip = 1 + found_ext = 1 + pending_comment = "" + next + } else if (found_ext) { + # Additional sections for same extension (auth, aor) + skip = 1 + next + } + } + # If we have a pending comment for a different extension, print it + pending_comment != "" && $0 !~ "^\\[" ext "\\]$" { + print pending_comment + pending_comment = "" + } + # Skip lines until empty line + skip && /^$/ { skip = 0; next } + skip { next } + { print } + ' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp + mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan + print_success "Removed extension $ext ($name)" + fi +} + +rename_device() { + print_header "Rename Device" + declare -A DEVICE_MAP + declare -A NAME_MAP + declare -A AA_MAP + local count=1 + echo "Select device to rename:" + echo "" + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + local temp="${line#*; === Device: }" + temp="${temp% ===}" + local aa_tag="" + if [[ "$temp" == *"[AA:yes]"* ]]; then + aa_tag="[AA:yes]" + temp="${temp% \[AA:yes\]}" + elif [[ "$temp" == *"[AA:no]"* ]]; then + aa_tag="[AA:no]" + temp="${temp% \[AA:no\]}" + fi + local name="${temp% (*)}" + local cat="${temp##* (}"; cat="${cat%)}" + fi + if [[ "$line" =~ ^\[([0-9]+)\]$ && -n "$name" ]]; then + local ext="${BASH_REMATCH[1]}" + echo " ${count}) Ext ${ext} - ${name} (${cat})" + DEVICE_MAP[$count]=$ext + NAME_MAP[$count]="${name}|${cat}" + AA_MAP[$count]="${aa_tag}" + ((count++)) + name="" + fi + done < /etc/asterisk/pjsip.conf + echo "" + echo " 0) Cancel" + echo "" + read -p "Select: " choice + + [[ "$choice" == "0" || -z "${DEVICE_MAP[$choice]}" ]] && return + + local ext="${DEVICE_MAP[$choice]}" + local info="${NAME_MAP[$choice]}" + local aa_tag="${AA_MAP[$choice]}" + local old_name="${info%|*}" + local cat="${info##*|}" + + echo "" + echo "Current name: ${old_name}" + read -p "New name: " new_name + + if [[ -z "$new_name" ]]; then + print_error "Name cannot be empty" + return + fi + + # Backup config + backup_config "/etc/asterisk/pjsip.conf" + + # Use awk to properly update both the comment line (preserving AA tag) and callerid + awk -v ext="$ext" -v old_name="$old_name" -v new_name="$new_name" -v cat="$cat" -v aa_tag="$aa_tag" ' + # Update device comment line + /^; === Device:/ && $0 ~ old_name && $0 ~ cat { + if (aa_tag != "") { + print "; === Device: " new_name " (" cat ") " aa_tag " ===" + } else { + print "; === Device: " new_name " (" cat ") ===" + } + next + } + # Track when we are in the correct extension section + $0 ~ "^\\[" ext "\\]$" { in_ext = 1 } + /^$/ { in_ext = 0 } + # Update callerid in the extension section + in_ext && /^callerid=/ { + print "callerid=\"" new_name "\" <" ext ">" + next + } + { print } + ' /etc/asterisk/pjsip.conf > /etc/asterisk/pjsip.conf.tmp + mv /etc/asterisk/pjsip.conf.tmp /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + + # Reload Asterisk + asterisk -rx "pjsip reload" 2>/dev/null + rebuild_dialplan quiet + + print_success "Device renamed: ${old_name} → ${new_name}" +} + + +show_registered_devices() { + # Collect all device data + declare -A device_data + local dev_name="" dev_cat="" + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + # Remove the prefix and suffix, handling variable whitespace + local temp="${line#*; === Device: }" + temp="${temp%% ===}" # Use %% to handle multiple spaces before === + temp="${temp## }" # Trim leading spaces + temp="${temp%% }" # Trim trailing spaces + + [[ "$temp" == *"[AA:"* ]] && temp="${temp% \[AA:*\]}" + + # Extract category - everything inside the last (...) + dev_cat="${temp##*\(}" + dev_cat="${dev_cat%\)}" + dev_cat="${dev_cat## }" # Trim any leading spaces + dev_cat="${dev_cat%% }" # Trim any trailing spaces + + # Extract name - everything before the last ( + dev_name="${temp%% \(*}" + fi + if [[ "$line" =~ ^\[([0-9]+)\] ]]; then + local ext="${BASH_REMATCH[1]}" + if [[ -n "$dev_name" ]]; then + device_data[$ext]="${dev_name}|${dev_cat}" + dev_name="" + dev_cat="" + fi + fi + done < /etc/asterisk/pjsip.conf + + # Interactive loop + while true; do + # Group by category + declare -A categories + declare -A category_names + for ext in "${!device_data[@]}"; do + local info="${device_data[$ext]}" + local cat="${info##*|}" + categories[$cat]="${categories[$cat]} $ext" + done + + # Get full category names from categories file + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + category_names[$cat_id]="$cat_name" + done < "$CATEGORIES_FILE" + + clear + print_header "Device Status" + + echo "Select category to view:" + echo " 1) All devices" + local i=2 + declare -A cat_menu + for cat in $(echo "${!categories[@]}" | tr ' ' '\n' | sort); do + local display_name="${category_names[$cat]:-$cat}" + echo " ${i}) ${display_name}" + cat_menu[$i]="$cat" + ((i++)) + done + echo " 0) Back to menu" + echo "" + read -p "Select [1]: " cat_choice + + [[ "$cat_choice" == "0" ]] && return + cat_choice="${cat_choice:-1}" + + clear + print_header "Device Status" + printf "${CYAN}%-6s %-25s %-15s %-15s %-15s${NC}\n" "Ext" "Name" "Category" "Status" "Password" + echo "--------------------------------------------------------------------------------------------" + + if [[ "$cat_choice" == "1" ]]; then + # Show all devices + for ext in $(echo "${!device_data[@]}" | tr ' ' '\n' | sort -n); do + local info="${device_data[$ext]}" + local name="${info%|*}" + local cat="${info##*|}" + local cat_display="${category_names[$cat]:-$cat}" + local status="${RED}Offline${NC}" + local avail=$(asterisk -rx "pjsip show endpoint ${ext}" 2>/dev/null | grep -E "Contact:.*(Avail|NonQual)" || true) + [[ -n "$avail" ]] && status="${GREEN}Online${NC}" + local password=$(grep -A 10 "^\[$ext\]" /etc/asterisk/pjsip.conf | grep "password=" | head -1 | cut -d= -f2) + printf "%-6s %-25s %-15s %b %-15s\n" "$ext" "${name:0:23}" "${cat_display:0:13}" "$status" "$password" + done + else + # Show specific category + local selected_cat="${cat_menu[$cat_choice]}" + if [[ -n "$selected_cat" ]]; then + local cat_display="${category_names[$selected_cat]:-$selected_cat}" + echo -e "${BOLD}Showing: ${cat_display}${NC}" + echo "" + for ext in $(echo "${categories[$selected_cat]}" | tr ' ' '\n' | sort -n); do + local info="${device_data[$ext]}" + local name="${info%|*}" + local cat="${info##*|}" + local cat_display="${category_names[$cat]:-$cat}" + local status="${RED}Offline${NC}" + local avail=$(asterisk -rx "pjsip show endpoint ${ext}" 2>/dev/null | grep -E "Contact:.*(Avail|NonQual)" || true) + [[ -n "$avail" ]] && status="${GREEN}Online${NC}" + local password=$(grep -A 10 "^\[$ext\]" /etc/asterisk/pjsip.conf | grep "password=" | head -1 | cut -d= -f2) + printf "%-6s %-25s %-15s %b %-15s\n" "$ext" "${name:0:23}" "${cat_display:0:13}" "$status" "$password" + done + fi + fi + + echo "" + echo "Connection Details:" + echo " Domain: ${DOMAIN_NAME:-$(hostname -I | awk '{print $1}')}" + echo " Port: ${DEFAULT_SIP_PORT}/udp (LAN) or ${DEFAULT_SIPS_PORT}/tcp (TLS)" + echo "" + read -p "Press Enter to select another category (or 0 to exit)... " + done +} + + +# ================================================================ +# 4. PTT WIZARD (Fixed: Mute by default) +# ================================================================ + +configure_ptt_menu() { + print_header "Configure PTT Button" + detect_ptt_button +} + +detect_ptt_button() { + # Ensure evtest is installed + if ! command -v evtest &>/dev/null; then + apt install -y evtest >/dev/null 2>&1 + fi + + # Add user to input group + [[ -n "$KIOSK_USER" ]] && usermod -aG input "$KIOSK_USER" 2>/dev/null || true + + print_info "Scanning input devices..." + echo "" + + declare -a SUGGESTED_DEVICES SUGGESTED_NAMES OTHER_DEVICES OTHER_NAMES + + for dev in /dev/input/event*; do + [[ -e "$dev" ]] || continue + local name=$(cat "/sys/class/input/$(basename $dev)/device/name" 2>/dev/null || echo "Unknown") + local lname=$(echo "$name" | tr '[:upper:]' '[:lower:]') + + # Filter out system devices that aren't PTT candidates + if [[ "$lname" =~ (power.button|sleep.button|lid.switch|virtual|video.bus|hdmi|dp,pcm|hotkey|touchpad|touchscreen) ]]; then + OTHER_DEVICES+=("$dev") + OTHER_NAMES+=("$name") + # Prioritize keyboards, USB HID devices, pedals + elif [[ "$lname" =~ (keyboard|sayo.*nano$|pedal|foot|^hid) ]]; then + SUGGESTED_DEVICES+=("$dev") + SUGGESTED_NAMES+=("$name") + else + OTHER_DEVICES+=("$dev") + OTHER_NAMES+=("$name") + fi + done + + # Display suggested devices first + if [[ ${#SUGGESTED_DEVICES[@]} -gt 0 ]]; then + echo -e "${GREEN}Keyboards and USB buttons:${NC}" + for i in "${!SUGGESTED_DEVICES[@]}"; do + printf " ${CYAN}%2d)${NC} %s - %s\n" "$((i+1))" "$(basename ${SUGGESTED_DEVICES[$i]})" "${SUGGESTED_NAMES[$i]}" + done + echo "" + fi + + # Display other devices + if [[ ${#OTHER_DEVICES[@]} -gt 0 ]]; then + echo -e "${YELLOW}Other devices:${NC}" + local offset=${#SUGGESTED_DEVICES[@]} + for i in "${!OTHER_DEVICES[@]}"; do + printf " ${CYAN}%2d)${NC} %s - %s\n" "$((offset+i+1))" "$(basename ${OTHER_DEVICES[$i]})" "${OTHER_NAMES[$i]}" + done + echo "" + fi + + local ALL_DEVICES=("${SUGGESTED_DEVICES[@]}" "${OTHER_DEVICES[@]}") + local total=${#ALL_DEVICES[@]} + + if [[ $total -eq 0 ]]; then + print_error "No input devices found" + return 1 + fi + + echo " 0) Back" + echo "" + read -p "Select device [1]: " selection + selection="${selection:-1}" + + [[ "$selection" == "0" ]] && return 0 + [[ "$selection" -lt 1 || "$selection" -gt "$total" ]] && { print_error "Invalid selection"; return 1; } + + PTT_DEVICE="${ALL_DEVICES[$((selection-1))]}" + local dev_name=$(cat "/sys/class/input/$(basename $PTT_DEVICE)/device/name" 2>/dev/null || echo "Unknown") + echo "" + print_success "Selected: $dev_name" + echo " ($PTT_DEVICE)" + echo "" + + # Key detection loop + while true; do + echo -e "${YELLOW}══════════════════════════════════════════════════${NC}" + echo -e "${YELLOW} DO NOT PRESS YET - wait for countdown${NC}" + echo -e "${YELLOW}══════════════════════════════════════════════════${NC}" + + for i in 5 4 3 2 1; do + echo -ne "\r Waiting... $i " + sleep 1 + done + echo "" + echo "" + echo -e "${GREEN}>>> NOW PRESS YOUR PTT BUTTON <<<${NC}" + echo "" + + local detected_code=$(timeout 10 evtest "$PTT_DEVICE" 2>/dev/null | grep -m1 "value 1$" | grep -oP 'code \K[0-9]+' || echo "") + + if [[ -n "$detected_code" && "$detected_code" -gt 0 ]]; then + # Map common key codes to friendly names + local key_name="Key $detected_code" + case "$detected_code" in + 1) key_name="Escape" ;; + 28) key_name="Enter" ;; + 57) key_name="Spacebar" ;; + 69) key_name="Num Lock" ;; + 113) key_name="Mute" ;; + 114) key_name="Volume Down" ;; + 115) key_name="Volume Up" ;; + 116) key_name="Power" ;; + 142) key_name="Sleep" ;; + 272) key_name="Left Click" ;; + 273) key_name="Right Click" ;; + esac + + print_success "Detected: $key_name (code $detected_code)" + echo "" + read -p "Use this key? [Y/n]: " use_key + + if [[ ! "$use_key" =~ ^[Nn]$ ]]; then + PTT_KEYCODE="$detected_code" + PTT_KEYNAME="$key_name" + break + fi + else + print_warn "No button press detected" + fi + + echo "" + echo " 1) Try again" + echo " 2) Enter key code manually" + echo " 3) Cancel" + read -p "Select [1]: " retry + + case "${retry:-1}" in + 2) + read -p "Enter key code: " PTT_KEYCODE + PTT_KEYNAME="Manual" + break + ;; + 3) + return 1 + ;; + esac + done + + # Ensure user is in input group (critical for PTT device access) + if [[ -n "$KIOSK_USER" ]]; then + if ! id -nG "$KIOSK_USER" | grep -qw "input"; then + print_info "Adding $KIOSK_USER to input group..." + usermod -aG input "$KIOSK_USER" + echo "" + print_error "IMPORTANT: User added to 'input' group" + echo " User must log out and log back in (or reboot) for group change to take effect." + echo " PTT will NOT work until then!" + echo "" + read -p "Press Enter to acknowledge..." + fi + fi + + # Save configuration via save_config (will set proper permissions) + save_config + + print_success "PTT configured: $PTT_KEYNAME on $(basename $PTT_DEVICE)" + echo "" + echo "═══════════════════════════════════════════════════════" + echo " PTT Configuration Complete" + echo "═══════════════════════════════════════════════════════" + echo " Device: $PTT_DEVICE" + echo " Button: $PTT_KEYNAME" + echo " User: ${KIOSK_USER:-not set}" + echo "" + echo " Testing PTT:" + echo " 1. Check logs: journalctl -t kiosk-ptt -f" + echo " 2. Press PTT button" + echo " 3. You should see: 'PTT pressed - mic unmuted'" + echo "" + echo " If you see 'Permission denied' errors:" + echo " - User needs to be in 'input' group (already added above)" + echo " - Log out and log back in, or reboot" + echo "═══════════════════════════════════════════════════════" + + # Restart PTT service if client is installed (bare metal only) + if [[ "$INSTALLED_CLIENT" == "y" && -n "$KIOSK_USER" ]] && ! is_docker; then + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + echo "" + print_info "Restarting PTT service..." + sudo -u "$KIOSK_USER" $user_dbus systemctl --user daemon-reload 2>/dev/null + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart kiosk-ptt 2>/dev/null || true + sleep 2 + echo "" + echo "Checking PTT status..." + journalctl -t kiosk-ptt -n 5 --no-pager 2>/dev/null || echo " No logs yet (check after logging out/in if needed)" + fi + + return 0 +} + +create_ptt_handler() { + cat > /usr/local/bin/kiosk-ptt << 'PTTSCRIPT' +#!/bin/bash +CONFIG="/etc/easy-asterisk/config" +PTT_CONFIG="/etc/easy-asterisk/ptt-device" +[[ -f "$CONFIG" ]] && source "$CONFIG" +[[ -f "$PTT_CONFIG" ]] && source "$PTT_CONFIG" + +# Exit if no PTT device configured - leave audio unmuted for normal kiosk operation +[[ -z "$PTT_DEVICE" ]] && exit 0 + +# Ensure we have the user's runtime directory +if [[ -z "$XDG_RUNTIME_DIR" ]]; then + # If running as systemd service, this should already be set + # But if not, try to detect it + if [[ -n "$KIOSK_UID" ]]; then + export XDG_RUNTIME_DIR="/run/user/${KIOSK_UID}" + else + # Fall back to current user + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + fi +fi + +# Wait for PipeWire/PulseAudio to be ready +for i in {1..10}; do + if pactl info >/dev/null 2>&1; then + break + fi + sleep 1 +done + +# PTT mode: Mute audio source on start, unmute only when button pressed +pactl set-source-mute @DEFAULT_SOURCE@ 1 2>/dev/null || { + logger -t kiosk-ptt "ERROR: Failed to mute audio source" + exit 1 +} + +logger -t kiosk-ptt "PTT handler started, microphone muted, listening on $PTT_DEVICE" + +# Unmute on press, mute on release +evtest --grab "$PTT_DEVICE" 2>/dev/null | while read -r line; do + if [[ "$line" =~ "value 1" ]]; then + pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null + logger -t kiosk-ptt "PTT pressed - mic unmuted" + fi + if [[ "$line" =~ "value 0" ]]; then + pactl set-source-mute @DEFAULT_SOURCE@ 1 2>/dev/null + logger -t kiosk-ptt "PTT released - mic muted" + fi +done +PTTSCRIPT + chmod +x /usr/local/bin/kiosk-ptt +} + +# ================================================================ +# 5. AUDIO DUCKING +# ================================================================ + +configure_audio_ducking() { + [[ -z "$KIOSK_USER" ]] && return + local wp_dir="/home/${KIOSK_USER}/.config/wireplumber/wireplumber.conf.d" + mkdir -p "$wp_dir" + cat > "${wp_dir}/50-intercom-ducking.conf" << 'EOF' +wireplumber.settings = { linking.allow-moving-streams = true } +EOF + chown -R ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.config" +} + +ensure_audio_unmuted() { + [[ -z "$KIOSK_USER" ]] && return + [[ -z "$KIOSK_UID" ]] && return + + # Only unmute if PTT is not configured + if [[ ! -f /etc/easy-asterisk/ptt-device ]]; then + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + + # Wait a moment for PipeWire to initialize + sleep 2 + + # Unmute all sources and sinks + sudo -u "$KIOSK_USER" $user_dbus pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null || true + sudo -u "$KIOSK_USER" $user_dbus pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null || true + + # Set reasonable volume levels if they're at 0 + local source_vol=$(sudo -u "$KIOSK_USER" $user_dbus pactl get-source-volume @DEFAULT_SOURCE@ 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%') + local sink_vol=$(sudo -u "$KIOSK_USER" $user_dbus pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\d+%' | head -1 | tr -d '%') + + [[ -n "$source_vol" && "$source_vol" -lt 50 ]] && sudo -u "$KIOSK_USER" $user_dbus pactl set-source-volume @DEFAULT_SOURCE@ 75% 2>/dev/null || true + [[ -n "$sink_vol" && "$sink_vol" -lt 50 ]] && sudo -u "$KIOSK_USER" $user_dbus pactl set-sink-volume @DEFAULT_SINK@ 75% 2>/dev/null || true + fi +} + +# ================================================================ +# 6. DIAGNOSTICS & FIREWALL +# ================================================================ + +show_port_requirements() { + print_header "Port / Firewall Requirements" + echo "This server needs traffic to pass from your Clients (Kiosks/Phones)." + echo "" + echo "Does your Asterisk server have a PUBLIC IP (VPS/Cloud)?" + echo " -> YES: You must use 'Forwarding' (DNAT) rules on your router." + echo " -> NO: You must use 'Allow/Pass' rules on your VLAN interfaces." + echo "" + echo "Required Ports:" + echo "┌──────────────────┬──────────┬───────────────────────────────┐" + echo "│ Port │ Protocol │ Purpose │" + echo "├──────────────────┼──────────┼───────────────────────────────┤" + echo "│ 5060 │ UDP │ SIP Signaling (Registration) │" + echo "│ 5061 │ TCP │ SIP-TLS Signaling (Secure) │" + echo "│ 10000-20000 │ UDP │ RTP Media (Audio/Video) │" + if [[ "$USE_COTURN" == "y" ]]; then + echo "│ ${DEFAULT_TURN_PORT} │ UDP/TCP │ TURN Signaling (Handshake) │" + echo "│ 49152-65535 │ UDP │ TURN Relay (Actual Media Path)│" + fi + echo "└──────────────────┴──────────┴───────────────────────────────┘" + echo "" + echo "NOTE: VPN Users" + echo "If ALL clients and server are on a VPN (Tailscale/Wireguard), you DO NOT" + echo "need port forwarding or COTURN. Just bind Asterisk to the VPN IP." +} + +show_firewall_guide() { + print_header "Interactive Firewall Guide (Hand-holding Mode)" + echo "For: Routers with VLAN support" + echo "" + echo "=== SCENARIO A: INTERNAL ONLY (VLAN to VLAN) ===" + echo "Example: Kiosks on VLAN 10, Server on VLAN 20" + echo "GOAL: Allow Kiosks to talk to Server." + echo "" + echo "STEP 1: Log in to Router. Go to Firewall > Rules > VLAN 10 Interface." + echo " (Do NOT use 'Port Forwarding' for internal VLANs!)" + echo "" + echo "STEP 2: Create Rule 1 (Signaling)" + echo " - Action: Pass (Allow)" + echo " - Protocol: UDP/TCP" + echo " - Source: VLAN 10 Net" + echo " - Dest: ${CURRENT_PUBLIC_IP:-Server_IP}" + echo " - Port: 3478 (or your TURN_PORT if changed)" + echo "" + echo "STEP 3: Create Rule 2 (The Relay Range - CRITICAL)" + echo " - Action: Pass (Allow)" + echo " - Protocol: UDP" + echo " - Source: VLAN 10 Net" + echo " - Dest: ${CURRENT_PUBLIC_IP:-Server_IP}" + echo " - Port Range:" + echo " From: 49152" + echo " To: 65535" + echo " (Note: Type these numbers in the Start/End boxes)" + echo "" + echo "================================================" + echo "" + echo "=== SCENARIO B: EXTERNAL ACCESS (Internet to LAN) ===" + echo "Example: Remote phone connecting from a hotel." + echo "GOAL: Forward traffic from Internet to Server." + echo "" + echo "STEP 1: Go to Firewall > NAT > Port Forwarding." + echo "STEP 2: Create Rule." + echo " - Interface: WAN" + echo " - Protocol: UDP" + echo " - Dest. Port: 3478 (or your TURN_PORT) and 49152-65535" + echo " - Redirect IP: ${CURRENT_PUBLIC_IP:-Server_IP}" + echo "" + read -p "Press Enter to return..." +} + +show_preflight_check() { + print_header "Pre-Flight Requirements Check" + echo "Modern browsers (Chrome, Safari, Kiosk Mode) have strict security settings." + echo "" + echo "1. HTTPS / SSL Certificate (Required for Camera/Mic)" + echo " - Browsers block Mic/Cam on 'Insecure Origins' (HTTP)." + echo " - Exception: http://localhost is allowed." + echo " - Solution: You NEED a domain (FQDN) and SSL Cert (LetsEncrypt)." + echo " - Workaround: Use the 'Caddy Cert Sync' option in this script." + echo "" + echo "2. Static vs Dynamic IP" + echo " - If your Public IP changes, COTURN will break." + echo " - Solution: Use the 'Update IP manually' or auto-script in the menu." + echo "" + echo "3. VPN Alternative" + echo " - A VPN (Tailscale) negates the need for COTURN and Port Forwarding." + echo " - It treats all devices as if they are on the same flat network." + echo "" + read -p "Press Enter to return..." +} + +test_sip_connectivity() { + print_header "SIP Connectivity Test" + if asterisk_running; then + print_success "Asterisk Running" + else + print_error "Asterisk Down" + fi + echo "" + echo "Listening ports:" + ss -ulnp | grep 5060 || echo " UDP 5060: Not listening" + ss -tlnp | grep 5061 || echo " TCP 5061: Not listening" + if [[ -n "$DOMAIN_NAME" ]]; then + echo "" + echo "TLS Certificate check:" + timeout 5 openssl s_client -connect localhost:5061 -servername "$DOMAIN_NAME" 2>/dev/null | grep "Verify return code" || echo " TLS test failed" + fi +} + +verify_cidr_config() { + print_header "CIDR Configuration" + local my_ip=$(hostname -I | cut -d' ' -f1) + echo "Server IP: $my_ip" + echo "" + echo "Current NAT settings in pjsip.conf:" + grep -E "external_|local_net" /etc/asterisk/pjsip.conf 2>/dev/null || echo " No NAT settings found" +} + +configure_vlan_subnets() { + print_header "VLAN / VPN Subnet Configuration" + load_config + + echo "Additional Subnet Support for Easy Asterisk" + echo "================================================" + echo "" + echo "If your network uses VLANs or VPNs, you need to tell" + echo "Asterisk about all the local subnets so that:" + echo " - Calls don't drop after 30 seconds (VLAN issue)" + echo " - VPN-connected mobile devices can register" + echo " - Audio works correctly for VPN users" + echo "" + echo "Example subnets:" + echo " 192.168.1.0/24 - Main network" + echo " 192.168.10.0/24 - IoT VLAN" + echo " 100.64.0.0/10 - Tailscale VPN" + echo " 10.0.0.0/8 - WireGuard/OpenVPN" + echo "" + + # Auto-detect VPN interfaces and their subnets + local detected_vpn_subnets="" + local vpn_info="" + while IFS= read -r line; do + local iface=$(echo "$line" | awk '{print $2}' | tr -d ':') + local addr=$(echo "$line" | awk '{print $4}') + if [[ -n "$addr" && -n "$iface" ]]; then + case "$iface" in + tailscale*|ts*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (Tailscale)\n" + detected_vpn_subnets="${detected_vpn_subnets} 100.64.0.0/10" + ;; + wg*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (WireGuard)\n" + detected_vpn_subnets="${detected_vpn_subnets} ${addr}" + ;; + tun*|tap*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (OpenVPN/VPN tunnel)\n" + detected_vpn_subnets="${detected_vpn_subnets} ${addr}" + ;; + nordlynx*|proton*) + vpn_info="${vpn_info} Detected: ${iface} -> ${addr} (VPN)\n" + detected_vpn_subnets="${detected_vpn_subnets} ${addr}" + ;; + esac + fi + done < <(ip -o -f inet addr show 2>/dev/null | grep -vE 'lo |docker|br-|veth') + detected_vpn_subnets=$(echo "$detected_vpn_subnets" | xargs -n1 2>/dev/null | sort -u | xargs 2>/dev/null) + + if [[ -n "$vpn_info" ]]; then + echo -e "${GREEN}VPN interfaces detected on this server:${NC}" + echo -e "$vpn_info" + echo " Suggested VPN subnets: ${detected_vpn_subnets}" + echo "" + echo " NOTE: If mobile devices connect via VPN (e.g., Tailscale on phones)," + echo " you MUST add the VPN subnet here for them to reach Asterisk." + echo "" + fi + + read -p "Does your network use VLANs or VPNs? (y/n) [${HAS_VLANS}]: " has_vlans + has_vlans=${has_vlans:-$HAS_VLANS} + + if [[ "$has_vlans" =~ ^[Yy] ]]; then + HAS_VLANS="y" + echo "" + echo "Current Subnets: ${VLAN_SUBNETS:-none}" + if [[ -n "$detected_vpn_subnets" ]]; then + echo "Detected VPN Subnets: ${detected_vpn_subnets}" + fi + echo "" + echo "Enter ALL additional subnets (VLAN + VPN) in CIDR notation, separated by spaces." + echo "Example: 192.168.10.0/24 100.64.0.0/10" + echo "" + local default_subnets="${VLAN_SUBNETS:-$detected_vpn_subnets}" + read -p "Subnets [${default_subnets}]: " vlan_input + vlan_input="${vlan_input:-$default_subnets}" + + if [[ -n "$vlan_input" ]]; then + VLAN_SUBNETS="$vlan_input" + save_config + print_success "VLAN configuration saved" + echo "" + echo "Rebuilding pjsip.conf to apply changes..." + generate_pjsip_conf + asterisk -rx "module reload res_pjsip.so" 2>/dev/null + print_success "Asterisk configuration updated" + + echo "" + echo "═══════════════════════════════════════════════════════════" + echo " VLAN DNS SETUP GUIDE (Split-Horizon)" + echo "═══════════════════════════════════════════════════════════" + echo "" + echo "For proper VLAN operation with FQDNs, you need split-horizon DNS." + echo "" + read -p "Display DNS setup guide? (y/n) [y]: " show_dns + show_dns=${show_dns:-y} + + if [[ "$show_dns" =~ ^[Yy]$ ]]; then + cat << 'DNSGUIDE' + +WHAT YOU'RE ACHIEVING: +• Devices on VLANs use router for DNS (ctrld) +• ctrld split-horizon rules send FQDNs to the right LAN servers +• Only ctrld (router) can talk to servers' DNS (protected by UFW) +• No inter-VLAN routing is opened, just DNS and service ports + +1. CTRLD.TOML (on OPNSense/Router): + +[listener.0] + ip = '0.0.0.0' + port = 53 + + [listener.0.policy] + networks = [ + { 'network.0' = ['upstream.0'] }, + { 'network.1' = ['upstream.1'] } + ] + rules = [ + { 'asterisk.mydomain.com' = ['upstream.4'] } + ] + +[network.0] + cidrs = ['192.168.1.0/24'] + +[network.1] + cidrs = ['192.168.200.0/24'] + +[upstream.0] + type = 'doh' + endpoint = 'https://dns.controld.com/your-profile' + timeout = 5000 + +[upstream.4] + type = 'legacy' + endpoint = '192.168.1.11' # This Asterisk server + timeout = 3000 + +2. DNSMASQ ON THIS SERVER: + +sudo apt-get install dnsmasq +echo "listen-address=127.0.0.1" >> /etc/dnsmasq.conf +echo "listen-address=$(hostname -I | cut -d' ' -f1)" >> /etc/dnsmasq.conf +echo "bind-interfaces" >> /etc/dnsmasq.conf +echo "address=/asterisk.mydomain.com/$(hostname -I | cut -d' ' -f1)" >> /etc/dnsmasq.conf +sudo systemctl restart dnsmasq + +3. UFW RULES ON THIS SERVER: + +sudo ufw allow from 192.168.1.1 to any port 53 proto udp +sudo ufw allow from 192.168.1.1 to any port 53 proto tcp +sudo ufw deny 53 +sudo ufw reload + +Replace 192.168.1.1 with your router's LAN IP. + +4. OPNSENSE FIREWALL RULES (for each VLAN): + +Rule 1 - Allow DNS from VLAN to Router: + Action: Pass + Source: VLANxx net + Destination: This Firewall + Port: 53 (DNS) + Protocol: TCP/UDP + +Rule 2 - Allow SIP/RTP from VLAN to Asterisk: + Source: VLANxx net + Destination: $(hostname -I | cut -d' ' -f1) + Ports: 5060/udp, 5061/tcp, 10000-20000/udp + +5. DHCP SETTINGS (OPNSense): + +For each VLAN, set DNS Servers to ONLY the router's VLAN IP. +Do NOT enter this server's IP as DNS. + +═══════════════════════════════════════════════════════════ +DNSGUIDE + fi + else + print_error "No subnets provided" + fi + else + HAS_VLANS="n" + VLAN_SUBNETS="" + save_config + print_success "VLAN support disabled" + fi +} + +# ================================================================ +# PROVISIONING MANAGER +# ================================================================ + +setup_http_provisioning() { + print_header "HTTP Provisioning Setup" + + echo "This will configure Asterisk's built-in HTTP server for" + echo "client provisioning (Linphone, etc.)." + echo "" + echo "Ports:" + echo " HTTP: 8088" + echo " HTTPS: 8089" + echo "" + + # Create http.conf + backup_config "/etc/asterisk/http.conf" 2>/dev/null + cat > /etc/asterisk/http.conf << 'EOF' +[general] +enabled=yes +bindaddr=0.0.0.0 +bindport=8088 + +tlsenable=yes +tlsbindaddr=0.0.0.0:8089 +tlscertfile=/etc/asterisk/certs/server.crt +tlsprivatekey=/etc/asterisk/certs/server.key + +; Serve static files from /var/lib/asterisk/static-http +enablestatic=yes +redirect=/static /var/lib/asterisk/static-http + +; Security +session_limit=100 +session_inactivity=30000 +session_keep_alive=15000 +EOF + + chown asterisk:asterisk /etc/asterisk/http.conf + + # Create provisioning directory + mkdir -p "$PROVISIONING_DIR" + chown asterisk:asterisk "$PROVISIONING_DIR" + + # Create symlink if needed (Ubuntu/Debian fix) + if [[ ! -L /usr/share/asterisk/static-http ]]; then + mkdir -p /usr/share/asterisk + ln -sf "$PROVISIONING_DIR" /usr/share/asterisk/static-http + print_info "Created symlink: /usr/share/asterisk/static-http -> $PROVISIONING_DIR" + fi + + # Reload Asterisk HTTP module + asterisk -rx "module reload res_http_post.so" 2>/dev/null || true + asterisk -rx "http show status" 2>/dev/null + + print_success "HTTP provisioning configured" + echo "" + echo "Access provisioning files at:" + echo " HTTP: http://$(hostname -I | cut -d' ' -f1):8088/static/" + echo " HTTPS: https://$(hostname -I | cut -d' ' -f1):8089/static/" +} + +create_linphone_xml() { + print_header "Create/Edit Linphone Provisioning XML" + load_config + + local xml_file="$PROVISIONING_DIR/linphone.xml" + local server_ip=$(hostname -I | cut -d' ' -f1) + local domain="${DOMAIN_NAME:-$server_ip}" + local transport="tcp" + + if [[ "$ENABLE_TLS" == "y" && -n "$DOMAIN_NAME" ]]; then + transport="tls" + fi + + echo "Current Configuration:" + echo " Domain: $domain" + echo " Transport: $transport" + echo " Server IP: $server_ip" + echo "" + + read -p "Create/Update linphone.xml? (y/n) [y]: " create_xml + create_xml=${create_xml:-y} + + if [[ "$create_xml" =~ ^[Yy]$ ]]; then + mkdir -p "$PROVISIONING_DIR" + + cat > "$xml_file" << EOF + + + + + +
+ 0 + 1 + 0 +
+ +
+ <sip:${domain};transport=${transport}> + sip:USERNAME@${domain} + 3600 + 0 + 0 +
+ +
+ USERNAME + PASSWORD + ${domain} +
+ +
+ 7078 + 60 +
+ +
+ ANDROID SND: Android Sound card + ANDROID SND: Android Sound card + ANDROID SND: Android Sound card +
+ +
+ 0 + 0 + 0 +
+ +
+ 1 + 0 + 1 + 0 + + 0 + + 1 + 1 +
+ +
+ + 0 + 1 +
+ +
+ 1300 + + 3 + ${TURN_SERVER:-${domain}:3478} +
+ +
+EOF + + # Add TURN credentials section if TURN is enabled + if [[ "$TURN_ENABLED" == "y" && -n "$TURN_SERVER" && -n "$TURN_USERNAME" && -n "$TURN_PASSWORD" ]]; then + # Insert TURN credentials into the net section before + sed -i "s|.*|${TURN_SERVER}\n 1\n ${TURN_USERNAME}\n ${TURN_PASSWORD}|" "$xml_file" + fi + + chown asterisk:asterisk "$xml_file" + chmod 644 "$xml_file" + + print_success "Created: $xml_file" + echo "" + echo "Provisioning URL:" + if [[ "$transport" == "tls" ]]; then + echo " https://${domain}:8089/static/linphone.xml" + else + echo " http://${server_ip}:8088/static/linphone.xml" + fi + echo "" + echo "IMPORTANT for Android:" + echo " 1. Use the URL above in Linphone's 'Remote provisioning'" + echo " 2. Replace USERNAME and PASSWORD in device-specific XML files" + echo " 3. Set Battery Optimization to 'Unrestricted' manually on phone" + echo " 4. The XML prevents audio pause when screen turns off" + echo "" + echo "FOR /e/OS (eFoundation) users:" + echo " See 'Troubleshoot /e/OS Audio' in Provisioning Manager menu" + fi +} + +edit_linphone_xml() { + local xml_file="$PROVISIONING_DIR/linphone.xml" + + if [[ ! -f "$xml_file" ]]; then + print_error "linphone.xml does not exist. Create it first." + return 1 + fi + + print_header "Edit Linphone XML" + echo "Opening in nano editor..." + echo "Press Ctrl+X to save and exit" + echo "" + read -p "Press Enter to continue..." + + nano "$xml_file" + + print_success "Changes saved" +} + +show_provisioning_status() { + print_header "Provisioning Status" + + # Check HTTP configuration + if [[ -f /etc/asterisk/http.conf ]] && grep -q "enabled=yes" /etc/asterisk/http.conf 2>/dev/null; then + echo -e "HTTP Server: ${GREEN}Enabled${NC}" + asterisk -rx "http show status" 2>/dev/null | head -10 + else + echo -e "HTTP Server: ${RED}Disabled${NC}" + fi + + echo "" + + # Check provisioning directory + if [[ -d "$PROVISIONING_DIR" ]]; then + echo -e "Provisioning Dir: ${GREEN}$PROVISIONING_DIR${NC}" + echo "Files:" + ls -lh "$PROVISIONING_DIR" 2>/dev/null | tail -n +2 || echo " (empty)" + else + echo -e "Provisioning Dir: ${RED}Not created${NC}" + fi + + echo "" + + # Check symlink + if [[ -L /usr/share/asterisk/static-http ]]; then + echo -e "Symlink: ${GREEN}OK${NC} (/usr/share/asterisk/static-http)" + else + echo -e "Symlink: ${YELLOW}Not created${NC}" + fi + + echo "" + local server_ip=$(hostname -I | cut -d' ' -f1) + echo "Provisioning URLs:" + echo " HTTP: http://${server_ip}:8088/static/" + echo " HTTPS: https://${server_ip}:8089/static/" +} + +troubleshoot_eos_audio() { + print_header "/e/OS Audio Troubleshooting" + + cat << 'EOSHELP' +PROBLEM: No audio sent by phone unless Linphone has focus +═══════════════════════════════════════════════════════════ + +This is a known issue with /e/OS (eFoundation OS) and privacy-focused +Android ROMs. /e/OS has stricter privacy controls that prevent apps +from accessing the microphone in the background. + +SOLUTIONS (Try in order): + +1. LINPHONE APP SETTINGS (In Linphone app itself): + ──────────────────────────────────────────────────── + a) Open Linphone → ☰ Menu → Settings → Audio + b) Change "Audio Route" to "Speaker" (not Earpiece) + c) Enable "Use Speaker for calls" + d) Disable "Echo Cancellation" (test if this helps) + e) Go to Settings → Network + f) Set "Media Encryption" to "None" (or match server) + +2. /e/OS PRIVACY SETTINGS: + ──────────────────────────────────────────────────── + a) Settings → Apps → Linphone + b) Permissions → Microphone → "Allow all the time" + c) Permissions → Camera → "Don't allow" (if not using video) + d) "Remove permissions if app isn't used" → DISABLE + +3. /e/OS ADVANCED PRIVACY SETTINGS: + ──────────────────────────────────────────────────── + a) Settings → Privacy (Advanced Privacy / Privacy Central) + b) Find Linphone in the list + c) Disable "Hide my IP" for Linphone + d) Set Location to "Real" (not fake location) + e) Disable any "Manage trackers" restrictions for Linphone + +4. /e/OS NETWORK PERMISSIONS: + ──────────────────────────────────────────────────── + a) Settings → Apps → Linphone → Mobile data & Wi-Fi + b) Enable "Background data" + c) Enable "Unrestricted data usage" + d) Make sure "Allow network access" is ON + +5. /e/OS AUTOSTART: + ──────────────────────────────────────────────────── + a) Settings → Apps → Linphone → Battery + b) Battery optimization → "Don't optimize" or "Unrestricted" + c) Settings → Apps → Linphone → Advanced + d) Enable "Autostart" if available + +6. LINPHONE XML PROVISIONING (Server-side fix): + ──────────────────────────────────────────────────── + Your linphone.xml should already have these settings: + • android_pause_calls_when_audio_focus_lost=0 + • keep_service_alive=1 + • start_at_boot=1 + • audio_route_speaker=1 + + To verify, check: $PROVISIONING_DIR/linphone.xml + +7. ALTERNATIVE: USE SPEAKER MODE DURING CALL: + ──────────────────────────────────────────────────── + As a workaround, during an active call: + • Tap the speaker icon to enable speakerphone + • This often forces audio to work even in background + • Not ideal but proves the audio path works + +8. NUCLEAR OPTION - DISABLE PRIVACY FEATURES: + ──────────────────────────────────────────────────── + If nothing works, temporarily disable /e/OS privacy features: + a) Settings → Privacy → Advanced Privacy + b) Toggle OFF "Advanced Privacy" + c) Test if Linphone audio works + d) If it works, re-enable and whitelist Linphone + +9. ALTERNATIVE SIP APP: + ──────────────────────────────────────────────────── + If Linphone continues to have issues on /e/OS, try: + • Zoiper (better /e/OS compatibility) + • CSipSimple (older but reliable) + • Grandstream Wave (commercial but works well) + +TESTING: +════════ +1. Make a call with Linphone in foreground → audio works +2. Press Home button → does audio continue? +3. If audio stops, the issue is confirmed + +WHAT'S HAPPENING: +═════════════════ +/e/OS restricts background microphone access for privacy. +Even with permissions granted, the OS may suspend audio +capture when the app loses focus. The XML settings and +speaker mode help work around this limitation. + +MORE HELP: +══════════ +• /e/OS Community: https://community.e.foundation +• Linphone Forums: https://forum.linphone.org +• Issue: "Background microphone access on /e/OS" + +═══════════════════════════════════════════════════════════ +EOSHELP +} + +create_baresip_config() { + print_header "Create Baresip Setup Instructions" + load_config + + local server_ip=$(hostname -I | cut -d' ' -f1) + local domain="${DOMAIN_NAME:-$server_ip}" + + echo "Baresip Setup Guide Generator" + echo "================================================" + echo "" + echo "Use Baresip if Linphone has audio issues (screen off, etc.)" + echo "Baresip often works better on privacy-focused Android ROMs." + echo "" + echo "NOTE: Baresip does NOT support remote provisioning." + echo " Manual configuration required." + echo "" + echo "Current Configuration:" + echo " Domain: $domain" + echo " Server IP: $server_ip" + echo "" + + read -p "Enter extension number (e.g., 202): " extension + [[ -z "$extension" ]] && { print_error "Extension required"; return 1; } + + read -p "Enter SIP password: " sip_password + [[ -z "$sip_password" ]] && { print_error "Password required"; return 1; } + + read -p "Enter display name (e.g., Kitchen Phone): " display_name + display_name=${display_name:-Extension $extension} + + local config_file="$PROVISIONING_DIR/baresip-${extension}.txt" + + mkdir -p "$PROVISIONING_DIR" + + cat > "$config_file" << BARESIPEOF +═══════════════════════════════════════════════════════════ +BARESIP SETUP INSTRUCTIONS +Generated by Easy Asterisk v${SCRIPT_VERSION} +═══════════════════════════════════════════════════════════ + +IMPORTANT: Baresip does NOT support remote provisioning. +You must configure manually following these steps. + +STEP 1: INSTALL BARESIP +════════════════════════════════════════════════════════════ +• Download Baresip from F-Droid or Play Store +• Open the Baresip app + +STEP 2: ADD ACCOUNT (Initial Entry) +════════════════════════════════════════════════════════════ +1. Tap Menu (☰ hamburger icon) → Accounts +2. Tap the Add (+) button at the top +3. In "SIP URI" field, enter: BARESIPEOF + echo "${extension}@${domain}" >> "$config_file" + cat >> "$config_file" << 'BARESIPEOF' +4. Tap the Save (✓ checkmark) icon at the top + +STEP 3: EDIT ACCOUNT (Complete Configuration) +════════════════════════════════════════════════════════════ +Now go back and edit the account to add authentication: + +1. Tap Menu (☰) → Accounts +2. Tap on the account you just created +3. Fill in the following fields: + +BARESIPEOF + cat >> "$config_file" << EOF + Display Name: ${display_name} + + Authentication Username: ${extension} + (CRITICAL: Just the extension number, NOT ${extension}@${domain}) + + Authentication Password: ${sip_password} + + Outbound Proxy URI: ${domain} + (CRITICAL: Just the domain, NOT sip:${server_ip}:5060) + + Media Encryption: srtp + (Select from dropdown menu) + + Register: ✓ (Check this box) + +4. Tap Save (✓ checkmark icon) + +STEP 4: VERIFY REGISTRATION +════════════════════════════════════════════════════════════ +• Wait a few seconds for registration +• You should see: + - Green dot next to account, OR + - "Registered" status text + +If registration FAILS: + ✗ Double-check "Authentication Username" is JUST "${extension}" + ✗ Double-check "Outbound Proxy URI" is JUST "${domain}" + ✗ Verify password is correct: ${sip_password} + +STEP 5: SET CALLING AS DEFAULT (Optional) +════════════════════════════════════════════════════════════ +To make tapping a contact initiate a call (not message): + +1. Tap Menu (☰) → Settings (or Preferences) +2. Look for "Default Action" or "Contact Action" +3. If available, select: "Audio Call" or "Call" +4. Save + +NOTE: This option may not exist in all Baresip versions. + If not available, you can still call by: + - Long-pressing a contact → Select "Call" + - Or using the phone icon during selection + +STEP 6: AUDIO SETTINGS (Recommended) +════════════════════════════════════════════════════════════ +1. Tap Menu (☰) → Settings → Audio +2. Configure: + Audio Module: opensles (or audiotrack if opensles doesn't work) + Echo Cancellation: ✓ Enabled + Noise Suppression: ✓ Enabled + +STEP 7: ANDROID PERMISSIONS +════════════════════════════════════════════════════════════ +Go to your phone's: +Settings → Apps → Baresip + +Set the following: +• Permissions → Microphone: Allow while using app +• Permissions → Phone: Allow +• Battery: Unrestricted (or Not optimized) +• Mobile data & Wi-Fi → Background data: Enabled + +DIALING EXTENSIONS +════════════════════════════════════════════════════════════ +To call other extensions: + +Method 1 (Try this first): + Just dial the extension number: 101, 202, etc. + +Method 2 (If method 1 doesn't work): + Full format: 101@${domain} + +Common Extensions: +• Individual devices: 101, 102, 201, 202, etc. +• Page groups (auto-answer broadcast): 199 +• Ring groups (rings all phones): 299 + +TOP BAR ICONS IN BARESIP +════════════════════════════════════════════════════════════ +☰ = Hamburger menu (Accounts, Settings, About, etc.) +✓ = Save/Confirm current action +⋮ = Additional options (context-dependent) +📞 = Answer incoming call / Place outgoing call +🔊 = Enable speakerphone (during active call) +🔇 = Mute microphone (during active call) +✕ = Hang up / End call + +TROUBLESHOOTING +════════════════════════════════════════════════════════════ + +PROBLEM: Registration fails +SOLUTION: + • Verify "Authentication Username" is JUST: ${extension} + • Verify "Outbound Proxy URI" is JUST: ${domain} + • Check password is correct + • Check phone has network connectivity + • Check firewall allows SIP traffic + +PROBLEM: Can't dial extensions +SOLUTION: + • Verify you're registered (green dot/status) + • Try dialing full format: ${extension}@${domain} + • Check extension exists on server + +PROBLEM: No audio / Audio doesn't work +SOLUTION: + • Menu → Settings → Audio → Try different "Audio Module" + • Check microphone permissions in Android settings + • During call, try tapping speaker icon + +PROBLEM: Audio cuts when screen turns off +SOLUTION: + • Settings → Apps → Baresip → Battery → Unrestricted + • Baresip handles this much better than Linphone! + • This issue is rare with Baresip + +PROBLEM: Can't find "Default Action" setting +SOLUTION: + • Not all Baresip versions have this option + • Alternative: Long-press contact → Select "Call" + • Or tap contact then tap phone icon + +═══════════════════════════════════════════════════════════ +QUICK REFERENCE +═══════════════════════════════════════════════════════════ +Display Name: ${display_name} +SIP URI (initial): ${extension}@${domain} +Auth Username: ${extension} +Auth Password: ${sip_password} +Outbound Proxy: ${domain} +Media Encryption: srtp +Register: ✓ +═══════════════════════════════════════════════════════════ + +EOF + + chown asterisk:asterisk "$config_file" + chmod 644 "$config_file" + + print_success "Created: $config_file" + echo "" + echo "═══════════════════════════════════════════════════════════" + echo "BARESIP SETUP - Extension ${extension}" + echo "═══════════════════════════════════════════════════════════" + echo "" + echo "Download instructions:" + echo " http://${server_ip}:8088/static/baresip-${extension}.txt" + echo "" + echo "QUICK SETUP SUMMARY:" + echo "" + echo "Step 1: Add Account" + echo " Menu → Accounts → Add (+)" + echo " SIP URI: ${extension}@${domain}" + echo " Save (✓)" + echo "" + echo "Step 2: Edit Account" + echo " Tap account → Edit" + echo " Auth Username: ${extension} (JUST the number!)" + echo " Auth Password: ${sip_password}" + echo " Outbound Proxy: ${domain} (JUST the domain!)" + echo " Media Encryption: srtp (select from dropdown)" + echo " Register: ✓" + echo " Save (✓)" + echo "" + echo "Step 3: Verify" + echo " Look for green dot or 'Registered' status" + echo "" + echo "Step 4: Dial Extensions" + echo " Just dial: 101, 202, etc." + echo "" + echo "Full details in the text file above." + echo "═══════════════════════════════════════════════════════════" +} + +provisioning_manager_menu() { + while true; do + clear + print_header "Provisioning Manager" + echo " 1) Setup HTTP Server (ports 8088/8089)" + echo " 2) Create/Update linphone.xml" + echo " 3) Edit linphone.xml" + echo " 4) Create Baresip Config" + echo " 5) Show Status" + echo " 6) Open Provisioning Directory" + echo " 7) Troubleshoot /e/OS Audio Issues" + echo " 0) Back" + read -p " Select: " choice + + case $choice in + 1) setup_http_provisioning ;; + 2) create_linphone_xml ;; + 3) edit_linphone_xml ;; + 4) create_baresip_config ;; + 5) show_provisioning_status ;; + 6) + if command -v mc &>/dev/null; then + mc "$PROVISIONING_DIR" + else + print_info "Opening with ls..." + ls -lah "$PROVISIONING_DIR" + fi + ;; + 7) troubleshoot_eos_audio ;; + 0) return ;; + esac + + [[ "$choice" != "0" ]] && read -p "Press Enter..." + done +} + +# ================================================================ +# MANUAL UPDATE SYSTEM +# ================================================================ + +manual_update_asterisk() { + if is_docker; then + print_header "Update Asterisk (Docker)" + echo " In Docker, Asterisk is updated by rebuilding the container image." + echo "" + echo " Steps:" + echo " 1. docker compose down" + echo " 2. docker compose build --no-cache" + echo " 3. docker compose up -d" + echo "" + echo " Your configuration is preserved in Docker volumes." + echo " Current version:" + asterisk -V 2>/dev/null || echo " Asterisk not running" + return + fi + + print_header "Manual Asterisk Update" + echo "WARNING: This will update Asterisk from the repository." + echo "A backup will be created automatically." + echo "" + asterisk -V 2>/dev/null || echo "Asterisk not currently running" + echo "" + read -p "Continue with update? (y/n) [n]: " confirm + confirm=${confirm:-n} + + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + print_info "Update cancelled" + return + fi + + # Backup configurations + local backup_dir="/root/asterisk-backup-$(date +%Y%m%d_%H%M%S)" + mkdir -p "$backup_dir" + echo "Creating backup in $backup_dir..." + cp -r /etc/asterisk "$backup_dir/" + cp -r /var/lib/asterisk "$backup_dir/" 2>/dev/null || true + + print_success "Backup created: $backup_dir" + + # Update + echo "" + print_info "Updating Asterisk..." + apt update + apt install --only-upgrade asterisk asterisk-modules -y + + # Restart + echo "" + restart_asterisk_safe + + if asterisk_running; then + print_success "Asterisk updated successfully" + asterisk -V + echo "" + echo "Backup location: $backup_dir" + echo "" + echo "To rollback if needed:" + echo " systemctl stop asterisk" + echo " cp -r $backup_dir/asterisk/* /etc/asterisk/" + echo " systemctl start asterisk" + else + print_error "Asterisk failed to start after update!" + echo "" + echo "Rolling back..." + cp -r "$backup_dir/asterisk/"* /etc/asterisk/ + restart_asterisk_safe + print_info "Rollback complete" + fi +} + +# ================================================================ +# ROOM DIRECTORY +# ================================================================ + +show_room_directory() { + print_header "Room Directory" + load_config + + if [[ ! -f "$ROOMS_FILE" ]]; then + print_error "Rooms file not found: $ROOMS_FILE" + return + fi + + echo "Ring Groups vs Page Groups:" + echo " • Ring Groups: Rings all members until one answers" + echo " • Page Groups: Auto-answer broadcast to all members" + echo "" + echo "═══════════════════════════════════════════════════════════" + + local has_rooms=false + while IFS='|' read -r ext name members timeout type; do + # Skip comments and empty lines + [[ "$ext" =~ ^[[:space:]]*# ]] && continue + [[ -z "$ext" ]] && continue + + has_rooms=true + + # Determine icon based on type + local icon="📞" + local type_label="Ring Group" + if [[ "$type" == "page" ]]; then + icon="📢" + type_label="Page Group" + fi + + echo "" + echo "$icon Extension: $ext - $name" + echo " Type: $type_label" + echo " Members: $members" + echo " Timeout: ${timeout}s" + done < "$ROOMS_FILE" + + if [[ "$has_rooms" == "false" ]]; then + echo "" + echo "No rooms configured yet." + echo "Use 'Device Management → Manage rooms' to create rooms." + fi + + echo "" + echo "═══════════════════════════════════════════════════════════" +} + +watch_live_logs() { + print_header "Live Debugging" + echo "Enabling PJSIP Logger..." + asterisk -rx "module load res_pjsip_logger.so" 2>/dev/null || true + asterisk -rx "pjsip set logger on" 2>/dev/null + echo "" + echo "Options:" + echo " 1) Asterisk Console (verbose)" + echo " 2) Packet Capture (tcpdump)" + read -p "Select [1]: " pcap + if [[ "$pcap" == "2" ]]; then + echo "Starting tcpdump. Press CTRL+C to stop." + tcpdump -i any port 5060 or port 5061 -nn -v + else + echo "Starting Console. Press CTRL+C to exit." + asterisk -rvvv + fi + asterisk -rx "pjsip set logger off" 2>/dev/null +} + +router_doctor() { + print_header "Router Traffic Doctor" + if ! asterisk_running; then + print_error "Asterisk is NOT RUNNING" + restart_asterisk_safe + return + fi + + print_success "Asterisk is UP" + echo "" + echo "Server Listening IPs:" + ip -o -4 addr show | awk '{print " " $2 ": " $4}' + echo "" + echo "Instructions:" + echo " 1. Take out your phone/laptop" + echo " 2. Attempt to REGISTER or CALL" + echo " 3. I will listen for 15 seconds" + echo "" + read -p "Press Enter to start listening..." + + if timeout 15 tcpdump -i any -c 1 "port 5060 or port 5061" 2>/dev/null; then + echo "" + print_success "PACKET RECEIVED! Router forwarding is working." + else + echo "" + print_error "NO PACKETS RECEIVED." + echo "Your router or firewall is blocking the connection." + fi +} + +configure_local_client() { + if is_docker; then + print_error "Local client not available in Docker. Use Sipnetic, Linphone, or Baresip on your phone/tablet." + return + fi + print_header "Configure Local Client" + load_config + + # If KIOSK_USER already set from config, show and ask if want to change + if [[ -n "$KIOSK_USER" ]]; then + echo "Current configured user: $KIOSK_USER" + read -p "Change user? [y/N]: " change_user + if [[ "$change_user" =~ ^[Yy]$ ]]; then + KIOSK_USER="" + KIOSK_UID="" + fi + fi + + # If still no user, select one + if [[ -z "$KIOSK_USER" ]]; then + echo "" + echo "Select the user to configure:" + echo "" + if ! select_user; then + print_error "User selection failed" + return 1 + fi + else + # Ensure KIOSK_UID is set + KIOSK_UID=$(id -u "$KIOSK_USER" 2>/dev/null) + fi + + echo "" + + if [[ ! -d "/home/${KIOSK_USER}/.baresip" ]]; then + print_error "Baresip not installed for $KIOSK_USER" + echo "" + read -p "Install Baresip client now? [Y/n]: " install_it + if [[ ! "$install_it" =~ ^[Nn]$ ]]; then + install_baresip_packages + configure_baresip + enable_client_services + INSTALLED_CLIENT="y" + save_config + print_success "Baresip installed" + echo "" + echo "Audio configured for $KIOSK_USER" + echo "If audio doesn't work, log out and back in or reboot." + echo "" + else + return + fi + fi + + read -p "Extension: " ext + read -p "Password: " pass + read -p "Server Domain/IP: " server + + local transport_str="udp" + local media_enc="" + + if [[ "$server" =~ [a-zA-Z] ]]; then + print_info "Domain detected. Using TLS." + transport_str="tls" + media_enc=";mediaenc=srtp" + fi + + echo "" + echo "Answer Mode:" + echo " 1) Manual (ring on incoming)" + echo " 2) Auto (auto-answer)" + read -p "Select [1]: " amode + local answermode="manual" + [[ "$amode" == "2" ]] && answermode="auto" + + echo "" + echo "Enable TURN? (Required if behind NAT/VLAN without VPN)" + read -p "Use TURN server? [y/N]: " use_turn + local turn_config="" + if [[ "$use_turn" =~ ^[Yy]$ ]]; then + read -p "TURN User [${TURN_USER}]: " t_user + t_user="${t_user:-$TURN_USER}" + read -p "TURN Pass [${TURN_PASS}]: " t_pass + t_pass="${t_pass:-$TURN_PASS}" + local turn_host="${server}" + if [[ ! "$turn_host" =~ [a-zA-Z] ]]; then + # If server is IP, ask if TURN host is different + read -p "TURN Host [${server}]: " th + turn_host="${th:-$server}" + fi + read -p "TURN Port [3478]: " t_port + t_port="${t_port:-3478}" + turn_config="turn_server turn:${t_user}:${t_pass}@${turn_host}:${t_port}" + fi + + # Update config file for TURN + local conf_file="/home/${KIOSK_USER}/.baresip/config" + if [[ -f "$conf_file" ]]; then + sed -i '/^turn_server/d' "$conf_file" + if [[ -n "$turn_config" ]]; then + echo "$turn_config" >> "$conf_file" + print_success "TURN configuration added" + fi + fi + + cat > "/home/${KIOSK_USER}/.baresip/accounts" << EOF +;auth_pass=${pass};answermode=${answermode}${media_enc} +EOF + chown ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.baresip/accounts" + chown ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.baresip/config" + + # Update main config + ASTERISK_HOST="$server" + KIOSK_EXTENSION="$ext" + CLIENT_ANSWERMODE="$answermode" + save_config + + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + + # Reload systemd daemon in case services changed + sudo -u "${KIOSK_USER}" $user_dbus systemctl --user daemon-reload 2>/dev/null + + # Restart audio and client services + print_info "Restarting services..." + sudo -u "${KIOSK_USER}" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true + sleep 2 + sudo -u "${KIOSK_USER}" $user_dbus systemctl --user restart baresip 2>/dev/null + + # Ensure audio is unmuted if not in PTT mode + if [[ ! -f /etc/easy-asterisk/ptt-device ]]; then + sleep 1 + ensure_audio_unmuted + fi + + print_success "Client Reconfigured & Services Restarted" + echo "" + echo "Run Diagnostics to verify connection status." +} + +run_client_diagnostics() { + if is_docker; then + print_error "Client diagnostics not available in Docker. Run vpn-diagnostics for server-side checks." + return + fi + print_header "Client Diagnostics" + load_config + local t_user="${KIOSK_USER:-$SUDO_USER}" + t_user="${t_user:-$USER}" + local t_uid=$(id -u "$t_user" 2>/dev/null) + + echo -e "User: ${BOLD}$t_user${NC}" + echo "---------------------------------------------------" + if sudo -u "$t_user" XDG_RUNTIME_DIR=/run/user/$t_uid systemctl --user is-active baresip >/dev/null 2>&1; then + print_success "Baresip RUNNING" + else + print_error "Baresip STOPPED/FAILED" + fi + echo "---------------------------------------------------" + + echo "Audio Services:" + local user_dbus="XDG_RUNTIME_DIR=/run/user/$t_uid" + if sudo -u "$t_user" $user_dbus systemctl --user is-active pipewire >/dev/null 2>&1; then + print_success "PipeWire RUNNING" + else + print_error "PipeWire STOPPED" + fi + if sudo -u "$t_user" $user_dbus systemctl --user is-active pipewire-pulse >/dev/null 2>&1; then + print_success "PipeWire-Pulse RUNNING" + else + print_error "PipeWire-Pulse STOPPED" + fi + + echo "" + echo "Audio Status:" + local src_mute=$(sudo -u "$t_user" $user_dbus pactl get-source-mute @DEFAULT_SOURCE@ 2>/dev/null | awk '{print $2}') + local sink_mute=$(sudo -u "$t_user" $user_dbus pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | awk '{print $2}') + local src_vol=$(sudo -u "$t_user" $user_dbus pactl get-source-volume @DEFAULT_SOURCE@ 2>/dev/null | grep -oP '\d+%' | head -1) + local sink_vol=$(sudo -u "$t_user" $user_dbus pactl get-sink-volume @DEFAULT_SINK@ 2>/dev/null | grep -oP '\d+%' | head -1) + + echo " Microphone: ${src_mute:-unknown} (Volume: ${src_vol:-unknown})" + echo " Speaker: ${sink_mute:-unknown} (Volume: ${sink_vol:-unknown})" + + if [[ "$src_mute" == "yes" ]]; then + echo "" + print_error "MICROPHONE IS MUTED - No audio will be sent!" + echo " To fix: pactl set-source-mute @DEFAULT_SOURCE@ 0" + fi + + echo "" + echo "PTT Configuration:" + if [[ -f /etc/easy-asterisk/ptt-device ]]; then + echo " PTT Mode: ENABLED" + source /etc/easy-asterisk/ptt-device 2>/dev/null + echo " Device: ${PTT_DEVICE:-not set}" + else + echo " PTT Mode: DISABLED (normal intercom mode)" + fi + + echo "---------------------------------------------------" + + echo "Network Interface:" + grep "^net_interface" "/home/$t_user/.baresip/config" 2>/dev/null || echo " Not set" + + echo "---------------------------------------------------" + echo "Account Config:" + cat "/home/$t_user/.baresip/accounts" 2>/dev/null | sed 's/auth_pass=[^;]*/auth_pass=***/' || echo " Not found" + + echo "---------------------------------------------------" + if [[ -n "$ASTERISK_HOST" ]]; then + echo -n "Server ($ASTERISK_HOST): " + if ping -c 1 -W 2 "$ASTERISK_HOST" >/dev/null 2>&1; then + print_success "Reachable" + else + print_error "Unreachable" + fi + fi + echo "---------------------------------------------------" + echo "System Logs (launcher):" + journalctl -t baresip-launcher -n 10 --no-pager 2>/dev/null | tail -10 || echo " No launcher logs" + echo "" + echo "System Logs (PTT):" + journalctl -t kiosk-ptt -n 5 --no-pager 2>/dev/null | tail -5 || echo " No PTT logs" + echo "" + echo "Baresip Service Log:" + sudo -u "$t_user" journalctl --user -u baresip -n 10 --no-pager 2>/dev/null || echo " No logs" + echo "---------------------------------------------------" + echo "" + echo "To see live logs, run:" + echo " journalctl -t baresip-launcher -f # Launcher logs" + echo " journalctl -t kiosk-ptt -f # PTT logs" + echo " sudo -u $t_user journalctl --user -u baresip -f # Baresip logs" + echo "---------------------------------------------------" +} + +run_audio_test() { + print_header "Audio Test" + echo "Playing test tone..." + speaker-test -t sine -f 440 -c 2 -l 1 >/dev/null 2>&1 + echo "" + read -p "Did you hear audio? [y/N]: " res + if [[ "$res" =~ ^[Yy]$ ]]; then + print_success "Audio OK" + else + print_error "Check volume/connections" + fi +} + +verify_audio_setup() { + print_header "Audio Verification" + echo "=== Codecs ===" + asterisk -rx "core show codecs" 2>/dev/null | grep -E "(opus|ulaw|alaw|g722)" || echo " N/A" + echo "" + echo "=== PJSIP Modules ===" + asterisk -rx "module show like pjsip" 2>/dev/null | head -10 || echo " N/A" + echo "" + echo "=== Certificate ===" + if [[ -f /etc/asterisk/certs/server.crt ]]; then + openssl x509 -in /etc/asterisk/certs/server.crt -noout -subject -dates 2>/dev/null + else + echo " None" + fi +} + +# ================================================================ +# 7. ASTERISK CONFIG +# ================================================================ + +fix_asterisk_systemd() { + if is_docker; then + # No systemd in Docker - Asterisk runs as the main container process + return + fi + print_info "Configuring systemd..." + mkdir -p /etc/systemd/system/asterisk.service.d/ + cat > /etc/systemd/system/asterisk.service.d/override.conf << 'SVCEOF' +[Unit] +Wants=network-online.target +After=network-online.target + +[Service] +ExecStart= +ExecStart=/usr/sbin/asterisk -f -U asterisk -G asterisk +RuntimeDirectory=asterisk +RuntimeDirectoryMode=0750 +MemoryMax=infinity +TasksMax=infinity +KillMode=mixed +KillSignal=SIGTERM +TimeoutStartSec=60 +TimeoutStopSec=30 +SendSIGKILL=no +Restart=always +RestartSec=10 +Type=simple +SVCEOF + systemctl daemon-reload +} + +recover_xml_docs() { + mkdir -p /var/lib/asterisk/documentation/thirdparty + chown -R asterisk:asterisk /var/lib/asterisk/documentation 2>/dev/null +} + +repair_core_configs() { + print_info "Repairing configs..." + + # Copy modules (not symlink - AppArmor blocks symlinks) + if [[ -d "/usr/lib/x86_64-linux-gnu/asterisk/modules" ]]; then + mkdir -p /usr/lib/asterisk/modules + cp -rn /usr/lib/x86_64-linux-gnu/asterisk/modules/* /usr/lib/asterisk/modules/ 2>/dev/null || true + fi + + mkdir -p /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + recover_xml_docs + + if [[ ! -f /etc/asterisk/asterisk.conf ]]; then + cat > /etc/asterisk/asterisk.conf << EOF +[directories] +astetcdir => /etc/asterisk +astmoddir => /usr/lib/asterisk/modules +astvarlibdir => /var/lib/asterisk +astdbdir => /var/lib/asterisk +astkeydir => /var/lib/asterisk +astdatadir => /var/lib/asterisk +astagidir => /var/lib/asterisk/agi-bin +astspooldir => /var/spool/asterisk +astrundir => /var/run/asterisk +astlogdir => /var/log/asterisk +EOF + fi + + cat > /etc/asterisk/modules.conf << EOF +[modules] +autoload=yes +noload => chan_sip.so +noload => chan_iax2.so +load => res_pjsip.so +load => res_pjsip_session.so +load => res_pjsip_logger.so +load => chan_pjsip.so +load => codec_ulaw.so +load => codec_alaw.so +load => codec_g722.so +load => codec_opus.so +load => res_rtp_asterisk.so +load => app_dial.so +load => app_page.so +load => pbx_config.so +EOF + + # Disable optional modules (NOT stasis - required in Asterisk 20.x) + for conf in ari http manager geolocation; do + cat > "/etc/asterisk/${conf}.conf" << EOF +[general] +enabled = no +EOF + done + + # Configure Stasis properly (required core module) + cat > /etc/asterisk/stasis.conf << EOF +[general] +; Stasis is required for Asterisk 20.x core functionality +EOF + + if [[ ! -f /etc/asterisk/sorcery.conf ]]; then + cat > /etc/asterisk/sorcery.conf << EOF +[res_pjsip] +endpoint=config,pjsip.conf,criteria=type=endpoint +auth=config,pjsip.conf,criteria=type=auth +aor=config,pjsip.conf,criteria=type=aor +transport=config,pjsip.conf,criteria=type=transport +EOF + fi + + # ICE configuration + # ICE is enabled so Asterisk participates in ICE negotiation with clients. + # stunaddr/turnaddr are NOT set because: + # - Asterisk knows its public IP via external_media_address in pjsip.conf + # - Its RTP ports are port-forwarded, so host candidates are sufficient + # - Setting stunaddr/turnaddr causes STUN/TURN gather timeouts (~27s delay) + # coturn (if running) is for SIP clients behind strict NAT — they configure + # TURN in their own app settings, independently of Asterisk's rtp.conf. + load_config + local ice_config="" + if [[ -n "$DOMAIN_NAME" ]] || [[ "$VPN_ICE_ENABLED" == "y" ]] || [[ "$TURN_ENABLED" == "y" ]]; then + ice_config="icesupport=yes" + else + ice_config="# icesupport disabled - LAN only mode" + fi + + cat > /etc/asterisk/rtp.conf << EOF +[general] +rtpstart=${RTP_START:-10000} +rtpend=${RTP_END:-20000} +strictrtp=yes +${ice_config} +EOF + + cat > /etc/asterisk/logger.conf << EOF +[general] +[logfiles] +console => notice,warning,error +EOF + + rm -f /var/lib/asterisk/.asterisk_history + chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk 2>/dev/null || true + chown -R asterisk:asterisk /usr/lib/asterisk/modules 2>/dev/null || true +} + +generate_pjsip_conf() { + print_info "Generating PJSIP..." + load_config + local conf_file="/etc/asterisk/pjsip.conf" + backup_config "$conf_file" + + # Prioritize CURRENT_PUBLIC_IP from coturn/updater if available, else detect + local public_ip="${CURRENT_PUBLIC_IP}" + if [[ -z "$public_ip" ]]; then + public_ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || echo "") + fi + + # Get server IP for transport binding info + local server_ip=$(hostname -I | cut -d' ' -f1) + + local raw_cidr=$(ip -o -f inet addr show | awk '/scope global/ {print $4}' | head -1) + local default_cidr="$raw_cidr" + if [[ "$raw_cidr" =~ \.([0-9]+)/24$ ]]; then default_cidr="${raw_cidr%.*}.0/24"; fi + + # Use stored CIDR if available + local local_net="${LOCAL_CIDR:-$default_cidr}" + + # Build local_net entries (main network + VLANs) + local all_local_nets="local_net=$local_net" + if [[ "$HAS_VLANS" == "y" && -n "$VLAN_SUBNETS" ]]; then + for vlan_subnet in $VLAN_SUBNETS; do + all_local_nets="${all_local_nets} +local_net=${vlan_subnet}" + done + print_info "VLAN subnets configured: $VLAN_SUBNETS" + fi + + local nat_settings="" + if [[ -n "$public_ip" && -n "$DOMAIN_NAME" ]]; then + # FQDN mode: full NAT settings with external addresses + nat_settings="external_media_address=$public_ip +external_signaling_address=$public_ip +${all_local_nets}" + print_info "NAT: Public IP=$public_ip, Server IP=$server_ip" + elif [[ "$HAS_VLANS" == "y" && -n "$VLAN_SUBNETS" ]]; then + # LAN/VPN mode with VLAN/VPN subnets: include local_net entries + # so Asterisk recognizes VPN traffic as local (prevents VPN devices + # appearing offline and fixes media routing for VPN-connected mobiles) + nat_settings="${all_local_nets}" + print_info "LAN mode with additional subnets: $VLAN_SUBNETS" + fi + + cat > "$conf_file" << EOF +; Easy Asterisk v${SCRIPT_VERSION} +[global] +type=global +user_agent=EasyAsterisk + +[transport-udp] +type=transport +protocol=udp +bind=0.0.0.0:${DEFAULT_SIP_PORT} +; Server IP: ${server_ip} +${nat_settings} + +[transport-tcp] +type=transport +protocol=tcp +bind=0.0.0.0:${DEFAULT_SIP_PORT} +; Server IP: ${server_ip} +${nat_settings} + +[transport-tls] +type=transport +protocol=tls +bind=0.0.0.0:${DEFAULT_SIPS_PORT} +; Server IP: ${server_ip} +cert_file=/etc/asterisk/certs/server.crt +priv_key_file=/etc/asterisk/certs/server.key +ca_list_file=/etc/ssl/certs/ca-certificates.crt +method=tlsv1_2 +${nat_settings} + +EOF + + local backup_file=$(ls -t "${conf_file}.backup-"* 2>/dev/null | head -1) + if [[ -f "$backup_file" ]]; then + awk '/^; === Device:/{flag=1} flag' "$backup_file" >> "$conf_file" + print_success "Restored devices from backup" + fi + chown asterisk:asterisk "$conf_file" +} + +rebuild_dialplan() { + local quiet=$1 + [[ "$quiet" != "quiet" ]] && print_info "Rebuilding dialplan..." + local conf_file="/etc/asterisk/extensions.conf" + backup_config "$conf_file" + + cat > "$conf_file" << EOF +[general] +static=yes +writeprotect=no +[default] +exten => _X.,1,Hangup() +[intercom] +EOF + + local dev_name="" dev_cat="" dev_auto="" dev_aa_override="" + local -A device_extensions=() + while IFS= read -r line; do + if [[ "$line" == *"; === Device:"* ]]; then + dev_aa_override="" + local temp="${line#*; === Device: }" + temp="${temp% ===}" + if [[ "$temp" == *"[AA:yes]"* ]]; then + dev_aa_override="yes"; temp="${temp% [AA:yes]}" + elif [[ "$temp" == *"[AA:no]"* ]]; then + dev_aa_override="no"; temp="${temp% [AA:no]}" + fi + dev_cat="${temp##* (}"; dev_cat="${dev_cat%)}" + dev_name="${temp% (*)}" + dev_auto="no" + local cat_data=$(grep "^${dev_cat}|" "$CATEGORIES_FILE" 2>/dev/null || true) + if [[ -n "$cat_data" ]]; then + local is_auto=$(echo "$cat_data" | cut -d'|' -f3) + [[ "$is_auto" == "yes" ]] && dev_auto="yes" + fi + [[ "$dev_aa_override" == "yes" ]] && dev_auto="yes" + [[ "$dev_aa_override" == "no" ]] && dev_auto="no" + fi + if [[ "$line" =~ ^\[([0-9]+)\] ]]; then + local ext="${BASH_REMATCH[1]}" + if [[ -n "$dev_name" ]]; then + device_extensions[$ext]=1 + if [[ "$dev_auto" == "yes" ]]; then + cat >> "$conf_file" << EOF +exten => ${ext},1,NoOp(Auto-Answer ${ext}) + same => n,Set(PJSIP_HEADER(add,Call-Info)=\;answer-after=0) + same => n,Set(PJSIP_HEADER(add,Alert-Info)=auto-answer) + same => n,Dial(PJSIP/${ext},60) + same => n,Hangup() + +EOF + else + cat >> "$conf_file" << EOF +exten => ${ext},1,NoOp(Call ${ext}) + same => n,Dial(PJSIP/${ext},60) + same => n,Hangup() + +EOF + fi + dev_name="" + fi + fi + done < /etc/asterisk/pjsip.conf + + # Add rooms (skip if extension already used by a device) + if [[ -f "$ROOMS_FILE" ]]; then + while IFS='|' read -r rext rname rmem rtime rtype; do + [[ "$rext" =~ ^# ]] && continue + [[ -z "$rext" ]] && continue + if [[ -n "${device_extensions[$rext]:-}" ]]; then + [[ "$quiet" != "quiet" ]] && print_warn "Room '$rname' ext $rext conflicts with device — skipping" + continue + fi + local dial_list="" + IFS=',' read -ra EXTS <<< "$rmem" + for ext in "${EXTS[@]}"; do + ext=$(echo "$ext" | tr -d ' ') + [[ -n "$dial_list" ]] && dial_list="${dial_list}&" + dial_list="${dial_list}PJSIP/${ext}" + done + if [[ "$rtype" == "page" ]]; then + cat >> "$conf_file" << EOF +; Room: ${rname} (Page) +exten => ${rext},1,NoOp(Page ${rname}) + same => n,Set(PJSIP_HEADER(add,Call-Info)=\;answer-after=0) + same => n,Page(${dial_list},i,${rtime}) + same => n,Hangup() + +EOF + else + cat >> "$conf_file" << EOF +; Room: ${rname} (Ring) +exten => ${rext},1,NoOp(Call ${rname}) + same => n,Dial(${dial_list},${rtime}) + same => n,Hangup() + +EOF + fi + done < "$ROOMS_FILE" + fi + + chown -R asterisk:asterisk /etc/asterisk + asterisk -rx "dialplan reload" &>/dev/null || true +} + +configure_asterisk() { + if ! id asterisk >/dev/null 2>&1; then + useradd -r -s /bin/false -d /var/lib/asterisk asterisk 2>/dev/null || true + fi + + print_info "Configuring Asterisk..." + fix_asterisk_systemd + initialize_default_categories + repair_core_configs + + mkdir -p /etc/asterisk/certs + if [[ ! -f /etc/asterisk/certs/server.crt ]]; then + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/asterisk/certs/server.key \ + -out /etc/asterisk/certs/server.crt \ + -subj "/CN=asterisk-local" 2>/dev/null + fi + + chown asterisk:asterisk /etc/asterisk/certs/server.* 2>/dev/null || true + chmod 644 /etc/asterisk/certs/server.crt 2>/dev/null || true + chmod 600 /etc/asterisk/certs/server.key 2>/dev/null || true + + generate_pjsip_conf + rebuild_dialplan "quiet" + + restart_asterisk_safe + if ! is_docker; then + systemctl enable asterisk + fi +} + +# ================================================================ +# 8. CLIENT CONFIG +# ================================================================ + +configure_baresip() { + if is_docker; then return; fi + local baresip_dir="/home/${KIOSK_USER}/.baresip" + mkdir -p "$baresip_dir" + + # Detect network interface + local found_iface="" + for target in 8.8.8.8 1.1.1.1 9.9.9.9; do + local iface=$(ip route get "$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -1) + if [[ -n "$iface" ]]; then + found_iface="$iface" + print_success "Network interface: $found_iface" + break + fi + done + + cat > "${baresip_dir}/config" << EOF +poll_method epoll +audio_player pulse +audio_source pulse +audio_alert pulse +sip_autoanswer yes +sip_cafile /etc/ssl/certs/ca-certificates.crt +rtp_timeout 0 +net_af ipv4 +module_path /usr/lib/baresip/modules +module srtp.so +module stdio.so +module pulse.so +module g711.so +module opus.so +module account.so +module stun.so +module ice.so +module turn.so +EOF + + [[ -n "$found_iface" ]] && echo "net_interface $found_iface" >> "${baresip_dir}/config" + + local transport="udp" + local mediaenc="" + if [[ "$ENABLE_TLS" == "y" ]]; then + transport="tls" + mediaenc=";mediaenc=srtp" + fi + + local amode="${CLIENT_ANSWERMODE:-auto}" + + cat > "${baresip_dir}/accounts" << EOF +;auth_pass=${SIP_PASSWORD};answermode=${amode}${mediaenc} +EOF + chown -R ${KIOSK_USER}:${KIOSK_USER} "$baresip_dir" + chmod 700 "$baresip_dir" + + configure_audio_ducking + create_ptt_handler + create_baresip_launcher +} + +create_baresip_launcher() { + local launcher_user="${KIOSK_USER}" + cat > /usr/local/bin/easy-asterisk-launcher << LAUNCHER +#!/bin/bash +CONFIG_FILE="/home/${launcher_user}/.baresip/config" +ACCOUNTS_FILE="/home/${launcher_user}/.baresip/accounts" +TARGETS=("8.8.8.8" "1.1.1.1" "9.9.9.9") +FOUND_IFACE="" + +logger -t baresip-launcher "Starting Baresip launcher for user ${launcher_user}" + +# Wait for network +for i in {1..6}; do + for target in "\${TARGETS[@]}"; do + IFACE=\$(ip route get "\$target" 2>/dev/null | awk '{for(i=1;i<=NF;i++) if(\$i=="dev") print \$(i+1)}' | head -1) + if [[ -n "\$IFACE" ]]; then + FOUND_IFACE="\$IFACE" + logger -t baresip-launcher "Network found on interface: \$IFACE" + break 2 + fi + done + logger -t baresip-launcher "Waiting for network... (attempt \$i/6)" + sleep 5 +done + +if [[ -z "\$FOUND_IFACE" ]]; then + logger -t baresip-launcher "ERROR: No network interface found after 30 seconds" +fi + +# Update network interface in config +if [[ -f "\$CONFIG_FILE" && -n "\$FOUND_IFACE" ]]; then + sed -i '/^#*net_interface/d' "\$CONFIG_FILE" + echo "net_interface \${FOUND_IFACE}" >> "\$CONFIG_FILE" + logger -t baresip-launcher "Updated config with interface: \$FOUND_IFACE" +fi + +# Verify config files exist +if [[ ! -f "\$CONFIG_FILE" ]]; then + logger -t baresip-launcher "ERROR: Config file not found: \$CONFIG_FILE" + exit 1 +fi + +if [[ ! -f "\$ACCOUNTS_FILE" ]]; then + logger -t baresip-launcher "ERROR: Accounts file not found: \$ACCOUNTS_FILE" + exit 1 +fi + +logger -t baresip-launcher "Starting Baresip client..." +exec /usr/bin/baresip -f "/home/${launcher_user}/.baresip" +LAUNCHER + chmod +x /usr/local/bin/easy-asterisk-launcher +} + +enable_client_services() { + if is_docker; then + # No local audio client in Docker containers + return + fi + local systemd_dir="/home/${KIOSK_USER}/.config/systemd/user" + mkdir -p "$systemd_dir" + + # Ensure audio group membership + if ! id -nG "$KIOSK_USER" | grep -qw "audio"; then + usermod -aG audio "$KIOSK_USER" + fi + + # Ensure input group membership (for PTT device access) + if ! id -nG "$KIOSK_USER" | grep -qw "input"; then + usermod -aG input "$KIOSK_USER" + fi + + # Baresip service + cat > "${systemd_dir}/baresip.service" << EOF +[Unit] +Description=Baresip SIP Client +After=pipewire.service pipewire-pulse.service network-online.target +Wants=network-online.target pipewire.service pipewire-pulse.service +Requires=pipewire-pulse.service + +[Service] +Type=simple +ExecStartPre=/bin/sleep 5 +ExecStart=/usr/local/bin/easy-asterisk-launcher +Restart=always +RestartSec=10 +Environment=XDG_RUNTIME_DIR=/run/user/${KIOSK_UID} +Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${KIOSK_UID}/bus +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target +EOF + + # PTT service - only create if PTT is configured + cat > "${systemd_dir}/kiosk-ptt.service" << EOF +[Unit] +Description=PTT Button Handler +After=pipewire.service pipewire-pulse.service baresip.service +Requires=pipewire-pulse.service +ConditionPathExists=/etc/easy-asterisk/ptt-device + +[Service] +Type=simple +ExecStartPre=/bin/sleep 8 +ExecStart=/usr/local/bin/kiosk-ptt +Restart=always +RestartSec=10 +Environment=XDG_RUNTIME_DIR=/run/user/${KIOSK_UID} +Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/${KIOSK_UID}/bus +Environment=KIOSK_UID=${KIOSK_UID} +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=default.target +EOF + + chown -R ${KIOSK_USER}:${KIOSK_USER} "/home/${KIOSK_USER}/.config" + + if [[ -n "$KIOSK_USER" ]]; then + loginctl enable-linger $KIOSK_USER 2>/dev/null || true + local user_dbus="XDG_RUNTIME_DIR=/run/user/${KIOSK_UID}" + + # Enable and start PipeWire services for the user + sudo -u "$KIOSK_USER" $user_dbus systemctl --user daemon-reload + sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable pipewire pipewire-pulse 2>/dev/null || true + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true + + # Enable baresip + sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable baresip + + # Only enable PTT if configured + if [[ -f /etc/easy-asterisk/ptt-device ]]; then + sudo -u "$KIOSK_USER" $user_dbus systemctl --user enable kiosk-ptt + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart baresip kiosk-ptt + else + sudo -u "$KIOSK_USER" $user_dbus systemctl --user restart baresip + # Ensure audio is unmuted for normal kiosk operation + ensure_audio_unmuted + fi + fi +} + +# ================================================================ +# 9. CERTIFICATE HANDLING +# ================================================================ + +check_cert_coverage() { + local cert_file=$1 target_domain=$2 base_domain=$3 + [[ ! -f "$cert_file" ]] && return 1 + local sans=$(openssl x509 -in "$cert_file" -text -noout 2>/dev/null | grep -A1 "Subject Alternative Name" | tail -1) + echo "$sans" | grep -q "DNS:${target_domain}" && return 0 + echo "$sans" | grep -q "DNS:\*.${base_domain}" && return 0 + return 1 +} + +setup_caddy_cert_sync() { + local mode=$1 + [[ "$mode" == "force" ]] && print_header "Caddy Cert Sync" + + load_config + local domain=${DOMAIN_NAME:-sip.example.com} + if [[ "$mode" == "force" ]]; then + read -p "Domain [$domain]: " input_domain + domain="${input_domain:-$domain}" + fi + + local actual_user="${SUDO_USER:-$USER}" + local actual_home=$(eval echo ~"$actual_user") + local base_domain=$(echo "$domain" | awk -F. '{print $(NF-1)"."$NF}') + + local search_paths=( + "${actual_home}/docker/caddy/ssl" + "${actual_home}/docker/caddy/caddy_data" + "${actual_home}/docker/caddy/caddy_data/caddy/certificates/acme-v02.api.letsencrypt.org-directory" + "/var/lib/caddy" + "/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory" + "/data/caddy" + "/root/.local/share/caddy/certificates" + ) + local caddy_cert="" caddy_key="" + + [[ "$mode" == "force" ]] && echo "Searching for certificates..." + + for base_path in "${search_paths[@]}"; do + if ! sudo test -d "$base_path" 2>/dev/null; then continue; fi + [[ "$mode" == "force" ]] && echo " Checking: $base_path" + + local candidates=$(sudo find "$base_path" -maxdepth 5 -type f \( -name "fullchain.pem" -o -name "*.crt" \) 2>/dev/null) + + for cert in $candidates; do + sudo cp "$cert" /tmp/cert_check.pem 2>/dev/null || continue + if check_cert_coverage "/tmp/cert_check.pem" "$domain" "$base_domain"; then + [[ "$mode" == "force" ]] && print_success "Found matching cert: $cert" + caddy_cert="$cert" + local dir=$(dirname "$cert") + local name=$(basename "$cert") + if [[ "$name" == "fullchain.pem" ]]; then + caddy_key="${dir}/privkey.pem" + else + caddy_key=$(echo "$cert" | sed 's/\.crt/\.key/') + fi + if sudo test -f "$caddy_key"; then + rm -f /tmp/cert_check.pem + break 2 + fi + fi + rm -f /tmp/cert_check.pem + done + done + + if [[ -n "$caddy_cert" && -n "$caddy_key" ]]; then + mkdir -p /etc/asterisk/certs + sudo cat "$caddy_cert" > /etc/asterisk/certs/server.crt + sudo cat "$caddy_key" > /etc/asterisk/certs/server.key + + chown asterisk:asterisk /etc/asterisk/certs/server.* + chmod 644 /etc/asterisk/certs/server.crt + chmod 600 /etc/asterisk/certs/server.key + + DOMAIN_NAME="$domain" + ENABLE_TLS="y" + ASTERISK_HOST="$domain" + save_config + + generate_pjsip_conf + restart_asterisk_safe + + [[ "$mode" == "force" ]] && print_success "Certificates installed for $domain" + return 0 + else + [[ "$mode" == "force" ]] && print_warn "No matching certificates found" + return 1 + fi +} + +setup_internet_access() { + print_header "Setup Internet Access" + + echo "Select Certificate Source:" + echo " 1) Auto-Sync from Caddy (Docker/Native)" + echo " 2) Standalone Certbot (Requires Port 80 open)" + echo " 3) Self-Signed (Internal testing only)" + echo " 4) Manual Path" + echo " 0) Cancel" + read -p "Select: " cert_opt + + [[ "$cert_opt" == "0" ]] && return + + # Show port requirements + show_preflight_check + show_port_requirements + echo "" + read -p "Continue? [Y/n]: " cont + [[ "$cont" =~ ^[Nn]$ ]] && return + + load_config + read -p "FQDN [${DOMAIN_NAME:-sip.example.com}]: " fqdn + DOMAIN_NAME="${fqdn:-${DOMAIN_NAME:-sip.example.com}}" + ASTERISK_HOST="$DOMAIN_NAME" + + echo "" + echo "Do you have a separate domain for TURN? (e.g., turn.example.com)" + read -p "Enter TURN domain (leave empty to use $DOMAIN_NAME): " t_dom + TURN_DOMAIN="${t_dom:-$DOMAIN_NAME}" + + # CIDR Prompt + echo "" + print_header "Local Network CIDR" + local raw_cidr=$(ip -o -f inet addr show | awk '/scope global/ {print $4}' | head -1) + local default_cidr="$raw_cidr" + if [[ "$raw_cidr" =~ \.([0-9]+)/24$ ]]; then default_cidr="${raw_cidr%.*}.0/24"; fi + echo "This helps Asterisk distinguish local vs external traffic." + read -p "Local network CIDR [$default_cidr]: " local_net + LOCAL_CIDR="${local_net:-$default_cidr}" + + save_config + + case "$cert_opt" in + 1) # Caddy + # Show Caddy Helper text + echo "---------------------------------------------------------" + echo "CADDY HELPER: Ensure these are in your Caddyfile to get certs:" + echo "" + echo "${DOMAIN_NAME} {" + echo " respond \"Asterisk Cert Placeholder\" 200" + echo "}" + if [[ "$TURN_DOMAIN" != "$DOMAIN_NAME" ]]; then + echo "" + echo "${TURN_DOMAIN} {" + echo " respond \"TURN Cert Placeholder\" 200" + echo "}" + fi + echo "" + echo "Restart Caddy, wait 30s, then press Enter." + echo "---------------------------------------------------------" + read -p "Press Enter to sync..." + if setup_caddy_cert_sync "auto"; then + print_success "Setup complete using Caddy certificates!" + else + print_error "Caddy sync failed. Ensure Caddy is running." + return + fi + ;; + 2) # Certbot + print_info "Installing Certbot..." + apt install -y certbot + certbot certonly --standalone -d "$DOMAIN_NAME" --non-interactive --agree-tos --register-unsafely-without-email + if [[ -f "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" ]]; then + mkdir -p /etc/asterisk/certs + cat "/etc/letsencrypt/live/$DOMAIN_NAME/fullchain.pem" > /etc/asterisk/certs/server.crt + cat "/etc/letsencrypt/live/$DOMAIN_NAME/privkey.pem" > /etc/asterisk/certs/server.key + chown asterisk:asterisk /etc/asterisk/certs/server.* + print_success "Certbot Success" + else + print_error "Certbot failed" + return + fi + ;; + 3) # Self-Signed + mkdir -p /etc/asterisk/certs + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout /etc/asterisk/certs/server.key \ + -out /etc/asterisk/certs/server.crt \ + -subj "/CN=$DOMAIN_NAME" 2>/dev/null + chown asterisk:asterisk /etc/asterisk/certs/server.* + chmod 644 /etc/asterisk/certs/server.crt + chmod 600 /etc/asterisk/certs/server.key + print_success "Self-signed certificate generated" + print_warn "Clients will need to trust this certificate" + ;; + 4) # Manual + read -p "Certificate Path: " cp + read -p "Private Key Path: " kp + if [[ -f "$cp" && -f "$kp" ]]; then + mkdir -p /etc/asterisk/certs + cat "$cp" > /etc/asterisk/certs/server.crt + cat "$kp" > /etc/asterisk/certs/server.key + chown asterisk:asterisk /etc/asterisk/certs/server.* + print_success "Certificates installed" + else + print_error "Files not found!" + return + fi + ;; + esac + + ENABLE_TLS="y" + save_config + generate_pjsip_conf + restart_asterisk_safe + print_success "Internet access configuration complete" +} + +# ================================================================ +# 10. INSTALLATION +# ================================================================ + +install_full() { + if is_docker; then + # In Docker: server is pre-installed, just configure + print_header "Server Configuration" + install_asterisk_packages + configure_asterisk + INSTALLED_SERVER="y" + ENABLE_TLS="n" + save_config + print_success "Server configured" + return + fi + + print_header "Full Installation" + local default_user="${SUDO_USER:-$USER}" + read -p "Client User [$default_user]: " target_user + KIOSK_USER="${target_user:-$default_user}" + KIOSK_UID=$(id -u "$KIOSK_USER") + + if ! collect_common_config; then return; fi + collect_client_config + install_dependencies + INSTALLED_SERVER="y" + INSTALLED_CLIENT="y" + ENABLE_TLS="n" # LAN-only by default, set to "y" only if internet/certs setup is run + configure_asterisk + configure_baresip + enable_client_services + open_firewall_ports + save_config + + echo "" + echo "════════════════════════════════════════════════════════" + print_success "Local network install complete" + echo "" + echo "Server and devices are reachable over internal LAN network only." + echo "To add internet calling capability, continue with the setup below." + echo "════════════════════════════════════════════════════════" + echo "" + read -p "Run Internet/Certificate Setup wizard now? [Y/n]: " run_setup + [[ ! "$run_setup" =~ ^[Nn]$ ]] && setup_internet_access + + print_success "Installation complete" +} + +install_server_only() { + if is_docker; then + install_full + return + fi + + print_header "Server Installation" + ASTERISK_HOST="127.0.0.1" + ENABLE_TLS="n" # LAN-only by default, set to "y" only if internet/certs setup is run + install_asterisk_packages + configure_asterisk + open_firewall_ports + INSTALLED_SERVER="y" + save_config + + echo "" + echo "════════════════════════════════════════════════════════" + print_success "Local network install complete" + echo "" + echo "Server and devices are reachable over internal LAN network only." + echo "To add internet calling capability, continue with the setup below." + echo "════════════════════════════════════════════════════════" + echo "" + read -p "Run Internet/Certificate Setup wizard now? [Y/n]: " run_setup + [[ ! "$run_setup" =~ ^[Nn]$ ]] && setup_internet_access + + print_success "Server installed" +} + +install_client_only() { + print_header "Client Installation" + echo "Select the user to install the kiosk client for:" + echo "" + + if ! select_user; then + print_error "User selection failed" + return 1 + fi + + echo "" + read -p "Server (IP or domain): " ASTERISK_HOST + read -p "SIP Password: " SIP_PASSWORD + + if [[ "$ASTERISK_HOST" =~ [a-zA-Z] ]]; then + ENABLE_TLS="y" + else + ENABLE_TLS="n" + fi + + echo "" + echo "Answer Mode:" + echo " 1) Auto (auto-answer incoming calls)" + echo " 2) Manual (ring on incoming)" + read -p "Select [1]: " aa_sel + CLIENT_ANSWERMODE="auto" + [[ "$aa_sel" == "2" ]] && CLIENT_ANSWERMODE="manual" + + collect_client_config + install_baresip_packages + INSTALLED_CLIENT="y" + configure_baresip + enable_client_services + save_config + + print_success "Client installed" + echo "" + echo "════════════════════════════════════════════════════════" + echo " IMPORTANT: Audio Configuration" + echo "════════════════════════════════════════════════════════" + echo " User: $KIOSK_USER" + echo " - Audio group: Added" + echo " - PipeWire services: Enabled" + echo " - Microphone: Unmuted (for intercom mode)" + echo "" + echo " If audio doesn't work immediately:" + echo " 1. Log out and log back in as '$KIOSK_USER'" + echo " 2. Or reboot the system" + echo " 3. Check audio with: pactl list sources short" + echo "" + echo " PTT Mode: Not configured (normal intercom operation)" + echo " To configure PTT: Main Menu > Client Management > Configure PTT Button" + echo "════════════════════════════════════════════════════════" +} + +collect_common_config() { + SIP_PASSWORD="${SIP_PASSWORD:-$(generate_password)}" + ASTERISK_HOST="127.0.0.1" + return 0 +} + +collect_client_config() { + read -p "Extension [101]: " KIOSK_EXTENSION + KIOSK_EXTENSION="${KIOSK_EXTENSION:-101}" + KIOSK_NAME="kiosk-${KIOSK_EXTENSION}" +} + +install_dependencies() { + install_asterisk_packages + if ! is_docker; then + install_baresip_packages + fi +} + +install_asterisk_packages() { + if is_docker; then + # In Docker, packages are pre-installed via Dockerfile + print_info "Docker mode: packages pre-installed" + mkdir -p /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + return + fi + echo "exit 101" > /usr/sbin/policy-rc.d + chmod +x /usr/sbin/policy-rc.d + apt update + # asterisk-opus removed (included in asterisk-modules on Ubuntu 24.04+) + apt install -y asterisk asterisk-core-sounds-en-gsm asterisk-modules openssl curl tcpdump sngrep || true + mkdir -p /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk + ldconfig + update-ca-certificates 2>/dev/null || true + rm -f /usr/sbin/policy-rc.d + fix_asterisk_systemd +} + +install_baresip_packages() { + if is_docker; then + # Baresip (local SIP client) is not used inside the container + print_info "Docker mode: Baresip not applicable (use mobile/desktop SIP clients)" + return + fi + apt update + apt install -y baresip baresip-core pipewire pipewire-alsa pipewire-pulse wireplumber alsa-utils evtest || true +} + +uninstall_menu() { + if is_docker; then + print_header "Reset Configuration" + echo " In Docker, the container is ephemeral." + echo " To fully uninstall: docker compose down -v" + echo "" + echo " 1) Reset all configs (keep container)" + echo " 2) Reset devices only" + echo " 0) Cancel" + read -p "Select: " ch + case $ch in + 1) + rm -rf /etc/easy-asterisk/* + print_success "Configuration reset. Restart container to regenerate defaults." + ;; + 2) + if [[ -f /etc/asterisk/pjsip.conf ]]; then + # Remove device sections, keep transport config + local temp="/tmp/pjsip_base_$$.conf" + awk '/^; === Device:/{exit} {print}' /etc/asterisk/pjsip.conf > "$temp" + mv "$temp" /etc/asterisk/pjsip.conf + chown asterisk:asterisk /etc/asterisk/pjsip.conf + asterisk -rx "pjsip reload" >/dev/null 2>&1 || true + fi + print_success "All devices removed" + ;; + esac + return + fi + + print_header "Uninstall" + echo " 1) Remove Everything" + echo " 2) Asterisk Only" + echo " 3) Baresip Only" + echo " 0) Cancel" + read -p "Select: " ch + case $ch in + 1) + systemctl stop asterisk 2>/dev/null || true + apt purge -y asterisk* baresip baresip-core 2>/dev/null || true + rm -rf /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /usr/lib/asterisk + rm -rf /etc/systemd/system/asterisk.service.d /etc/easy-asterisk + [[ -n "$KIOSK_USER" ]] && rm -rf "/home/${KIOSK_USER}/.baresip" + systemctl daemon-reload + INSTALLED_SERVER="n" + INSTALLED_CLIENT="n" + rm -f "$CONFIG_FILE" + print_success "Removed all" + ;; + 2) + systemctl stop asterisk 2>/dev/null || true + apt purge -y asterisk* 2>/dev/null || true + rm -rf /etc/asterisk /var/lib/asterisk + INSTALLED_SERVER="n" + save_config + print_success "Removed Asterisk" + ;; + 3) + apt purge -y baresip baresip-core 2>/dev/null || true + [[ -n "$KIOSK_USER" ]] && rm -rf "/home/${KIOSK_USER}/.baresip" + INSTALLED_CLIENT="n" + save_config + print_success "Removed Baresip" + ;; + esac +} + +# ================================================================ +# 11. MENU SYSTEM (Reordered: Server #2, Devices #3) +# ================================================================ + +show_main_menu() { + clear + print_header "Easy Asterisk v${SCRIPT_VERSION}" + + load_config + + if is_docker; then + # Docker status display + echo " Status:" + echo -e " Mode: ${CYAN}Docker Container${NC}" + if asterisk_running; then + echo -e " Asterisk: ${GREEN}Running${NC}" + else + echo -e " Asterisk: ${RED}Not running${NC}" + fi + if webadmin_running; then + echo -e " Web Admin: ${GREEN}Running${NC} (port ${WEB_ADMIN_PORT})" + else + echo -e " Web Admin: ${YELLOW}Stopped${NC}" + fi + [[ -n "$DOMAIN_NAME" ]] && echo -e " Domain: ${DOMAIN_NAME}" + if [[ "$TURN_ENABLED" == "y" ]]; then + echo -e " TURN: ${GREEN}Enabled${NC} (${TURN_SERVER:-auto})" + elif [[ "$VPN_ICE_ENABLED" == "y" ]]; then + echo -e " STUN/ICE: ${GREEN}Enabled${NC} (${CUSTOM_STUN_SERVER:-auto})" + fi + echo "" + + declare -A menu_map + local count=1 + + if [[ "$INSTALLED_SERVER" != "y" ]]; then + echo " ${count}) Configure Server"; menu_map[$count]="submenu_install"; ((count++)) + fi + echo " ${count}) Server Settings"; menu_map[$count]="submenu_server"; ((count++)) + echo " ${count}) Device Management"; menu_map[$count]="submenu_devices"; ((count++)) + echo " ${count}) Tools"; menu_map[$count]="submenu_tools"; ((count++)) + echo " 0) Exit" + else + # Bare metal status display + echo " Status:" + if [[ -f "$CONFIG_FILE" ]]; then + [[ "$INSTALLED_SERVER" == "y" ]] && echo -e " Server: ${GREEN}Installed${NC}" || echo -e " Server: ${YELLOW}Not installed${NC}" + [[ "$INSTALLED_CLIENT" == "y" ]] && echo -e " Client: ${GREEN}Installed${NC}" || echo -e " Client: ${YELLOW}Not installed${NC}" + [[ -n "$DOMAIN_NAME" ]] && echo -e " Domain: ${DOMAIN_NAME}" + else + echo -e " ${YELLOW}Not configured${NC}" + fi + echo "" + + declare -A menu_map + local count=1 + + echo " ${count}) Install/Configure"; menu_map[$count]="submenu_install"; ((count++)) + if [[ "$INSTALLED_SERVER" == "y" ]]; then + echo " ${count}) Server Settings"; menu_map[$count]="submenu_server"; ((count++)) + echo " ${count}) Device Management"; menu_map[$count]="submenu_devices"; ((count++)) + fi + echo " ${count}) Client Settings"; menu_map[$count]="submenu_client"; ((count++)) + echo " ${count}) Tools"; menu_map[$count]="submenu_tools"; ((count++)) + echo " 0) Exit" + fi + echo "" + + read -p " Select: " choice + [[ "$choice" == "0" ]] && exit 0 + local action=${menu_map[$choice]} + [[ -n "$action" ]] && $action + show_main_menu +} + +submenu_install() { + if is_docker; then + clear + print_header "Configure Server" + echo " 1) Configure/Reconfigure Server" + echo " 2) Reset Configuration" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) install_full; read -p "Press Enter..." ;; + 2) uninstall_menu; read -p "Press Enter..." ;; + esac + return + fi + + clear + print_header "Install" + echo " 1) Full (server + client)" + echo " 2) Server only" + echo " 3) Client only" + echo " 4) Uninstall" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) install_full; read -p "Press Enter..." ;; + 2) install_server_only; read -p "Press Enter..." ;; + 3) install_client_only; read -p "Press Enter..." ;; + 4) uninstall_menu; read -p "Press Enter..." ;; + esac +} + +# ================================================================ +# WEB ADMIN INTERFACE +# ================================================================ + +# WEB_ADMIN_PORT is set in load_config (default: 8080) +WEB_ADMIN_SCRIPT="/usr/local/bin/easy-asterisk-webadmin" +WEB_ADMIN_SERVICE="/etc/systemd/system/easy-asterisk-webadmin.service" +WEB_ADMIN_HTPASSWD="/etc/easy-asterisk/webadmin.htpasswd" + +create_web_admin_script() { + cat > "$WEB_ADMIN_SCRIPT" << 'WEBADMIN' +#!/usr/bin/env python3 +""" +Easy Asterisk Web Admin - Simple web interface for client management +""" + +import http.server +import socketserver +import json +import subprocess +import os +import re +import base64 +import hashlib +import html +from urllib.parse import parse_qs, urlparse +from functools import partial + +PORT = int(os.environ.get('WEBADMIN_PORT', 8080)) +AUTH_DISABLED = os.environ.get('WEBADMIN_AUTH_DISABLED', 'false').lower() == 'true' +HTPASSWD_FILE = "/etc/easy-asterisk/webadmin.htpasswd" +PJSIP_CONF = "/etc/asterisk/pjsip.conf" +CATEGORIES_FILE = "/etc/easy-asterisk/categories.conf" +ROOMS_FILE = "/etc/easy-asterisk/rooms.conf" +CONFIG_FILE = "/etc/easy-asterisk/config" + +def check_auth(headers): + """Verify HTTP Basic Auth against htpasswd file""" + if AUTH_DISABLED: + return True # Auth disabled for reverse proxy mode + + if not os.path.exists(HTPASSWD_FILE): + return True # No auth required if no htpasswd file + + auth_header = headers.get('Authorization', '') + if not auth_header.startswith('Basic '): + return False + + try: + credentials = base64.b64decode(auth_header[6:]).decode('utf-8') + username, password = credentials.split(':', 1) + + with open(HTPASSWD_FILE, 'r') as f: + for line in f: + line = line.strip() + if ':' in line: + stored_user, stored_hash = line.split(':', 1) + if stored_user == username: + # Support plain text (for simplicity) or SHA256 + if stored_hash.startswith('{SHA256}'): + expected = '{SHA256}' + hashlib.sha256(password.encode()).hexdigest() + return stored_hash == expected + else: + return stored_hash == password + return False + except: + return False + +def get_registered_endpoints(): + """Get list of registered endpoints from Asterisk - matches bash script logic""" + try: + # Get full endpoint details which shows Contact lines with Avail status + result = subprocess.run( + ['asterisk', '-rx', 'pjsip show endpoints'], + capture_output=True, text=True, timeout=10 + ) + endpoints = {} + current_endpoint = None + + for line in result.stdout.split('\n'): + # Match endpoint header line: " Endpoint: 101/101" + endpoint_match = re.match(r'\s*Endpoint:\s+(\d+)/', line) + if endpoint_match: + current_endpoint = endpoint_match.group(1) + endpoints[current_endpoint] = 'offline' # Default to offline + + # Match contact line with Avail status: " Contact: 101/sip:... Avail" + if current_endpoint and 'Contact:' in line: + if 'Avail' in line or 'NonQual' in line: + endpoints[current_endpoint] = 'online' + + return endpoints + except: + return {} + +def get_devices(): + """Parse pjsip.conf to get device information - matches bash script logic""" + devices = [] + if not os.path.exists(PJSIP_CONF): + return devices + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + dev_name = None + dev_cat = None + dev_aa = None + + for line in lines: + line = line.strip() + + # Match device comment line + if '; === Device:' in line: + # Parse: ; === Device: Name (category) [AA:yes/no] === + temp = line.split('; === Device:')[1] if '; === Device:' in line else '' + temp = temp.split('===')[0].strip() # Remove trailing === + + # Check for AA tag + dev_aa = None + if '[AA:yes]' in temp: + dev_aa = 'yes' + temp = temp.replace('[AA:yes]', '').strip() + elif '[AA:no]' in temp: + dev_aa = 'no' + temp = temp.replace('[AA:no]', '').strip() + + # Extract category from parentheses + if '(' in temp and ')' in temp: + dev_cat = temp[temp.rfind('(')+1:temp.rfind(')')] + dev_name = temp[:temp.rfind('(')].strip() + else: + dev_name = temp + dev_cat = 'unknown' + + # Match extension line [xxx] + elif dev_name and re.match(r'^\[(\d+)\]$', line): + ext = re.match(r'^\[(\d+)\]$', line).group(1) + devices.append({ + 'name': dev_name, + 'category': dev_cat, + 'extension': ext, + 'auto_answer': dev_aa, + 'transport': 'udp', # Default, will check below + 'encryption': 'no' + }) + dev_name = None + dev_cat = None + dev_aa = None + + # Update transport/encryption for last added device + elif devices and line.startswith('transport=transport-'): + devices[-1]['transport'] = line.split('transport-')[1] + elif devices and line.startswith('media_encryption='): + val = line.split('=')[1] + if val == 'sdes' or val == 'dtls': + devices[-1]['encryption'] = val + # If encryption is set but no explicit transport, assume TLS + if devices[-1]['transport'] == 'udp': + devices[-1]['transport'] = 'tls' + elif val != 'no': + devices[-1]['encryption'] = val + + return devices + +def get_categories(): + """Get categories from config file""" + categories = [] + if os.path.exists(CATEGORIES_FILE): + with open(CATEGORIES_FILE, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 3: + categories.append({ + 'id': parts[0], + 'name': parts[1], + 'auto_answer': parts[2], + 'description': parts[3] if len(parts) > 3 else '' + }) + return categories + +def get_rooms(): + """Get rooms from config file""" + rooms = [] + if os.path.exists(ROOMS_FILE): + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 5: + rooms.append({ + 'extension': parts[0], + 'name': parts[1], + 'members': parts[2], + 'timeout': parts[3], + 'type': parts[4] + }) + return rooms + +def delete_device(extension): + """Delete a device from pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + new_lines = [] + skip = False + found = False + pending_comment = None + + for line in lines: + stripped = line.strip() + + if stripped.startswith('; === Device:'): + pending_comment = line + continue + + if re.match(rf'^\[{extension}\]$', stripped): + if pending_comment: + found = True + skip = True + pending_comment = None + continue + elif found: + skip = True + continue + + if pending_comment: + new_lines.append(pending_comment) + pending_comment = None + + if skip and stripped == '': + skip = False + continue + + if not skip: + new_lines.append(line) + + if found: + with open(PJSIP_CONF, 'w') as f: + f.writelines(new_lines) + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + return True, "Device deleted" + return False, "Device not found" + +def rename_device(extension, new_name): + """Rename a device in pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + in_device = False + device_ext = None + + for line in lines: + stripped = line.strip() + + # Match device comment and update name + if stripped.startswith('; === Device:'): + # Parse the comment to get category and AA tag + temp = stripped.split('; === Device:')[1].split('===')[0].strip() + aa_tag = '' + if '[AA:yes]' in temp: + aa_tag = ' [AA:yes]' + temp = temp.replace('[AA:yes]', '').strip() + elif '[AA:no]' in temp: + aa_tag = ' [AA:no]' + temp = temp.replace('[AA:no]', '').strip() + + if '(' in temp: + cat = temp[temp.rfind('(')+1:temp.rfind(')')] + else: + cat = 'unknown' + + # Store for next line check + pending_comment = (line, cat, aa_tag) + continue + + # Check if this is the extension we want + if 'pending_comment' in dir() and pending_comment: + match = re.match(r'^\[(\d+)\]$', stripped) + if match and match.group(1) == extension: + # This is our device - write updated comment + old_line, cat, aa_tag = pending_comment + new_lines.append(f'; === Device: {new_name} ({cat}){aa_tag} ===\n') + new_lines.append(line) + found = True + in_device = True + device_ext = extension + pending_comment = None + continue + else: + # Not our device, write original comment + new_lines.append(pending_comment[0]) + pending_comment = None + + # Update callerid line + if in_device and stripped.startswith('callerid='): + new_lines.append(f'callerid="{new_name}" <{device_ext}>\n') + continue + + # Reset on empty line after device + if in_device and stripped == '': + in_device = False + + new_lines.append(line) + + if found: + with open(PJSIP_CONF, 'w') as f: + f.writelines(new_lines) + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + return True, "Device renamed" + return False, "Device not found" + +def update_room_members(room_ext, new_members): + """Update room members""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + # Read all rooms + rooms = [] + found = False + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + rooms.append(line) + continue + parts = line.split('|') + if len(parts) >= 5 and parts[0] == room_ext: + # Update this room's members + parts[2] = new_members + rooms.append('|'.join(parts)) + found = True + else: + rooms.append(line) + + if found: + with open(ROOMS_FILE, 'w') as f: + f.write('\n'.join(rooms) + '\n') + # Rebuild dialplan + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room members updated" + return False, "Room not found" + +def add_device_to_room(room_ext, device_ext): + """Add a device to a room""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + # Find the room and its current members + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split('|') + if len(parts) >= 5 and parts[0] == room_ext: + current_members = parts[2].split(',') if parts[2] else [] + # Check if device is already a member + if device_ext in current_members: + return False, "Device already in room" + current_members.append(device_ext) + new_members = ','.join(current_members) + return update_room_members(room_ext, new_members) + return False, "Room not found" + +def remove_device_from_room(room_ext, device_ext): + """Remove a device from a room""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + # Find the room and its current members + with open(ROOMS_FILE, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith('#'): + continue + parts = line.split('|') + if len(parts) >= 5 and parts[0] == room_ext: + current_members = parts[2].split(',') if parts[2] else [] + # Check if device is a member + if device_ext not in current_members: + return False, "Device not in room" + current_members.remove(device_ext) + new_members = ','.join(current_members) + return update_room_members(room_ext, new_members) + return False, "Room not found" + +def change_device_category(extension, new_category): + """Change a device's category in pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + with open(PJSIP_CONF, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + in_device = False + + for line in lines: + stripped = line.strip() + + # Match device comment line: ; === Device: Name (category) === + if stripped.startswith('; === Device:') and f'[{extension}]' in ''.join(lines[lines.index(line):lines.index(line)+3]): + # Parse and update category in comment + match = re.match(r'^; === Device: (.+?) \(([^)]+)\)(.*?)===', stripped) + if match: + name = match.group(1) + old_cat = match.group(2) + rest = match.group(3) + new_lines.append(f'; === Device: {name} ({new_category}){rest}===\n') + found = True + in_device = True + continue + + new_lines.append(line) + + if found: + with open(PJSIP_CONF, 'w') as f: + f.writelines(new_lines) + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + return True, "Category changed" + return False, "Device not found" + +def create_room(extension, name, room_type='ring', timeout='60'): + """Create a new room in rooms.conf""" + if not os.path.exists(ROOMS_FILE): + with open(ROOMS_FILE, 'w') as f: + f.write('# Format: ext|name|members|timeout|type(ring/page)\n') + + # Check if extension already exists + with open(ROOMS_FILE, 'r') as f: + for line in f: + if line.strip() and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 1 and parts[0] == extension: + return False, "Room extension already exists" + + # Add new room + with open(ROOMS_FILE, 'a') as f: + f.write(f'{extension}|{name}||{timeout}|{room_type}\n') + + # Rebuild dialplan + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room created" + +def delete_room(extension): + """Delete a room from rooms.conf""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + with open(ROOMS_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 1 and parts[0] == extension: + found = True + continue + new_lines.append(line) + + if found: + with open(ROOMS_FILE, 'w') as f: + f.writelines(new_lines) + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room deleted" + return False, "Room not found" + +def rename_room(extension, new_name): + """Rename a room in rooms.conf""" + if not os.path.exists(ROOMS_FILE): + return False, "Rooms file not found" + + with open(ROOMS_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 5 and parts[0] == extension: + # Update name (parts[1]) + parts[1] = new_name + new_lines.append('|'.join(parts) + '\n') + found = True + continue + new_lines.append(line) + + if found: + with open(ROOMS_FILE, 'w') as f: + f.writelines(new_lines) + subprocess.run(['/usr/local/bin/easy-asterisk', '--rebuild-dialplan'], capture_output=True) + return True, "Room renamed" + return False, "Room not found" + +def create_category(cat_id, name, auto_answer='', description=''): + """Create a new category in categories.conf""" + if not os.path.exists(CATEGORIES_FILE): + with open(CATEGORIES_FILE, 'w') as f: + f.write('# Format: id|name|auto_answer|description\n') + + # Check if category already exists + with open(CATEGORIES_FILE, 'r') as f: + for line in f: + if line.strip() and not line.startswith('#'): + parts = line.split('|') + if len(parts) >= 1 and parts[0] == cat_id: + return False, "Category ID already exists" + + # Add new category + with open(CATEGORIES_FILE, 'a') as f: + f.write(f'{cat_id}|{name}|{auto_answer}|{description}\n') + + return True, "Category created" + +def delete_category(cat_id): + """Delete a category from categories.conf""" + if not os.path.exists(CATEGORIES_FILE): + return False, "Categories file not found" + + with open(CATEGORIES_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 1 and parts[0] == cat_id: + found = True + continue + new_lines.append(line) + + if found: + with open(CATEGORIES_FILE, 'w') as f: + f.writelines(new_lines) + return True, "Category deleted" + return False, "Category not found" + +def rename_category(cat_id, new_name): + """Rename a category in categories.conf""" + if not os.path.exists(CATEGORIES_FILE): + return False, "Categories file not found" + + with open(CATEGORIES_FILE, 'r') as f: + lines = f.readlines() + + new_lines = [] + found = False + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#'): + parts = stripped.split('|') + if len(parts) >= 2 and parts[0] == cat_id: + # Update name (parts[1]) + parts[1] = new_name + new_lines.append('|'.join(parts) + '\n') + found = True + continue + new_lines.append(line) + + if found: + with open(CATEGORIES_FILE, 'w') as f: + f.writelines(new_lines) + return True, "Category renamed" + return False, "Category not found" + +def generate_password(length=16): + """Generate a random password""" + import secrets + import string + chars = string.ascii_letters + string.digits + return ''.join(secrets.choice(chars) for _ in range(length)) + +def add_device(name, category, extension, conn_type='lan', auto_answer=None): + """Add a new device to pjsip.conf""" + if not os.path.exists(PJSIP_CONF): + return False, "Config file not found" + + # Check if extension exists + with open(PJSIP_CONF, 'r') as f: + if f'[{extension}]' in f.read(): + return False, "Extension already exists" + + password = generate_password() + + # Determine transport and encryption + # Check if VPN ICE mode is enabled (for third-party VPNs) + vpn_ice = 'n' + turn_enabled = 'n' + is_container = os.path.exists('/.dockerenv') + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as cf: + for cline in cf: + if cline.startswith('VPN_ICE_ENABLED='): + vpn_ice = cline.strip().split('=', 1)[1].strip('"') + elif cline.startswith('TURN_ENABLED='): + turn_enabled = cline.strip().split('=', 1)[1].strip('"') + + # In Docker: always use FQDN mode for web-created devices + if is_container and conn_type == 'lan': + conn_type = 'fqdn' + + if conn_type == 'fqdn': + transport = 'transport=transport-tls' + encryption = 'media_encryption=sdes' + ice = 'ice_support=yes' + else: + transport = 'transport=transport-udp' + encryption = 'media_encryption=no' + ice = 'ice_support=yes' if (vpn_ice == 'y' or turn_enabled == 'y') else '' + + aa_tag = '' + if auto_answer == 'yes': + aa_tag = '[AA:yes] ' + elif auto_answer == 'no': + aa_tag = '[AA:no] ' + + # Mobile devices get keepalive settings for NAT traversal + keepalive = '' + if category == 'mobile': + keepalive = 'rtp_keepalive=15\nrtp_timeout=120\nrtp_timeout_hold=120' + + device_config = f''' +; === Device: {name} ({category}) {aa_tag}=== +[{extension}] +type=endpoint +context=intercom +{transport} +disallow=all +allow=opus +allow=ulaw +allow=alaw +allow=g722 +{encryption} +direct_media=no +rtp_symmetric=yes +force_rport=yes +rewrite_contact=yes +{keepalive} +{ice} +auth={extension} +aors={extension} +callerid="{name}" <{extension}> + +[{extension}] +type=auth +auth_type=userpass +username={extension} +password={password} + +[{extension}] +type=aor +max_contacts=5 +remove_existing=yes +qualify_frequency=30 +''' + + with open(PJSIP_CONF, 'a') as f: + f.write(device_config) + + subprocess.run(['asterisk', '-rx', 'pjsip reload'], capture_output=True) + subprocess.run(['chown', 'asterisk:asterisk', PJSIP_CONF], capture_output=True) + + return True, {'extension': extension, 'password': password, 'name': name} + +def get_server_info(): + """Get server configuration info including TURN/STUN details""" + info = { + 'domain': '', + 'tls_enabled': False, + 'server_ip': '', + 'turn_enabled': False, + 'turn_server': '', + 'turn_username': '', + 'turn_password': '' + } + + if os.path.exists(CONFIG_FILE): + with open(CONFIG_FILE, 'r') as f: + for line in f: + line = line.strip() + if line.startswith('DOMAIN_NAME='): + info['domain'] = line.split('=', 1)[1].strip().strip('"') + elif line.startswith('ENABLE_TLS='): + info['tls_enabled'] = 'y' in line.lower() + elif line.startswith('TURN_ENABLED='): + info['turn_enabled'] = 'y' in line.split('=', 1)[1].lower() + elif line.startswith('TURN_SERVER='): + info['turn_server'] = line.split('=', 1)[1].strip().strip('"') + elif line.startswith('TURN_USERNAME='): + info['turn_username'] = line.split('=', 1)[1].strip().strip('"') + elif line.startswith('TURN_PASSWORD='): + info['turn_password'] = line.split('=', 1)[1].strip().strip('"') + + try: + result = subprocess.run(['hostname', '-I'], capture_output=True, text=True) + info['server_ip'] = result.stdout.split()[0] if result.stdout else '' + except: + pass + + return info + +HTML_TEMPLATE = ''' + + + + + Easy Asterisk - Client Admin + + + +
+
+

Easy Asterisk - Client Admin

+

Manage SIP clients and extensions

+
+ +
+ +
+
Devices
+
Rooms
+
Categories
+
+ +
+
+
+

Registered Devices

+
+
+ + +
+ + +
+
+
+ + + + + + + + + + + + +
ExtensionNameCategoryTransportStatusActions
+
+
+
+ +
+
+
+

Rooms (Ring/Page Groups)

+
+ + +
+
+
+
+
+ +
+
+
+

Device Categories

+
+ + +
+
+
+
+
+
+ + + + + + + + + + + + + + + + + + + +''' + +class WebAdminHandler(http.server.BaseHTTPRequestHandler): + def log_message(self, format, *args): + pass # Suppress default logging + + def send_auth_required(self): + self.send_response(401) + self.send_header('WWW-Authenticate', 'Basic realm="Easy Asterisk Admin"') + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b'

Authentication Required

') + + def do_GET(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + + if path == '/' or path == '/clients': + self.send_response(200) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(HTML_TEMPLATE.encode()) + + elif path == '/api/devices': + devices = get_devices() + self.send_json(devices) + + elif path == '/api/status': + status = get_registered_endpoints() + self.send_json(status) + + elif path == '/api/categories': + categories = get_categories() + self.send_json(categories) + + elif path == '/api/rooms': + rooms = get_rooms() + self.send_json(rooms) + + elif path == '/api/server': + info = get_server_info() + self.send_json(info) + + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode('utf-8') + + if path == '/api/devices': + try: + data = json.loads(body) + success, result = add_device( + data['name'], + data['category'], + data['extension'], + data.get('conn_type', 'lan'), + data.get('auto_answer') + ) + if success: + self.send_json({'success': True, 'data': result}) + else: + self.send_json({'success': False, 'error': result}, 400) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + + elif path.startswith('/api/rooms/') and path.endswith('/members'): + # Add device to room: POST /api/rooms/{room_ext}/members with {device: ext} + room_match = re.match(r'/api/rooms/(\d+)/members', path) + if room_match: + try: + room_ext = room_match.group(1) + data = json.loads(body) + device_ext = data.get('device') + if not device_ext: + self.send_json({'success': False, 'error': 'Device extension required'}, 400) + return + success, msg = add_device_to_room(room_ext, device_ext) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + else: + self.send_response(404) + self.end_headers() + + elif path == '/api/rooms': + # Create room: POST /api/rooms + try: + data = json.loads(body) + success, msg = create_room( + data['extension'], + data['name'], + data.get('type', 'ring'), + data.get('timeout', '60') + ) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + + elif path == '/api/categories': + # Create category: POST /api/categories + try: + data = json.loads(body) + success, msg = create_category( + data['id'], + data['name'], + data.get('auto_answer', ''), + data.get('description', '') + ) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + + else: + self.send_response(404) + self.end_headers() + + def do_DELETE(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + + # Delete device + device_match = re.match(r'/api/devices/(\d+)$', path) + if device_match: + ext = device_match.group(1) + success, msg = delete_device(ext) + self.send_json({'success': success, 'message': msg}) + return + + # Remove device from room: DELETE /api/rooms/{room_ext}/members/{device_ext} + room_member_match = re.match(r'/api/rooms/(\d+)/members/(\d+)', path) + if room_member_match: + room_ext = room_member_match.group(1) + device_ext = room_member_match.group(2) + success, msg = remove_device_from_room(room_ext, device_ext) + self.send_json({'success': success, 'message': msg}) + return + + # Delete room: DELETE /api/rooms/{ext} + room_match = re.match(r'/api/rooms/(\d+)$', path) + if room_match: + ext = room_match.group(1) + success, msg = delete_room(ext) + self.send_json({'success': success, 'message': msg}) + return + + # Delete category: DELETE /api/categories/{id} + cat_match = re.match(r'/api/categories/([a-z0-9]+)$', path) + if cat_match: + cat_id = cat_match.group(1) + success, msg = delete_category(cat_id) + self.send_json({'success': success, 'message': msg}) + return + + self.send_response(404) + self.end_headers() + + def do_PUT(self): + if not check_auth(self.headers): + self.send_auth_required() + return + + path = urlparse(self.path).path + content_length = int(self.headers.get('Content-Length', 0)) + body = self.rfile.read(content_length).decode('utf-8') + + # Change device category: PUT /api/devices/{ext}/category + cat_match = re.match(r'/api/devices/(\d+)/category$', path) + if cat_match: + ext = cat_match.group(1) + try: + data = json.loads(body) + new_cat = data.get('category', '').strip() + if not new_cat: + self.send_json({'success': False, 'error': 'Category required'}, 400) + return + success, msg = change_device_category(ext, new_cat) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + # Rename device: PUT /api/devices/{ext} + rename_match = re.match(r'/api/devices/(\d+)$', path) + if rename_match: + ext = rename_match.group(1) + try: + data = json.loads(body) + new_name = data.get('name', '').strip() + if not new_name: + self.send_json({'success': False, 'error': 'Name required'}, 400) + return + success, msg = rename_device(ext, new_name) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + # Rename room: PUT /api/rooms/{ext} + room_rename_match = re.match(r'/api/rooms/(\d+)$', path) + if room_rename_match: + ext = room_rename_match.group(1) + try: + data = json.loads(body) + new_name = data.get('name', '').strip() + if not new_name: + self.send_json({'success': False, 'error': 'Name required'}, 400) + return + success, msg = rename_room(ext, new_name) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + # Rename category: PUT /api/categories/{id} + cat_rename_match = re.match(r'/api/categories/([a-z0-9]+)$', path) + if cat_rename_match: + cat_id = cat_rename_match.group(1) + try: + data = json.loads(body) + new_name = data.get('name', '').strip() + if not new_name: + self.send_json({'success': False, 'error': 'Name required'}, 400) + return + success, msg = rename_category(cat_id, new_name) + self.send_json({'success': success, 'message': msg}) + except Exception as e: + self.send_json({'success': False, 'error': str(e)}, 400) + return + + self.send_response(404) + self.end_headers() + + def send_json(self, data, status=200): + self.send_response(status) + self.send_header('Content-type', 'application/json') + self.end_headers() + self.wfile.write(json.dumps(data).encode()) + +def main(): + with socketserver.TCPServer(("", PORT), WebAdminHandler) as httpd: + print(f"Easy Asterisk Web Admin running on port {PORT}") + httpd.serve_forever() + +if __name__ == "__main__": + main() +WEBADMIN + chmod +x "$WEB_ADMIN_SCRIPT" + print_success "Web admin script created" +} + +create_web_admin_service() { + if is_docker; then + # In Docker, web admin is managed as a background process, not a systemd service + print_success "Web admin service configured (Docker process mode)" + return + fi + cat > "$WEB_ADMIN_SERVICE" << EOF +[Unit] +Description=Easy Asterisk Web Admin +After=network.target asterisk.service + +[Service] +Type=simple +Environment=WEBADMIN_PORT=${WEB_ADMIN_PORT} +Environment=WEBADMIN_AUTH_DISABLED=${WEB_ADMIN_AUTH_DISABLED:-false} +ExecStart=/usr/bin/python3 ${WEB_ADMIN_SCRIPT} +Restart=on-failure +RestartSec=5 +User=root + +[Install] +WantedBy=multi-user.target +EOF + systemctl daemon-reload + print_success "Web admin service created" +} + +setup_web_admin_auth() { + print_header "Web Admin Authentication" + echo "Set up login credentials for the web admin interface." + echo "" + + read -p "Username [admin]: " wa_user + wa_user="${wa_user:-admin}" + + while true; do + read -s -p "Password: " wa_pass + echo "" + if [[ ${#wa_pass} -lt 6 ]]; then + print_error "Password must be at least 6 characters" + continue + fi + read -s -p "Confirm password: " wa_pass2 + echo "" + if [[ "$wa_pass" != "$wa_pass2" ]]; then + print_error "Passwords don't match" + continue + fi + break + done + + # Store with SHA256 hash + local hash=$(echo -n "$wa_pass" | sha256sum | awk '{print $1}') + echo "${wa_user}:{SHA256}${hash}" > "$WEB_ADMIN_HTPASSWD" + chmod 600 "$WEB_ADMIN_HTPASSWD" + print_success "Authentication configured for user: $wa_user" +} + +web_admin_menu() { + load_config + local server_ip=$(hostname -I | awk '{print $1}') + + print_header "Web Admin Management" + + # Check current status (Docker-aware) + local status="stopped" + if webadmin_running; then + status="running" + fi + + echo " Status: ${status^^}" + if [[ "$status" == "running" ]]; then + echo " URL: http://${server_ip}:${WEB_ADMIN_PORT}/clients" + [[ -n "$DOMAIN_NAME" ]] && echo " URL: http://${DOMAIN_NAME}:${WEB_ADMIN_PORT}/clients" + fi + if [[ "${WEB_ADMIN_AUTH_DISABLED:-}" == "true" ]]; then + echo " Auth: DISABLED (reverse proxy mode)" + else + echo " Auth: Internal basic auth" + fi + echo "" + echo " 1) Start Web Admin" + echo " 2) Stop Web Admin" + echo " 3) Restart Web Admin" + echo " 4) Configure Authentication" + echo " 5) Change Port (current: ${WEB_ADMIN_PORT})" + echo " 6) View Logs" + echo " 7) Reverse Proxy Setup (Caddy)" + echo " 0) Back" + echo "" + read -p " Select: " choice + + case $choice in + 1) + # Stop any existing instance first + stop_webadmin 2>/dev/null + print_info "Installing/updating web admin..." + start_webadmin + if webadmin_running; then + echo "" + echo " Access at: http://${server_ip}:${WEB_ADMIN_PORT}/clients" + [[ -n "$DOMAIN_NAME" ]] && echo " Or: http://${DOMAIN_NAME}:${WEB_ADMIN_PORT}/clients" + fi + ;; + 2) + print_info "Stopping web admin..." + stop_webadmin + if ! is_docker; then + systemctl disable easy-asterisk-webadmin 2>/dev/null || true + fi + ;; + 3) + restart_webadmin + ;; + 4) + setup_web_admin_auth + restart_webadmin + ;; + 5) + read -p "New port [${WEB_ADMIN_PORT}]: " new_port + new_port="${new_port:-$WEB_ADMIN_PORT}" + if [[ "$new_port" =~ ^[0-9]+$ ]] && [[ "$new_port" -ge 1024 ]] && [[ "$new_port" -le 65535 ]]; then + WEB_ADMIN_PORT="$new_port" + save_config + restart_webadmin + print_success "Port changed to $new_port" + else + print_error "Invalid port (must be 1024-65535)" + fi + ;; + 6) + if is_docker; then + echo " In Docker, check logs with: docker logs easy-asterisk" + else + journalctl -u easy-asterisk-webadmin -n 50 --no-pager + fi + ;; + 7) + print_header "Reverse Proxy Setup (Caddy)" + echo "" + echo " When using a reverse proxy like Caddy with HTTPS and its own" + echo " basic auth, you can disable internal authentication." + echo "" + echo " Current auth: $([[ "${WEB_ADMIN_AUTH_DISABLED:-}" == "true" ]] && echo "DISABLED" || echo "ENABLED")" + echo "" + echo " 1) Disable internal auth (for reverse proxy with its own auth)" + echo " 2) Enable internal auth (standalone use)" + echo " 3) Show Caddyfile example" + echo " 0) Back" + echo "" + read -p " Select: " rp_choice + case $rp_choice in + 1) + WEB_ADMIN_AUTH_DISABLED="true" + save_config + create_web_admin_script + restart_webadmin + print_success "Internal auth disabled. Use Caddy basic_auth for security." + ;; + 2) + WEB_ADMIN_AUTH_DISABLED="false" + save_config + create_web_admin_script + if [[ ! -f "$WEB_ADMIN_HTPASSWD" ]]; then + setup_web_admin_auth + fi + restart_webadmin + print_success "Internal auth enabled" + ;; + 3) + echo "" + echo " Add to your Caddyfile (docker-compose):" + echo "" + echo " ─────────────────────────────────────────" + echo " webadmin.yourdomain.com {" + echo " basicauth /* {" + echo " admin \$2a\$14\$... # use: caddy hash-password" + echo " }" + echo " reverse_proxy host.docker.internal:${WEB_ADMIN_PORT}" + echo " }" + echo " ─────────────────────────────────────────" + echo "" + echo " Generate password hash: docker exec -it caddy caddy hash-password" + echo " Then paste the hash after the username in Caddyfile." + echo "" + echo " If Caddy can't reach host.docker.internal, use your server's" + echo " LAN IP instead (e.g., 192.168.1.x:${WEB_ADMIN_PORT})" + echo "" + ;; + esac + ;; + 0) return ;; + esac +} + +configure_vpn_stun_ice() { + load_config + clear + print_header "VPN STUN/ICE Configuration" + + echo " This configures ICE (Interactive Connectivity Establishment) and" + echo " STUN (Session Traversal Utilities for NAT) for third-party VPNs." + echo "" + echo " ─────────────────────────────────────────────────────────────" + echo " When do you need this?" + echo "" + echo " • Your VPN does NAT between endpoints (audio fails or is one-way)" + echo " • Caller and receiver are on different VPN segments" + echo " • Direct VPN routing doesn't work for UDP/RTP traffic" + echo "" + echo " When do you NOT need this?" + echo "" + echo " • VPN gives both sides IPs on the same subnet (direct routing)" + echo " • Audio works fine without STUN" + echo " ─────────────────────────────────────────────────────────────" + echo "" + + local current_stun="${CUSTOM_STUN_SERVER:-Not configured}" + local current_ice="${VPN_ICE_ENABLED:-n}" + echo -e " Current Status:" + echo -e " VPN ICE: $([[ "$current_ice" == "y" ]] && echo "${GREEN}Enabled${NC}" || echo "${YELLOW}Disabled${NC}")" + echo -e " STUN Server: ${CYAN}${current_stun}${NC}" + echo "" + + echo " 1) Enable VPN ICE + self-hosted STUN (recommended for DNS filtering)" + echo " 2) Enable VPN ICE + Google STUN (requires DNS access)" + echo " 3) Enable VPN ICE + custom STUN server" + echo " 4) Disable VPN ICE (standard LAN mode)" + echo " 5) Test current STUN server" + echo " 6) Run VPN diagnostics" + echo " 7) Check DNS whitelist" + echo " 0) Back" + echo "" + read -p " Select: " stun_choice + + case $stun_choice in + 1) + # Self-hosted STUN via coturn + local server_ip=$(hostname -I | awk '{print $1}') + echo "" + echo " Self-hosted STUN uses coturn on this server (port 3478)." + echo " No external DNS dependencies - everything by IP." + echo "" + + # Detect VPN IPs for suggestion + local vpn_ip="" + while IFS= read -r line; do + local iface=$(echo "$line" | awk '{print $2}' | tr -d ':') + local ip_addr=$(echo "$line" | awk '{print $4}' | cut -d'/' -f1) + if [[ "$iface" =~ ^(tun|tap|wg|tailscale|utun|ppp|nordlynx) ]]; then + vpn_ip="$ip_addr" + break + fi + done < <(ip -o -f inet addr show scope global 2>/dev/null) + + local suggested_ip="${vpn_ip:-$server_ip}" + read -p " STUN server IP [${suggested_ip}]: " stun_ip + stun_ip="${stun_ip:-$suggested_ip}" + + read -p " STUN port [3478]: " stun_port + stun_port="${stun_port:-3478}" + + VPN_ICE_ENABLED="y" + CUSTOM_STUN_SERVER="${stun_ip}:${stun_port}" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + + print_success "VPN ICE enabled with self-hosted STUN: ${CUSTOM_STUN_SERVER}" + echo "" + echo " Make sure coturn is running on port ${stun_port}:" + echo " Docker: docker compose --profile stun up -d" + echo " Manual: apt install coturn && systemctl start coturn" + echo "" + echo " Configure Sipnetic STUN server: ${CUSTOM_STUN_SERVER}" + ;; + 2) + # Google STUN + echo "" + echo -e " ${YELLOW}Requires DNS access to: stun.l.google.com${NC}" + echo " Add this domain to your DNS whitelist on all networks" + echo " (server, caller, and receiver)." + echo "" + read -p " Continue? [y/N]: " confirm + if [[ "$confirm" =~ ^[Yy]$ ]]; then + VPN_ICE_ENABLED="y" + CUSTOM_STUN_SERVER="stun.l.google.com:19302" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + print_success "VPN ICE enabled with Google STUN" + echo "" + echo " DNS whitelist required: stun.l.google.com (UDP 19302)" + fi + ;; + 3) + # Custom STUN + echo "" + read -p " STUN server address (host:port): " custom_stun + if [[ -n "$custom_stun" ]]; then + VPN_ICE_ENABLED="y" + CUSTOM_STUN_SERVER="$custom_stun" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + print_success "VPN ICE enabled with custom STUN: ${custom_stun}" + else + print_error "No STUN server specified" + fi + ;; + 4) + # Disable + VPN_ICE_ENABLED="n" + CUSTOM_STUN_SERVER="" + save_config + repair_core_configs + generate_pjsip_conf + asterisk -rx "core reload" >/dev/null 2>&1 || true + print_success "VPN ICE disabled (standard LAN mode)" + ;; + 5) + # Test STUN + echo "" + if [[ -n "$CUSTOM_STUN_SERVER" ]]; then + local stun_host=$(echo "$CUSTOM_STUN_SERVER" | cut -d: -f1) + local stun_port=$(echo "$CUSTOM_STUN_SERVER" | cut -d: -f2) + stun_port="${stun_port:-3478}" + + echo " Testing STUN server: ${CUSTOM_STUN_SERVER}" + echo "" + + # DNS test + if [[ "$stun_host" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + print_success "STUN server is an IP address (no DNS needed)" + else + if nslookup "$stun_host" >/dev/null 2>&1; then + print_success "DNS resolves: ${stun_host}" + else + print_error "DNS BLOCKED: ${stun_host}" + echo " Add to DNS whitelist or use IP address instead" + fi + fi + + # Connectivity test + if ping -c 2 -W 3 "$stun_host" >/dev/null 2>&1; then + print_success "STUN host reachable: ${stun_host}" + else + print_warn "STUN host not pingable (may still work if ICMP blocked)" + fi + + # Port test via Asterisk + if command -v asterisk &>/dev/null; then + local rtp_check=$(asterisk -rx "rtp show settings" 2>/dev/null | grep -i "stun\|ice" || echo "") + if [[ -n "$rtp_check" ]]; then + echo "" + echo " Asterisk RTP settings:" + echo "$rtp_check" | while IFS= read -r line; do + echo " $line" + done + fi + fi + else + print_warn "No STUN server configured" + echo " Configure one using options 1-3 above" + fi + ;; + 6) + # VPN diagnostics + if command -v vpn-diagnostics &>/dev/null; then + vpn-diagnostics + elif [[ -f /usr/local/bin/vpn-diagnostics ]]; then + bash /usr/local/bin/vpn-diagnostics + else + print_error "vpn-diagnostics not found" + echo " Install: copy scripts/vpn-diagnostics.sh to /usr/local/bin/vpn-diagnostics" + fi + ;; + 7) + # DNS whitelist + if command -v dns-whitelist &>/dev/null; then + dns-whitelist --check + elif [[ -f /usr/local/bin/dns-whitelist ]]; then + bash /usr/local/bin/dns-whitelist --check + else + print_error "dns-whitelist not found" + echo " Install: copy scripts/dns-whitelist.sh to /usr/local/bin/dns-whitelist" + fi + ;; + 0) return ;; + esac +} + +submenu_server() { + clear + print_header "Server Settings" + echo " 1) Setup Internet Access (TLS/Certs/NAT)" + echo " 2) Force re-sync Caddy certs" + echo " 3) Show port/firewall requirements" + echo " 4) Interactive Firewall Guide" + echo " 5) Test SIP connectivity" + echo " 6) Verify CIDR/NAT config" + echo " 7) Watch Live Logs" + echo " 8) Router Doctor" + echo " 9) Configure VLAN/VPN Subnets" + echo " 10) Provisioning Manager" + echo " 11) Web Admin (Client Management)" + echo " 12) VPN STUN/ICE Configuration" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) setup_internet_access ;; + 2) setup_caddy_cert_sync "force" ;; + 3) show_port_requirements ;; + 4) show_firewall_guide ;; + 5) test_sip_connectivity ;; + 6) verify_cidr_config ;; + 7) watch_live_logs ;; + 8) router_doctor ;; + 9) configure_vlan_subnets ;; + 10) provisioning_manager_menu ;; + 11) web_admin_menu ;; + 12) configure_vpn_stun_ice ;; + 0) return ;; + esac + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_server +} + +submenu_devices() { + clear + print_header "Device Management" + echo " 1) Add device" + echo " 2) Remove device" + echo " 3) Rename device" + echo " 4) List devices" + echo " 5) Manage categories" + echo " 6) Manage rooms" + echo " 7) Export Clients" + echo " 8) Import Clients" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) add_device_menu ;; + 2) remove_device ;; + 3) rename_device ;; + 4) show_registered_devices ;; + 5) manage_categories ;; + 6) manage_rooms ;; + 7) export_clients ;; + 8) import_clients ;; + 0) return ;; + esac + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_devices +} + +export_clients() { + print_header "Export Client Configurations" + load_config + initialize_default_categories + + # Create export directory + local timestamp=$(date +%Y%m%d_%H%M%S) + local export_dir="/tmp/asterisk_export_${timestamp}" + local export_file="/root/asterisk-clients-${timestamp}.tar.gz" + + mkdir -p "$export_dir" + + # Check if there are any devices to export + if ! grep -q "^; === Device:" /etc/asterisk/pjsip.conf 2>/dev/null; then + print_error "No client devices found to export" + rm -rf "$export_dir" + return + fi + + # Export devices from pjsip.conf (everything after transport definitions) + echo "Extracting client devices..." + awk '/^; === Device:/{flag=1} flag' /etc/asterisk/pjsip.conf > "$export_dir/devices.conf" + + # Count devices + local device_count=$(grep -c "^; === Device:" "$export_dir/devices.conf") + + # Export categories + if [[ -f "$CATEGORIES_FILE" ]]; then + echo "Exporting categories..." + cp "$CATEGORIES_FILE" "$export_dir/categories.conf" + fi + + # Export rooms + if [[ -f "$ROOMS_FILE" ]]; then + echo "Exporting rooms..." + cp "$ROOMS_FILE" "$export_dir/rooms.conf" + fi + + # Create metadata file + cat > "$export_dir/export_info.txt" << EOF +Easy Asterisk Client Export +Export Date: $(date) +Device Count: $device_count +Domain: ${DOMAIN_NAME:-Not configured} +TLS Enabled: ${ENABLE_TLS:-no} +Exported by: $(whoami) +Hostname: $(hostname) +EOF + + # Create tar.gz archive + echo "Creating archive..." + tar -czf "$export_file" -C /tmp "asterisk_export_${timestamp}" 2>/dev/null + + # Cleanup temp directory + rm -rf "$export_dir" + + if [[ -f "$export_file" ]]; then + print_success "Export completed successfully!" + echo "" + echo " Exported: $device_count devices" + echo " File: $export_file" + echo " Size: $(du -h "$export_file" | cut -f1)" + echo "" + echo " To import on another system:" + echo " 1) Copy file to the target server" + echo " 2) Run Easy Asterisk" + echo " 3) Select 'Client Settings' -> 'Import Clients'" + else + print_error "Export failed" + fi +} + +import_clients() { + print_header "Import Client Configurations" + load_config + initialize_default_categories + + echo "Available export files in /root:" + local files=($(ls -t /root/asterisk-clients-*.tar.gz 2>/dev/null)) + + if [[ ${#files[@]} -eq 0 ]]; then + echo "" + read -p "Enter full path to export file: " import_file + else + echo "" + local i=1 + for f in "${files[@]}"; do + echo " $i) $(basename "$f") - $(du -h "$f" | cut -f1) - $(date -r "$f" '+%Y-%m-%d %H:%M')" + ((i++)) + done + echo " 0) Enter custom path" + echo "" + read -p "Select file [1]: " file_choice + file_choice="${file_choice:-1}" + + if [[ "$file_choice" == "0" ]]; then + read -p "Enter full path to export file: " import_file + elif [[ "$file_choice" -ge 1 && "$file_choice" -le ${#files[@]} ]]; then + import_file="${files[$((file_choice-1))]}" + else + print_error "Invalid selection" + return + fi + fi + + if [[ ! -f "$import_file" ]]; then + print_error "File not found: $import_file" + return + fi + + # Extract to temp directory + local timestamp=$(date +%Y%m%d_%H%M%S) + local import_dir="/tmp/asterisk_import_${timestamp}" + mkdir -p "$import_dir" + + echo "Extracting archive..." + tar -xzf "$import_file" -C "$import_dir" 2>/dev/null + + # Find the extracted directory + local extract_dir=$(find "$import_dir" -type d -name "asterisk_export_*" | head -1) + if [[ ! -d "$extract_dir" ]]; then + print_error "Invalid export file format" + rm -rf "$import_dir" + return + fi + + # Show export info + if [[ -f "$extract_dir/export_info.txt" ]]; then + echo "" + echo "═══════════════════════════════════════════════════════════════" + cat "$extract_dir/export_info.txt" + echo "═══════════════════════════════════════════════════════════════" + echo "" + fi + + # Count devices to import + local device_count=0 + if [[ -f "$extract_dir/devices.conf" ]]; then + device_count=$(grep -c "^; === Device:" "$extract_dir/devices.conf") + fi + + if [[ $device_count -eq 0 ]]; then + print_error "No devices found in export file" + rm -rf "$import_dir" + return + fi + + echo "This will import $device_count device(s)." + echo "" + read -p "Import mode [1=Merge, 2=Replace All]: " import_mode + import_mode="${import_mode:-1}" + + if [[ "$import_mode" == "2" ]]; then + echo "" + echo "${RED}WARNING: This will DELETE ALL existing devices!${NC}" + read -p "Type 'DELETE ALL' to confirm: " confirm + if [[ "$confirm" != "DELETE ALL" ]]; then + print_error "Import cancelled" + rm -rf "$import_dir" + return + fi + fi + + # Backup existing configurations + echo "Backing up current configuration..." + backup_config "/etc/asterisk/pjsip.conf" + backup_config "$CATEGORIES_FILE" + backup_config "$ROOMS_FILE" + + # Import devices + if [[ "$import_mode" == "2" ]]; then + # Replace mode - remove all existing devices + echo "Removing existing devices..." + local temp_pjsip="/tmp/pjsip_base_${timestamp}.conf" + awk '/^; === Device:/{exit} {print}' /etc/asterisk/pjsip.conf > "$temp_pjsip" + cat "$temp_pjsip" "$extract_dir/devices.conf" > /etc/asterisk/pjsip.conf + rm -f "$temp_pjsip" + print_success "Replaced all devices with imported devices" + else + # Merge mode - check for conflicts + echo "Checking for extension conflicts..." + local conflicts=0 + local conflict_list="" + + while IFS= read -r line; do + if [[ "$line" =~ ^\[([0-9]+)\]$ ]]; then + local ext="${BASH_REMATCH[1]}" + if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + conflicts=$((conflicts + 1)) + conflict_list="${conflict_list}${ext} " + fi + fi + done < "$extract_dir/devices.conf" + + if [[ $conflicts -gt 0 ]]; then + echo "" + echo "${YELLOW}Warning: Found $conflicts conflicting extension(s): $conflict_list${NC}" + read -p "Skip conflicting devices? [Y/n]: " skip_conflicts + skip_conflicts="${skip_conflicts:-Y}" + + if [[ ! "$skip_conflicts" =~ ^[Yy]$ ]]; then + print_error "Import cancelled" + rm -rf "$import_dir" + return + fi + + # Import only non-conflicting devices + echo "Importing non-conflicting devices..." + local temp_import="/tmp/import_filtered_${timestamp}.conf" + local skip_device=0 + local pending_header="" + + while IFS= read -r line; do + if [[ "$line" == "; === Device:"* ]]; then + skip_device=0 + pending_header="$line" + elif [[ "$line" =~ ^\[([0-9]+)\]$ ]]; then + local ext="${BASH_REMATCH[1]}" + if grep -q "^\[${ext}\]" /etc/asterisk/pjsip.conf 2>/dev/null; then + skip_device=1 + if [[ -n "$pending_header" ]]; then + echo " Skipping extension $ext (already exists)" + pending_header="" + fi + else + if [[ -n "$pending_header" ]]; then + echo "$pending_header" >> "$temp_import" + pending_header="" + fi + echo "$line" >> "$temp_import" + fi + elif [[ $skip_device -eq 0 ]]; then + echo "$line" >> "$temp_import" + fi + done < "$extract_dir/devices.conf" + + cat "$temp_import" >> /etc/asterisk/pjsip.conf + rm -f "$temp_import" + else + # No conflicts, import all + echo "No conflicts found, importing all devices..." + cat "$extract_dir/devices.conf" >> /etc/asterisk/pjsip.conf + fi + + print_success "Devices imported successfully" + fi + + # Import categories (merge, skip duplicates) + if [[ -f "$extract_dir/categories.conf" ]]; then + echo "Importing categories..." + while IFS='|' read -r cat_id cat_name auto_answer description; do + [[ "$cat_id" =~ ^# ]] && continue + [[ -z "$cat_id" ]] && continue + + # Skip if already exists + if grep -q "^${cat_id}|" "$CATEGORIES_FILE" 2>/dev/null; then + echo " Skipping category '$cat_id' (already exists)" + else + echo "${cat_id}|${cat_name}|${auto_answer}|${description}" >> "$CATEGORIES_FILE" + echo " Imported category: $cat_name" + fi + done < "$extract_dir/categories.conf" + fi + + # Import rooms (merge, skip duplicates) + if [[ -f "$extract_dir/rooms.conf" ]]; then + echo "Importing rooms..." + while IFS='|' read -r ext name members timeout type; do + [[ "$ext" =~ ^# ]] && continue + [[ -z "$ext" ]] && continue + + # Skip if already exists + if grep -q "^${ext}|" "$ROOMS_FILE" 2>/dev/null; then + echo " Skipping room '$name' (extension $ext already exists)" + else + echo "${ext}|${name}|${members}|${timeout}|${type}" >> "$ROOMS_FILE" + echo " Imported room: $name (ext $ext)" + fi + done < "$extract_dir/rooms.conf" + fi + + # Cleanup + rm -rf "$import_dir" + + # Reload Asterisk + echo "" + echo "Reloading Asterisk configuration..." + asterisk -rx "pjsip reload" >/dev/null 2>&1 + rebuild_dialplan quiet + + print_success "Import completed successfully!" + echo "" + echo " Run 'List devices' to verify imported clients" +} + +submenu_client() { + clear + print_header "Client Settings" + echo " 1) Configure Local Client" + echo " 2) Configure PTT Button" + echo " 3) Run Diagnostics" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) configure_local_client ;; + 2) configure_ptt_menu ;; + 3) run_client_diagnostics ;; + 0) return ;; + esac + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_client +} + +fix_audio_manually() { + if is_docker; then + print_error "Audio management not available in Docker (no local audio hardware)" + return + fi + print_header "Manual Audio Fix" + load_config + local t_user="${KIOSK_USER:-$SUDO_USER}" + t_user="${t_user:-$USER}" + local t_uid=$(id -u "$t_user" 2>/dev/null) + local user_dbus="XDG_RUNTIME_DIR=/run/user/$t_uid" + + echo "Fixing audio for user: $t_user" + echo "" + + # Restart PipeWire services + echo "Restarting PipeWire services..." + sudo -u "$t_user" $user_dbus systemctl --user restart pipewire pipewire-pulse 2>/dev/null || true + sleep 2 + + # Unmute audio + echo "Unmuting audio sources and sinks..." + sudo -u "$t_user" $user_dbus pactl set-source-mute @DEFAULT_SOURCE@ 0 2>/dev/null && echo " ✓ Microphone unmuted" || echo " ✗ Failed to unmute microphone" + sudo -u "$t_user" $user_dbus pactl set-sink-mute @DEFAULT_SINK@ 0 2>/dev/null && echo " ✓ Speaker unmuted" || echo " ✗ Failed to unmute speaker" + + # Set volume + echo "Setting volume levels to 75%..." + sudo -u "$t_user" $user_dbus pactl set-source-volume @DEFAULT_SOURCE@ 75% 2>/dev/null && echo " ✓ Microphone volume set" || echo " ✗ Failed to set microphone volume" + sudo -u "$t_user" $user_dbus pactl set-sink-volume @DEFAULT_SINK@ 75% 2>/dev/null && echo " ✓ Speaker volume set" || echo " ✗ Failed to set speaker volume" + + echo "" + echo "Current audio status:" + local src_mute=$(sudo -u "$t_user" $user_dbus pactl get-source-mute @DEFAULT_SOURCE@ 2>/dev/null | awk '{print $2}') + local sink_mute=$(sudo -u "$t_user" $user_dbus pactl get-sink-mute @DEFAULT_SINK@ 2>/dev/null | awk '{print $2}') + echo " Microphone: ${src_mute:-unknown}" + echo " Speaker: ${sink_mute:-unknown}" + + echo "" + echo "Restarting Baresip..." + sudo -u "$t_user" $user_dbus systemctl --user restart baresip 2>/dev/null && echo " ✓ Baresip restarted" || echo " ✗ Failed to restart Baresip" +} + +submenu_tools() { + clear + print_header "Tools" + + if is_docker; then + echo " 1) Room Directory" + echo " 2) Update Asterisk (Docker)" + echo " 3) VPN Diagnostics" + echo " 4) DNS Whitelist Check" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) show_room_directory ;; + 2) manual_update_asterisk ;; + 3) + if [[ -f /usr/local/bin/vpn-diagnostics ]]; then + bash /usr/local/bin/vpn-diagnostics + else + print_error "vpn-diagnostics not found" + fi + ;; + 4) + if [[ -f /usr/local/bin/dns-whitelist ]]; then + bash /usr/local/bin/dns-whitelist --check + else + print_error "dns-whitelist not found" + fi + ;; + 0) return ;; + esac + else + echo " 1) Audio Test" + echo " 2) Verify Audio/Codec Setup" + echo " 3) Fix Audio (Unmute & Restart)" + echo " 4) Room Directory" + echo " 5) Manual Update Asterisk" + echo " 0) Back" + read -p " Select: " choice + case $choice in + 1) run_audio_test ;; + 2) verify_audio_setup ;; + 3) fix_audio_manually ;; + 4) show_room_directory ;; + 5) manual_update_asterisk ;; + 0) return ;; + esac + fi + [[ "$choice" != "0" ]] && read -p "Press Enter..." + [[ "$choice" != "0" ]] && submenu_tools +} + +main() { + check_root + load_config + show_main_menu +} + +main "$@" diff --git a/vendor/easy-asterisk/scripts/dns-whitelist.sh b/vendor/easy-asterisk/scripts/dns-whitelist.sh new file mode 100644 index 0000000..61da578 --- /dev/null +++ b/vendor/easy-asterisk/scripts/dns-whitelist.sh @@ -0,0 +1,280 @@ +#!/bin/bash +# ================================================================ +# DNS Whitelist Checker for Easy Asterisk +# +# Checks which domains need to be whitelisted when DNS filtering +# is active on the server, caller, or receiver networks. +# +# Usage: dns-whitelist [--check] [--sipnetic] [--linphone] +# ================================================================ + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +CONFIG_FILE="/etc/easy-asterisk/config" +CHECK_MODE=false +SHOW_SIPNETIC=false +SHOW_LINPHONE=false +SHOW_ALL=true + +while [[ $# -gt 0 ]]; do + case "$1" in + --check) CHECK_MODE=true; shift ;; + --sipnetic) SHOW_SIPNETIC=true; SHOW_ALL=false; shift ;; + --linphone) SHOW_LINPHONE=true; SHOW_ALL=false; shift ;; + --help|-h) + echo "Usage: dns-whitelist [OPTIONS]" + echo "" + echo "Options:" + echo " --check Test reachability of each domain" + echo " --sipnetic Show Sipnetic-specific domains" + echo " --linphone Show Linphone-specific domains" + echo " --help Show this help" + exit 0 + ;; + *) shift ;; + esac +done + +print_header() { + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +check_dns() { + local domain="$1" + local port="$2" + local proto="${3:-tcp}" + + if $CHECK_MODE; then + # DNS resolution test + if nslookup "$domain" >/dev/null 2>&1; then + echo -e " ${GREEN}✓ DNS resolves${NC}" + else + echo -e " ${RED}✗ DNS BLOCKED - add to whitelist${NC}" + return 1 + fi + + # Connectivity test + if [[ "$proto" == "udp" ]]; then + # UDP - just check DNS resolution (can't reliably test UDP connectivity) + echo -e " ${CYAN}→ UDP port ${port} (cannot test remotely)${NC}" + else + if curl -s --connect-timeout 5 "https://${domain}" >/dev/null 2>&1 || \ + curl -s --connect-timeout 5 "http://${domain}" >/dev/null 2>&1; then + echo -e " ${GREEN}✓ Reachable${NC}" + else + echo -e " ${YELLOW}! Connection failed (may be expected)${NC}" + fi + fi + fi +} + +# Load config if available +source "$CONFIG_FILE" 2>/dev/null || true + +print_header "DNS Whitelist for Easy Asterisk" + +echo -e "${BOLD}Your Setup:${NC}" +if [[ -n "$DOMAIN_NAME" ]]; then + echo -e " Mode: FQDN/Internet (${DOMAIN_NAME})" +else + echo -e " Mode: LAN/VPN (no domain configured)" +fi +echo "" + +# ══════════════════════════════════════════════════════════════ +# SECTION 1: ASTERISK SERVER DOMAINS +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL; then + echo -e "${BOLD}━━━ 1. ASTERISK SERVER (whitelist on server's DNS filter) ━━━${NC}" + echo "" + + echo -e "${BOLD}Required for LAN/VPN mode:${NC}" + echo -e " ${GREEN}None${NC} - Asterisk needs no internet after installation" + echo -e " SIP operates over direct IP connections, no DNS involved" + echo "" + + echo -e "${BOLD}Required for FQDN/Internet mode only:${NC}" + echo "" + + echo -e " ${CYAN}ifconfig.me${NC} (HTTPS 443)" + echo -e " Purpose: Auto-detect public IP for NAT settings" + echo -e " When: Only during config regeneration" + check_dns "ifconfig.me" "443" + echo "" + + echo -e " ${CYAN}icanhazip.com${NC} (HTTPS 443)" + echo -e " Purpose: Fallback public IP detection" + check_dns "icanhazip.com" "443" + echo "" + + echo -e "${BOLD}Required if ICE/STUN enabled:${NC}" + echo "" + + # Check what STUN server is configured + stun_server="" + if [[ -f /etc/asterisk/rtp.conf ]]; then + stun_server=$(grep "^stunaddr=" /etc/asterisk/rtp.conf 2>/dev/null | cut -d= -f2) + fi + + if [[ -n "$stun_server" ]]; then + stun_host=$(echo "$stun_server" | cut -d: -f1) + stun_port=$(echo "$stun_server" | cut -d: -f2) + stun_port="${stun_port:-3478}" + echo -e " ${CYAN}${stun_host}${NC} (UDP ${stun_port})" + echo -e " Purpose: STUN NAT discovery" + echo -e " ${YELLOW}Tip: Use self-hosted coturn to avoid this dependency${NC}" + check_dns "$stun_host" "$stun_port" "udp" + else + echo -e " ${GREEN}No external STUN server configured${NC}" + echo -e " To use self-hosted: docker compose --profile stun up -d" + fi + echo "" + + echo -e "${BOLD}Required for package updates only:${NC}" + echo "" + echo -e " ${CYAN}archive.ubuntu.com${NC} / ${CYAN}security.ubuntu.com${NC} (HTTPS 443)" + echo -e " Purpose: apt package updates" + echo -e " When: Only during install/update (not runtime)" + echo "" + + echo -e "${BOLD}Required for TLS certificates:${NC}" + echo "" + echo -e " ${CYAN}acme-v02.api.letsencrypt.org${NC} (HTTPS 443)" + echo -e " Purpose: Let's Encrypt certificate issuance" + echo -e " When: Only if using Let's Encrypt / Certbot / Caddy" + if $CHECK_MODE; then + check_dns "acme-v02.api.letsencrypt.org" "443" + fi + echo "" +fi + +# ══════════════════════════════════════════════════════════════ +# SECTION 2: SIPNETIC (Mobile Client) DOMAINS +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL || $SHOW_SIPNETIC; then + echo -e "${BOLD}━━━ 2. SIPNETIC CLIENT (whitelist on caller/receiver DNS) ━━━${NC}" + echo "" + + echo -e "${BOLD}Required for SIP calls:${NC}" + echo -e " ${GREEN}None${NC} - Configure Sipnetic with the server's IP address directly" + echo -e " SIP registration and calls use IP:port, not DNS" + echo "" + + echo -e "${BOLD}Sipnetic app domains (for app functionality):${NC}" + echo "" + echo -e " ${CYAN}onesip.io${NC} / ${CYAN}api.onesip.io${NC}" + echo -e " Purpose: Sipnetic account/licensing (free tier works offline)" + echo -e " Required: Only for initial setup or account sync" + if $CHECK_MODE; then + check_dns "onesip.io" "443" + fi + echo "" + + echo -e " ${CYAN}play.google.com${NC} / ${CYAN}apps.apple.com${NC}" + echo -e " Purpose: App updates" + echo -e " Required: Only for installing/updating the app" + echo "" + + echo -e "${BOLD}If STUN configured in Sipnetic:${NC}" + echo "" + echo -e " The STUN server domain configured in Sipnetic's settings" + echo -e " needs to resolve on the mobile device's network." + echo "" + echo -e " ${YELLOW}Recommendation: Use the Asterisk server's VPN IP as STUN${NC}" + echo -e " ${YELLOW}server (if running self-hosted coturn), avoiding DNS entirely.${NC}" + echo "" + + echo -e "${BOLD}Sipnetic Configuration for DNS-Filtered Networks:${NC}" + echo "" + echo -e " Server: ${CYAN}${NC} (not a hostname)" + echo -e " Port: ${CYAN}5060${NC} (UDP, LAN/VPN mode)" + echo -e " Transport: ${CYAN}UDP${NC}" + echo -e " STUN: ${CYAN}:3478${NC} (if self-hosted coturn)" + echo -e " or leave blank if VPN provides direct routing" + echo "" +fi + +# ══════════════════════════════════════════════════════════════ +# SECTION 3: LINPHONE (Mobile Client) DOMAINS +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL || $SHOW_LINPHONE; then + echo -e "${BOLD}━━━ 3. LINPHONE CLIENT (whitelist on caller/receiver DNS) ━━━${NC}" + echo "" + + echo -e "${BOLD}Required for SIP calls:${NC}" + echo -e " ${GREEN}None${NC} - Same as Sipnetic, configure with server IP directly" + echo "" + + echo -e "${BOLD}Linphone app domains:${NC}" + echo "" + echo -e " ${CYAN}linphone.org${NC} / ${CYAN}sip.linphone.org${NC}" + echo -e " Purpose: Default Linphone SIP proxy (NOT needed for Easy Asterisk)" + echo -e " Required: ${GREEN}No${NC} - We use our own Asterisk server" + echo "" + echo -e " ${CYAN}subscribe.linphone.org${NC}" + echo -e " Purpose: Push notifications (may be needed for background calls)" + echo -e " Required: Only if you need calls to ring when app is backgrounded" + echo "" + + echo -e "${BOLD}For remote provisioning:${NC}" + echo "" + echo -e " If using Easy Asterisk's HTTP provisioning:" + echo -e " The phone must reach ${CYAN}http://:8088/static/linphone.xml${NC}" + echo -e " This is an IP address, so no DNS whitelist needed." + echo "" +fi + +# ══════════════════════════════════════════════════════════════ +# SECTION 4: SUMMARY +# ══════════════════════════════════════════════════════════════ +if $SHOW_ALL; then + print_header "Quick Reference - Minimum DNS Whitelist" + + echo -e "${BOLD}For LAN/VPN mode (no internet calling):${NC}" + echo "" + echo -e " Server DNS filter: ${GREEN}No domains needed${NC}" + echo -e " Client DNS filter: ${GREEN}No domains needed${NC}" + echo -e " (Configure everything by IP address)" + echo "" + + echo -e "${BOLD}For LAN/VPN + self-hosted STUN (coturn):${NC}" + echo "" + echo -e " Server DNS filter: ${GREEN}No domains needed${NC}" + echo -e " Client DNS filter: ${GREEN}No domains needed${NC}" + echo -e " (STUN server reached by VPN IP, not hostname)" + echo "" + + echo -e "${BOLD}For LAN/VPN + Google STUN:${NC}" + echo "" + echo -e " Server DNS filter: ${YELLOW}stun.l.google.com${NC}" + echo -e " Client DNS filter: ${YELLOW}stun.l.google.com${NC} (if also set in Sipnetic)" + echo "" + + echo -e "${BOLD}For FQDN/Internet mode:${NC}" + echo "" + echo -e " Server DNS filter: ${YELLOW}ifconfig.me, icanhazip.com, stun.l.google.com${NC}" + echo -e " ${YELLOW}acme-v02.api.letsencrypt.org${NC} (if using LE certs)" + echo -e " Client DNS filter: ${YELLOW}Your domain name (${DOMAIN_NAME:-yourdomain.com})${NC}" + echo "" + + print_header "Recommendation for DNS-Filtered Environments" + + echo -e " ${GREEN}Use LAN/VPN mode + self-hosted coturn (STUN-only)${NC}" + echo -e " ${GREEN}= Zero external DNS dependencies${NC}" + echo "" + echo -e " Setup: docker compose --profile stun up -d" + echo -e " Then configure STUN as your server's VPN IP:3478" + echo -e " No hostnames, no DNS, everything by IP." + echo "" +fi diff --git a/vendor/easy-asterisk/scripts/vpn-diagnostics.sh b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh new file mode 100644 index 0000000..550f7b5 --- /dev/null +++ b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh @@ -0,0 +1,366 @@ +#!/bin/bash +# ================================================================ +# VPN Diagnostics for Easy Asterisk +# +# Tests whether your third-party VPN setup needs STUN/TURN +# and validates connectivity between Asterisk and VPN clients. +# +# Usage: vpn-diagnostics [--auto] [--client-ip ] +# ================================================================ + +set -e + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +CONFIG_FILE="/etc/easy-asterisk/config" +RESULTS=() +WARNINGS=() +CLIENT_IP="" +AUTO_MODE=false + +# Parse arguments +while [[ $# -gt 0 ]]; do + case "$1" in + --auto) AUTO_MODE=true; shift ;; + --client-ip) CLIENT_IP="$2"; shift 2 ;; + --help|-h) + echo "Usage: vpn-diagnostics [OPTIONS]" + echo "" + echo "Options:" + echo " --auto Non-interactive mode" + echo " --client-ip Test connectivity to specific VPN client" + echo " --help Show this help" + exit 0 + ;; + *) shift ;; + esac +done + +print_header() { + echo "" + echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${CYAN} $1${NC}" + echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" + echo "" +} + +pass() { echo -e " ${GREEN}✓${NC} $1"; RESULTS+=("PASS: $1"); } +fail() { echo -e " ${RED}✗${NC} $1"; RESULTS+=("FAIL: $1"); } +warn() { echo -e " ${YELLOW}!${NC} $1"; WARNINGS+=("$1"); } +info() { echo -e " ${CYAN}→${NC} $1"; } + +# ── Test 1: Detect network interfaces ──────────────────────── +print_header "VPN Diagnostics for Easy Asterisk" + +echo -e "${BOLD}1. Network Interface Detection${NC}" +echo "" + +# Detect primary LAN interface +primary_ip=$(hostname -I | awk '{print $1}') +info "Primary IP: ${primary_ip}" + +# Detect VPN interfaces (tun, tap, wg, tailscale, utun, ppp) +vpn_found=false +vpn_ips=() +vpn_ifaces=() + +while IFS= read -r line; do + iface=$(echo "$line" | awk '{print $2}' | tr -d ':') + ip_addr=$(echo "$line" | awk '{print $4}' | cut -d'/' -f1) + + # Check for VPN interface patterns + if [[ "$iface" =~ ^(tun|tap|wg|tailscale|utun|ppp|nordlynx|proton|mullvad) ]] || \ + [[ "$ip_addr" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.64\.|100\.96\.|100\.100\.) ]]; then + vpn_found=true + vpn_ips+=("$ip_addr") + vpn_ifaces+=("$iface") + pass "VPN interface detected: ${iface} (${ip_addr})" + fi +done < <(ip -o -f inet addr show scope global 2>/dev/null) + +if ! $vpn_found; then + warn "No VPN interface detected on server" + info "If your VPN runs on the router (not this server), that's expected" + info "The VPN subnet should be added via VLAN/VPN subnet configuration" +fi + +# ── Test 2: Check Asterisk PJSIP transport configuration ───── +echo "" +echo -e "${BOLD}2. Asterisk Transport Configuration${NC}" +echo "" + +if [[ -f /etc/asterisk/pjsip.conf ]]; then + # Check local_net entries + local_nets=$(grep "^local_net=" /etc/asterisk/pjsip.conf 2>/dev/null | sort -u) + if [[ -n "$local_nets" ]]; then + while IFS= read -r net; do + info "Transport local_net: ${net#local_net=}" + done <<< "$local_nets" + + # Check if VPN subnets are included + for vpn_ip in "${vpn_ips[@]}"; do + vpn_subnet=$(echo "$vpn_ip" | sed 's/\.[0-9]*$/.0\/24/') + if echo "$local_nets" | grep -q "$vpn_subnet"; then + pass "VPN subnet ${vpn_subnet} included in transport" + else + fail "VPN subnet ${vpn_subnet} NOT in transport local_net" + warn "Add via: Server Settings → Configure VLAN/VPN Subnets" + fi + done + else + warn "No local_net entries found in transport (basic LAN mode)" + fi + + # Check transport types + if grep -q "transport=transport-udp" /etc/asterisk/pjsip.conf; then + pass "UDP transport configured for LAN/VPN devices" + fi + if grep -q "transport=transport-tls" /etc/asterisk/pjsip.conf; then + pass "TLS transport configured for FQDN devices" + fi +else + fail "pjsip.conf not found" +fi + +# ── Test 2b: TLS Certificate & Port Checks ──────────────────── +echo "" +echo -e "${BOLD}2b. TLS / Certificate Status${NC}" +echo "" + +# Check if port 5061 is actually listening +if command -v ss &>/dev/null; then + tls_listen=$(ss -tlnp 2>/dev/null | grep ":5061 " || true) +elif command -v netstat &>/dev/null; then + tls_listen=$(netstat -tlnp 2>/dev/null | grep ":5061 " || true) +else + tls_listen="" +fi + +if [[ -n "$tls_listen" ]]; then + pass "Port 5061 (TLS) is listening" +else + fail "Port 5061 (TLS) is NOT listening" + warn "Asterisk TLS transport failed to start — check certs and logs" +fi + +# Check TLS cert +cert_file="/etc/asterisk/certs/server.crt" +if [[ -f "$cert_file" ]]; then + pass "TLS certificate exists: $cert_file" + + # Check cert CN/SAN + cert_cn=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed 's/.*CN *= *//') + cert_san=$(openssl x509 -in "$cert_file" -noout -ext subjectAltName 2>/dev/null | grep -oP 'DNS:\K[^,]+' || true) + cert_expiry=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2) + + info "Cert CN: ${cert_cn:-unknown}" + if [[ -n "$cert_san" ]]; then + pass "Cert has SAN (Subject Alt Name): ${cert_san}" + else + fail "Cert has NO SAN — modern phones (iOS/Android) will reject it" + warn "Delete /etc/asterisk/certs/server.crt and restart to regenerate with SANs" + fi + info "Cert expires: ${cert_expiry:-unknown}" + + # Check if cert is self-signed + issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/.*CN *= *//') + if [[ "$issuer" == "$cert_cn" ]]; then + warn "Cert is SELF-SIGNED — phones must be set to accept self-signed certs" + info "In your SIP app: disable TLS certificate verification / allow self-signed" + fi + + # Verify PJSIP transport loaded it + if command -v asterisk &>/dev/null; then + transport_status=$(asterisk -rx "pjsip show transports" 2>/dev/null || true) + if echo "$transport_status" | grep -q "transport-tls"; then + pass "PJSIP TLS transport is loaded" + else + fail "PJSIP TLS transport NOT loaded — cert may be invalid" + fi + fi +else + fail "TLS certificate not found at $cert_file" +fi + +# ── Test 3: Check RTP and ICE/STUN configuration ───────────── +echo "" +echo -e "${BOLD}3. RTP / ICE / STUN Configuration${NC}" +echo "" + +if [[ -f /etc/asterisk/rtp.conf ]]; then + rtp_start=$(grep "^rtpstart=" /etc/asterisk/rtp.conf | cut -d= -f2) + rtp_end=$(grep "^rtpend=" /etc/asterisk/rtp.conf | cut -d= -f2) + info "RTP port range: ${rtp_start:-10000}-${rtp_end:-20000}" + + if grep -q "^icesupport=yes" /etc/asterisk/rtp.conf; then + pass "ICE support enabled" + stun_addr=$(grep "^stunaddr=" /etc/asterisk/rtp.conf | cut -d= -f2) + if [[ -n "$stun_addr" ]]; then + info "STUN server: ${stun_addr}" + + # Test STUN server reachability + stun_host=$(echo "$stun_addr" | cut -d: -f1) + stun_port=$(echo "$stun_addr" | cut -d: -f2) + stun_port="${stun_port:-3478}" + + if command -v nslookup &>/dev/null && nslookup "$stun_host" >/dev/null 2>&1; then + pass "STUN server DNS resolves: ${stun_host}" + else + fail "Cannot resolve STUN server: ${stun_host}" + warn "Add ${stun_host} to DNS whitelist" + fi + fi + else + info "ICE support disabled (standard for LAN/VPN mode)" + warn "If audio fails over VPN, enable ICE via: Server Settings → VPN STUN/ICE" + fi +else + warn "rtp.conf not found" +fi + +# ── Test 4: Check endpoint ICE settings ─────────────────────── +echo "" +echo -e "${BOLD}4. Per-Device ICE Configuration${NC}" +echo "" + +if [[ -f /etc/asterisk/pjsip.conf ]]; then + device_count=$(grep -c "^; === Device:" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0) + ice_device_count=$(grep -c "^ice_support=yes" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0) + info "Total devices: ${device_count}" + info "Devices with ICE: ${ice_device_count}" + + if [[ "$device_count" -gt 0 && "$ice_device_count" -eq 0 ]]; then + warn "No devices have ICE enabled" + info "For third-party VPNs with NAT, enable ICE via VPN STUN/ICE menu" + fi +fi + +# ── Test 5: VPN client connectivity ────────────────────────── +echo "" +echo -e "${BOLD}5. VPN Client Connectivity${NC}" +echo "" + +if [[ -z "$CLIENT_IP" ]] && ! $AUTO_MODE; then + echo " Enter a VPN client IP to test connectivity (or press Enter to skip):" + read -p " Client VPN IP: " CLIENT_IP +fi + +if [[ -n "$CLIENT_IP" ]]; then + # Ping test + if ping -c 2 -W 3 "$CLIENT_IP" >/dev/null 2>&1; then + pass "Ping to ${CLIENT_IP} succeeded" + else + fail "Ping to ${CLIENT_IP} failed" + warn "VPN routing issue - client may not be reachable" + fi + + # SIP port test (UDP 5060) + if command -v nc &>/dev/null; then + if nc -z -u -w 3 "$CLIENT_IP" 5060 2>/dev/null; then + pass "UDP 5060 reachable on ${CLIENT_IP}" + else + info "UDP 5060 probe inconclusive (normal for filtered VPNs)" + fi + fi +else + info "Skipping client connectivity test (no IP provided)" +fi + +# ── Test 6: NAT type detection ─────────────────────────────── +echo "" +echo -e "${BOLD}6. NAT Type Analysis${NC}" +echo "" + +# Check if server is behind NAT +if [[ -n "$primary_ip" ]]; then + public_ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || echo "") + if [[ -n "$public_ip" ]]; then + if [[ "$primary_ip" == "$public_ip" ]]; then + pass "Server has public IP (no NAT)" + else + info "Server behind NAT: ${primary_ip} → ${public_ip}" + info "This is normal for VPN setups where traffic stays on VPN" + fi + else + info "Cannot detect public IP (DNS filtering or no internet)" + info "Not needed for LAN/VPN mode" + fi +fi + +# ── Test 7: Asterisk registration status ───────────────────── +echo "" +echo -e "${BOLD}7. Asterisk Registration Status${NC}" +echo "" + +if command -v asterisk &>/dev/null; then + reg_output=$(asterisk -rx "pjsip show endpoints" 2>/dev/null || echo "") + if [[ -n "$reg_output" ]]; then + online_count=$(echo "$reg_output" | grep -c "Avail" 2>/dev/null || echo 0) + offline_count=$(echo "$reg_output" | grep -c "Unavail" 2>/dev/null || echo 0) + info "Endpoints online: ${online_count}" + info "Endpoints offline: ${offline_count}" + + if [[ "$offline_count" -gt 0 ]]; then + warn "Some endpoints are offline - check VPN connectivity" + echo "$reg_output" | grep "Unavail" | while IFS= read -r line; do + info " Offline: $line" + done + fi + else + info "Asterisk not running or no endpoints configured" + fi +else + info "Asterisk CLI not available" +fi + +# ── Summary ────────────────────────────────────────────────── +print_header "Diagnostic Summary" + +fail_count=0 +pass_count=0 +for result in "${RESULTS[@]}"; do + if [[ "$result" == FAIL* ]]; then + ((fail_count++)) + elif [[ "$result" == PASS* ]]; then + ((pass_count++)) + fi +done + +echo -e " Passed: ${GREEN}${pass_count}${NC}" +echo -e " Failed: ${RED}${fail_count}${NC}" +echo -e " Warnings: ${YELLOW}${#WARNINGS[@]}${NC}" + +if [[ ${#WARNINGS[@]} -gt 0 ]]; then + echo "" + echo -e "${BOLD}Recommendations:${NC}" + for w in "${WARNINGS[@]}"; do + echo -e " ${YELLOW}→${NC} $w" + done +fi + +# ── STUN Recommendation ───────────────────────────────────── +echo "" +echo -e "${BOLD}Do you need STUN?${NC}" +echo "" + +if $vpn_found; then + echo -e " VPN detected on this server." + echo -e " ${GREEN}If your VPN provides direct routing (both sides get VPN IPs),${NC}" + echo -e " ${GREEN}STUN is likely NOT needed.${NC}" + echo "" + echo -e " ${YELLOW}If audio works one-way or not at all, enable STUN:${NC}" + echo -e " 1. docker compose --profile stun up -d (self-hosted STUN)" + echo -e " 2. Or via easy-asterisk: Server Settings → VPN STUN/ICE" +else + echo -e " No VPN interface found on server." + echo -e " ${YELLOW}If VPN runs on router/firewall:${NC}" + echo -e " - Add VPN subnet via: Server Settings → VLAN/VPN Subnets" + echo -e " - If audio still fails, enable STUN for NAT traversal" +fi + +echo "" From dc1552f5d37de5c380a37f85af3ddee01749598f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:49:28 +0000 Subject: [PATCH 15/27] =?UTF-8?q?Fix=20asterisk.sh:=20symlink=E2=86=92cp,?= =?UTF-8?q?=20management=20script=20mount,=20TURN=5FSERVER,=20ports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace symlink with real cp for easy-asterisk-v0.10.0.sh: Docker COPY doesn't reliably follow symlinks; using a real copy is safer. - Add ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro bind mount so the management script can be updated without rebuilding the image. - Add TURN_SERVER to .env (empty in LAN-only mode, domain:3478 in FQDN mode) and reference it in compose instead of building the value inline — fixes malformed "":3478 in LAN-only mode. - Add provisioning ports 8088/8089 to UFW rules; these are Asterisk's built-in HTTP server for Linphone XML provisioning (not the web admin, not Caddy). - Document in README that Caddy has no role in calls: SIP/RTP use host networking. Caddy only proxies the web admin (8080). Provisioning ports (8088/8089) must be accessed directly, not through Caddy. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/asterisk.sh | 46 ++++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/services/asterisk.sh b/services/asterisk.sh index 060bf36..26b3c66 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -197,10 +197,10 @@ install_asterisk() { log_success "Source files copied" - # ── The Dockerfile expects these paths inside the build context ─────────── - # vendor Dockerfile: COPY easy-asterisk-v0.10.0.sh → /usr/local/bin/easy-asterisk - # We copy as easy-asterisk.sh locally, so symlink the expected filename for the build - ln -sf easy-asterisk.sh "$EA_DIR/easy-asterisk-v0.10.0.sh" + # The Dockerfile COPYs easy-asterisk-v0.10.0.sh (the versioned name). + # We keep easy-asterisk.sh as the canonical name and make a real copy + # with the versioned filename so Docker COPY works reliably (no symlinks). + cp "$EA_DIR/easy-asterisk.sh" "$EA_DIR/easy-asterisk-v0.10.0.sh" # ── FQDN setup ──────────────────────────────────────────────────────────── echo "" @@ -264,6 +264,8 @@ services: - asterisk-logs:/var/log/asterisk - asterisk-spool:/var/spool/asterisk - asterisk-lib:/var/lib/asterisk + # Bind-mount the management script so updates don't require a rebuild + - ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro environment: - DOMAIN_NAME=${DOMAIN_NAME} - ENABLE_TLS=${ENABLE_TLS:-y} @@ -272,7 +274,7 @@ services: - HAS_VLANS=${HAS_VLANS:-n} - VLAN_SUBNETS=${VLAN_SUBNETS:-} - TURN_ENABLED=${TURN_ENABLED:-y} - - TURN_SERVER=${DOMAIN_NAME}:${TURN_PORT:-3478} + - TURN_SERVER=${TURN_SERVER} - TURN_USERNAME=${TURN_USERNAME:-easyasterisk} - TURN_PASSWORD=${TURN_PASSWORD} - RTP_START=${RTP_START:-10000} @@ -347,6 +349,10 @@ VLAN_SUBNETS= TURN_USERNAME=easyasterisk TURN_PASSWORD=$TURN_PASSWORD +# TURN server address — auto-set based on FQDN or LAN mode above +# LAN-only: leave empty (coturn not used). FQDN mode: domain:port +TURN_SERVER=$( [[ "$LAN_ONLY" == "true" ]] && echo "" || echo "${DOMAIN_NAME}:3478" ) + # TURN port (change to 3479 if 3478 conflicts with UniFi controller or Mattermost) TURN_PORT=3478 @@ -369,18 +375,22 @@ ENV # ── UFW firewall rules ──────────────────────────────────────────────────── if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then log_info "Opening UFW ports for Asterisk..." - ufw allow 5060/udp comment "Asterisk SIP UDP" >/dev/null - ufw allow 5060/tcp comment "Asterisk SIP TCP" >/dev/null - ufw allow 5061/tcp comment "Asterisk SIP TLS" >/dev/null - ufw allow 8080/tcp comment "Asterisk web admin" >/dev/null - ufw allow 3478/udp comment "coturn STUN/TURN UDP" >/dev/null - ufw allow 3478/tcp comment "coturn STUN/TURN TCP" >/dev/null + ufw allow 5060/udp comment "Asterisk SIP UDP" >/dev/null + ufw allow 5060/tcp comment "Asterisk SIP TCP" >/dev/null + ufw allow 5061/tcp comment "Asterisk SIP TLS" >/dev/null + ufw allow 8080/tcp comment "Asterisk web admin" >/dev/null + ufw allow 8088/tcp comment "Asterisk HTTP provision" >/dev/null + ufw allow 8089/tcp comment "Asterisk HTTPS provision" >/dev/null + ufw allow 3478/udp comment "coturn STUN/TURN UDP" >/dev/null + ufw allow 3478/tcp comment "coturn STUN/TURN TCP" >/dev/null ufw allow 10000:20000/udp comment "Asterisk RTP media" >/dev/null ufw allow 49152:49252/udp comment "coturn TURN relay" >/dev/null log_success "UFW rules added" else log_info "UFW not active — open these ports manually if needed:" - log_info " 5060/udp+tcp, 5061/tcp, 8080/tcp, 3478/udp+tcp" + log_info " 5060/udp+tcp, 5061/tcp" + log_info " 8080/tcp (web admin), 8088/tcp, 8089/tcp (provisioning)" + log_info " 3478/udp+tcp (STUN/TURN)" log_info " 10000-20000/udp (RTP), 49152-49252/udp (TURN relay)" fi @@ -421,6 +431,17 @@ for Linphone (remote provisioning) or Baresip (manual). - **LAN/VPN**: UDP, no encryption — local network or WireGuard/Tailscale - **FQDN**: TLS + SRTP + coturn TURN relay — works from anywhere +## Caddy and phone calls +Asterisk uses **host networking** — SIP signaling and RTP media connect +directly to the server, completely bypassing Caddy. Do NOT put SIP ports +behind a reverse proxy (Contact header rewriting will break registration). + +Caddy only handles the **web admin** (port 8080) for HTTPS browser access. + +The **provisioning server** (ports 8088/8089) is Asterisk's built-in HTTP +server for Linphone XML config delivery. Access it directly by IP/domain, +not through Caddy — SIP clients fetch it at startup before registering. + ## Router port forwards (FQDN mode) | Port | Protocol | Service | |------|----------|---------| @@ -428,6 +449,7 @@ for Linphone (remote provisioning) or Baresip (manual). | 3478 | UDP+TCP | STUN/TURN | | 10000-20000 | UDP | RTP media | | 49152-49252 | UDP | TURN relay | +| 8088 | TCP | Provisioning (Linphone XML) — optional | ## TURN credentials (for SIP clients behind strict NAT) - Server: \${DOMAIN_NAME}:3478 From ab778868dfb2957d8594858ccc766edc373aaee8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 17:58:44 +0000 Subject: [PATCH 16/27] Fix Mattermost Calls, add Authelia to Asterisk web admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mattermost Calls: - Add 8443/udp to compose ports for the Calls plugin RTC server (WebRTC direct path; coturn relay is only the fallback, not the sole path) - Add 8443/udp to UFW rules and router port-forward table - Warn that WebRTC requires HTTPS — calls silently fail over HTTP - Prompt for Caddy domain and update MATTERMOST_SITE_URL in .env to match the HTTPS URL before Caddy is wired (previously SITEURL was written before the domain was known, leaving it as http://localhost:8065) - Update README with RTC server address field and corrected port table Asterisk web admin: - No built-in auth: add Authelia SSO check matching CLAUDE.md pattern - Set WEB_ADMIN_AUTH_DISABLED=true in .env when Authelia handles auth (prevents double-login prompts) https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/asterisk.sh | 16 +++++++- services/mattermost.sh | 90 +++++++++++++++++++++++++++++++++--------- 2 files changed, 85 insertions(+), 21 deletions(-) diff --git a/services/asterisk.sh b/services/asterisk.sh index 26b3c66..9db5797 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -396,8 +396,20 @@ ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR" - # ── Caddy for web admin ─────────────────────────────────────────────────── - configure_caddy_for_service "Asterisk Web Admin" "localhost:8080" "asterisk" + # ── Caddy for web admin (with optional Authelia SSO) ────────────────────── + # The web admin has no built-in auth; let Authelia gate it if available. + local EA_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth + if [[ "$_use_auth" =~ ^[Yy]$ ]]; then + EA_EXTRA_BLOCK=" import authelia" + # Tell Asterisk's web admin to skip its own auth — Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" "$EA_DIR/.env" + log_info "WEB_ADMIN_AUTH_DISABLED=true set (Authelia will handle authentication)" + fi + fi + configure_caddy_for_service "Asterisk Web Admin" "localhost:8080" "asterisk" "$EA_EXTRA_BLOCK" # ── README ──────────────────────────────────────────────────────────────── write_readme "$EA_DIR" << MD diff --git a/services/mattermost.sh b/services/mattermost.sh index b85a58c..5fd5b90 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -232,6 +232,7 @@ services: condition: service_healthy ports: - "8065:8065" + - "8443:8443/udp" # Calls plugin RTC server (WebRTC direct path) volumes: - ./data:/mattermost/data - ./logs:/mattermost/logs @@ -312,16 +313,17 @@ ENV echo "" log_info "Firewall — Mattermost coturn uses port 3479 (avoiding conflict with Easy Asterisk on 3478)." if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then - log_info "Opening UFW ports for Mattermost coturn..." - ufw allow 3479/udp comment "Mattermost coturn STUN/TURN" - ufw allow 3479/tcp comment "Mattermost coturn STUN/TURN TCP" - ufw allow 49153:49352/udp comment "Mattermost coturn relay" + log_info "Opening UFW ports for Mattermost..." + ufw allow 8443/udp comment "Mattermost Calls RTC server" >/dev/null + ufw allow 3479/udp comment "Mattermost coturn STUN/TURN" >/dev/null + ufw allow 3479/tcp comment "Mattermost coturn STUN/TURN" >/dev/null + ufw allow 49153:49352/udp comment "Mattermost coturn relay" >/dev/null log_success "UFW rules added" else log_info "UFW not active — add these rules manually if needed:" - echo " ufw allow 3479/udp comment \"Mattermost coturn STUN/TURN\"" - echo " ufw allow 3479/tcp comment \"Mattermost coturn STUN/TURN TCP\"" - echo " ufw allow 49153:49352/udp comment \"Mattermost coturn relay\"" + echo " ufw allow 8443/udp # Mattermost Calls RTC" + echo " ufw allow 3479/udp && ufw allow 3479/tcp # coturn STUN/TURN" + echo " ufw allow 49153:49352/udp # coturn relay" fi # ── Router port-forward instructions ────────────────────────────────────── @@ -331,15 +333,62 @@ ENV echo " ├──────────────────┬──────────┬──────────────────────────────────┤" echo " │ Port(s) │ Protocol │ Service │" echo " ├──────────────────┼──────────┼──────────────────────────────────┤" + echo " │ 8443 │ UDP │ Calls plugin RTC (direct WebRTC) │" echo " │ 3479 │ UDP+TCP │ coturn STUN/TURN │" echo " │ 49153–49352 │ UDP │ coturn relay range │" echo " └──────────────────┴──────────┴──────────────────────────────────┘" echo "" + echo " ⚠ WebRTC (Calls) requires HTTPS. Calls will not work if Mattermost" + echo " is accessed over plain HTTP. Configure Caddy with a domain below." + echo "" ensure_docker_dir_ownership "$DIR" # ── Caddy reverse proxy ─────────────────────────────────────────────────── - configure_caddy_for_service "Mattermost" "mattermost:8065" "chat" + # Mattermost's SITEURL must match the public URL for WebRTC (Calls) to work. + # If the user configures a Caddy domain here, update SITEURL in .env to match. + if [ -d "$DOCKER_DIR/caddy" ]; then + local _mm_domain="" + prompt_text "Caddy domain for Mattermost (e.g. chat.${SITE_DOMAIN:-example.com}) [skip]:" "" _mm_domain + if [[ -n "$_mm_domain" ]]; then + # Update SITEURL before wiring Caddy so the running container gets the right value + sed -i "s|^MATTERMOST_SITE_URL=.*|MATTERMOST_SITE_URL=https://$_mm_domain|" "$DIR/.env" + log_info "SITEURL updated → https://$_mm_domain (WebRTC requires HTTPS)" + # Write Caddyfile block directly (configure_caddy_for_service would prompt again) + local _caddyfile="$DOCKER_DIR/caddy/Caddyfile" + local _bk="$DOCKER_DIR/caddy/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + [[ -f "$_caddyfile" ]] && cp "$_caddyfile" "$_bk" && log_info "Backed up Caddyfile" + if grep -q "^${_mm_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_mm_domain already in Caddyfile — skipping block write" + else + cat >> "$_caddyfile" << MMCADDY + +# Mattermost +$_mm_domain { + reverse_proxy mattermost:8065 + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_mm_domain}.log + format json + } +} +MMCADDY + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "Mattermost accessible at: https://$_mm_domain" + else + log_warning "Caddy reload failed — check: docker logs caddy" + fi + fi + fi + fi # ── README ──────────────────────────────────────────────────────────────── write_readme "$DIR" << MD @@ -358,26 +407,29 @@ The first user to sign up becomes the System Admin. ## Calls plugin (voice/video) The Mattermost Calls plugin provides voice/video channels. +**WebRTC requires HTTPS** — calls will not work over plain HTTP. ### Enable the plugin 1. Go to **System Console → Plugins → Plugin Management** 2. Enable the **Calls** plugin (pre-installed in Team Edition) -### Configure TURN server +### Configure ICE / TURN server 1. Go to **System Console → Plugins → Calls** -2. Set **TURN server URL**: \`turn::3479\` -3. Set **TURN credentials type**: Static credentials (auth secret) -4. Set **TURN static auth secret**: (see TURN_SECRET in \`$DIR/.env\`) -5. Save and test a call +2. Set **RTC Server Address**: your server's public IP or domain +3. Set **TURN server URL**: \`turn::3479\` +4. Set **TURN credentials type**: Static credentials (auth secret) +5. Set **TURN static auth secret**: (see \`TURN_SECRET\` in \`$DIR/.env\`) +6. Save and test a call in a channel -Clients outside your LAN need the TURN server to relay media. The coturn -container listens on port 3479 (UDP+TCP) with relay range 49153–49352/UDP. +Direct WebRTC (port 8443/UDP) is tried first; coturn relay is the fallback +for clients behind strict NAT (cellular, hotel WiFi, Proton VPN, etc.). ## Router port-forwards (for external calls) -| Port(s) | Protocol | Service | -|--------------|----------|--------------------| -| 3479 | UDP+TCP | coturn STUN/TURN | -| 49153–49352 | UDP | coturn relay range | +| Port(s) | Protocol | Service | +|--------------|-----------|---------------------------------| +| 8443 | UDP | Calls plugin RTC (direct path) | +| 3479 | UDP+TCP | coturn STUN/TURN | +| 49153–49352 | UDP | coturn relay range | ## Manage \`\`\`bash From e9f05cc127fac815989ed54797ca2e37494f53e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 18:14:40 +0000 Subject: [PATCH 17/27] Use stored SITE_DOMAIN as default in Caddy domain prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lib/common.sh: configure_caddy_for_service now pre-fills the domain prompt with $DEFAULT_SUBDOMAIN.$SITE_DOMAIN when a site domain has been configured (setup.sh configure / ~/docker/.config). No more typing the full domain for every service — just press Enter to accept the default. services/mattermost.sh: remove redundant custom Caddy/domain block added in the previous commit. MATTERMOST_SITE_URL is already computed from SITE_DOMAIN before configure_caddy_for_service is called, so the simple call is sufficient. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- lib/common.sh | 10 +++++++-- services/mattermost.sh | 47 +++--------------------------------------- 2 files changed, 11 insertions(+), 46 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 45d1f40..c2d1fe8 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -297,10 +297,16 @@ configure_caddy_for_service() { echo "" echo "Enter the full domain for $SERVICE_NAME:" - echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" + local _default_domain="" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + _default_domain="$DEFAULT_SUBDOMAIN.$SITE_DOMAIN" + echo " Default: $_default_domain" + else + echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" + fi echo "" local SERVICE_DOMAIN="" - prompt_text "Domain:" "" SERVICE_DOMAIN + prompt_text "Domain [${_default_domain:-required}]:" "$_default_domain" SERVICE_DOMAIN if [ -z "$SERVICE_DOMAIN" ]; then echo " ⚠ No domain provided, skipping Caddy configuration."; return 0 fi diff --git a/services/mattermost.sh b/services/mattermost.sh index 5fd5b90..5690bd1 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -345,50 +345,9 @@ ENV ensure_docker_dir_ownership "$DIR" # ── Caddy reverse proxy ─────────────────────────────────────────────────── - # Mattermost's SITEURL must match the public URL for WebRTC (Calls) to work. - # If the user configures a Caddy domain here, update SITEURL in .env to match. - if [ -d "$DOCKER_DIR/caddy" ]; then - local _mm_domain="" - prompt_text "Caddy domain for Mattermost (e.g. chat.${SITE_DOMAIN:-example.com}) [skip]:" "" _mm_domain - if [[ -n "$_mm_domain" ]]; then - # Update SITEURL before wiring Caddy so the running container gets the right value - sed -i "s|^MATTERMOST_SITE_URL=.*|MATTERMOST_SITE_URL=https://$_mm_domain|" "$DIR/.env" - log_info "SITEURL updated → https://$_mm_domain (WebRTC requires HTTPS)" - # Write Caddyfile block directly (configure_caddy_for_service would prompt again) - local _caddyfile="$DOCKER_DIR/caddy/Caddyfile" - local _bk="$DOCKER_DIR/caddy/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - [[ -f "$_caddyfile" ]] && cp "$_caddyfile" "$_bk" && log_info "Backed up Caddyfile" - if grep -q "^${_mm_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_mm_domain already in Caddyfile — skipping block write" - else - cat >> "$_caddyfile" << MMCADDY - -# Mattermost -$_mm_domain { - reverse_proxy mattermost:8065 - - header { - Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - X-Content-Type-Options "nosniff" - X-Frame-Options "SAMEORIGIN" - Referrer-Policy "strict-origin-when-cross-origin" - } - - log { - output file /var/log/caddy/${_mm_domain}.log - format json - } -} -MMCADDY - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "Mattermost accessible at: https://$_mm_domain" - else - log_warning "Caddy reload failed — check: docker logs caddy" - fi - fi - fi - fi + # SITEURL is already set from SITE_DOMAIN above. configure_caddy_for_service + # will pre-fill the domain prompt with chat.$SITE_DOMAIN. + configure_caddy_for_service "Mattermost" "mattermost:8065" "chat" # ── README ──────────────────────────────────────────────────────────────── write_readme "$DIR" << MD From 9f0ff0bb9f733834169fd11a3bacfb2ef31164fc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 18:53:17 +0000 Subject: [PATCH 18/27] Add write_readme, Caddy, and start prompt to wolf/homeassistant/js99er/minecraft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wolf.sh: add Authelia SSO check, configure_caddy_for_service, write_readme, and prompt to start Wolf (it had none of these) - homeassistant.sh: add write_readme with access URL and manage commands - js99er.sh: add write_readme with access URL and manage commands - minecraft.sh: add write_readme with manage and backup commands Completes the service audit — all 49 services now have full interactive setup, standalone bootstrap, and self-documenting README in the deploy directory. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/homeassistant.sh | 19 ++++++++++++++ services/js99er.sh | 19 +++++++++++++- services/minecraft.sh | 19 ++++++++++++++ services/wolf.sh | 55 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 111 insertions(+), 1 deletion(-) diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 7087d15..56317c3 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -242,6 +242,25 @@ HA_CONFIG configure_caddy_for_service "Home Assistant" "homeassistant:8123" "home" fi + write_readme "$HOMEASSISTANT_DIR" << MD +# Home Assistant + +Home automation hub. Built-in auth — no Authelia needed. + +## Access +- URL: http://localhost:8123 +- First run: create your admin account through the onboarding wizard + +## Manage +\`\`\`bash +cd $HOMEASSISTANT_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + local START_HA="" prompt_yn "Start Home Assistant now? (y/n):" "y" START_HA if [ "$START_HA" = "y" ] || [ "$START_HA" = "Y" ]; then diff --git a/services/js99er.sh b/services/js99er.sh index 5377027..b68ed73 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -442,7 +442,24 @@ COMPOSE fi fi - # ── 7. Access summary ──────────────────────────────────────────────────── + write_readme "$JS99ER_DIR" << MD +# js99er — TI-99/4A Emulator + +Browser-based TI-99/4A emulator. No built-in auth — protect with Authelia if exposing externally. + +## Access +- URL: http://localhost:${JS99ER_PORT} +- Online (no install): https://js99er.net + +## Manage +\`\`\`bash +cd $JS99ER_DIR +docker compose up -d --build # start (or rebuild) +docker compose down # stop +docker compose logs -f # logs +\`\`\` +MD + echo "" echo " Access at: http://localhost:${JS99ER_PORT}" echo " If you set a domain above, it is also reachable via that domain (HTTPS)." diff --git a/services/minecraft.sh b/services/minecraft.sh index a855b56..0c1d033 100644 --- a/services/minecraft.sh +++ b/services/minecraft.sh @@ -2489,6 +2489,25 @@ NETEOF echo " Run: sudo ./setup.sh backup" echo "" + write_readme "$MC_DIR" << MD +# Minecraft — ${MC_NAME} + +## Manage +\`\`\`bash +cd $MC_DIR +docker compose up -d --build # start (builds image on first run) +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d --build # update +\`\`\` + +## Backups +World data lives in \`./data\` — covered by Kopia/Borg if installed. +\`\`\`bash +sudo ./setup.sh backup +\`\`\` +MD + # ── Optional: start server and run pre-gen now ────────────────────────────── local START_MC="" prompt_yn "Start the Minecraft server now? (first build takes a few minutes) (y/n) [y]:" "y" START_MC diff --git a/services/wolf.sh b/services/wolf.sh index 0573129..aee0d4d 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -927,6 +927,61 @@ PYEOF echo " Set up automatic backups with the backup module:" echo " sudo ./setup.sh backup" echo "" + # Wolf's web UI (pair/manage) has no built-in auth — protect with Authelia if available + local WOLF_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Wolf web UI with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && WOLF_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "Wolf" "wolf:47990" "wolf" "$WOLF_EXTRA_BLOCK" + + write_readme "$WOLF_DIR" << MD +# Wolf — Cloud Gaming (Games-on-Whales) + +Stream games to any Moonlight client over your LAN or Tailscale VPN. + +## Pair a new client +1. Open Moonlight on the client device +2. Add host: this server's IP +3. Run the pin command on the server: +\`\`\`bash +cd $WOLF_DIR && ./manage.sh pin +\`\`\` + +## Manage +\`\`\`bash +cd $WOLF_DIR +./manage.sh start # start Wolf +./manage.sh stop # stop +./manage.sh restart # restart +./manage.sh logs # live logs +./manage.sh status # container status +./manage.sh update # pull latest image and restart +./manage.sh add-apps # add game launchers +\`\`\` + +## Ports (open on firewall / router) +| Port(s) | Protocol | Use | +|---------|----------|-----| +| 47984–47990 | TCP | Moonlight control | +| 48010 | TCP | RTSP | +| 47998–48000 | UDP | RTP video/audio/control | + +## Backup +\`\`\`bash +sudo ./setup.sh backup # covers /etc/wolf saves and ES-DE settings +\`\`\` +MD + + local START_WOLF="" + prompt_yn "Start Wolf now? (y/n):" "y" START_WOLF + if [[ "$START_WOLF" =~ ^[Yy]$ ]]; then + docker compose up -d \ + && log_success "Wolf started — pair Moonlight to this server's IP" \ + || log_warning "Start failed — check: docker compose logs" + fi + log_success "Done. Pair Moonlight and play." } From 551c254b89844d8713d3c7fc53fe4d3bda2ca141 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 19:02:00 +0000 Subject: [PATCH 19/27] Add Authelia SSO prompt to js99er, magicmirror, wolf-pair These services have no built-in auth. Per CLAUDE.md they should check for Authelia and offer to protect them with SSO before calling configure_caddy_for_service. Adds the standard prompt_yn + import authelia extra block pattern to all three. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/js99er.sh | 8 +++++++- services/magicmirror.sh | 10 +++++++++- services/wolf-pair.sh | 8 +++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/services/js99er.sh b/services/js99er.sh index b68ed73..3ee645b 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -420,7 +420,13 @@ COMPOSE log_success "js99er configured at $JS99ER_DIR" # ── 5. Reverse proxy (no-ops if Caddy isn't installed locally) ─────────── - configure_caddy_for_service "js99er" "js99er:80" "js99er" + local JS99ER_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect js99er with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && JS99ER_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "js99er" "js99er:80" "js99er" "$JS99ER_EXTRA_BLOCK" # ── 6. Build & start ───────────────────────────────────────────────────── local START_JS99ER="" diff --git a/services/magicmirror.sh b/services/magicmirror.sh index ab78ba2..6e1bd14 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -283,7 +283,15 @@ MM_COMPOSE log_success "MagicMirror instance $i configured at $MM_DIR (port $MM_PORT)" # Offer Caddy only for first instance - [ "$i" -eq 1 ] && configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" + if [ "$i" -eq 1 ]; then + local MM_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && MM_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "MagicMirror" "magicmirror-${MM_PORT}:8080" "mirror" "$MM_EXTRA_BLOCK" + fi local START_MM="" prompt_yn "Start instance $i now? (y/n):" "y" START_MM diff --git a/services/wolf-pair.sh b/services/wolf-pair.sh index e1acf02..f549472 100644 --- a/services/wolf-pair.sh +++ b/services/wolf-pair.sh @@ -399,7 +399,13 @@ COMPOSE fi # ── 5. Caddy (optional) ─────────────────────────────────────────────────── - configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" + local WOLFPAIR_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect wolf-pair with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && WOLFPAIR_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "wolf-pair" "$WOLFPAIR_PORT" "wolf-pair" "$WOLFPAIR_EXTRA_BLOCK" # ── 6. README ───────────────────────────────────────────────────────────── write_readme "$WOLFPAIR_DIR" << 'MD' From e95e82be2f9f0c0771f15bae2bddd9be62976888 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 19:13:09 +0000 Subject: [PATCH 20/27] Fix functional bugs found in service audit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wg-easy: PASSWORD env var removed in v14+; generate bcrypt hash at install time via 'docker run wg-easy wgpw' and write PASSWORD_HASH instead. ntfy: write config/server.yml with base-url, cache-file, auth-file, and behind-proxy:true so push notification links work when behind Caddy. auth-default-access: deny-all (require topic auth). mealie: BASE_URL was hardcoded to http://localhost:9925; email links and OAuth redirects broke when served via Caddy. Now computed from SITE_DOMAIN and written to .env so it's easy to update. nextcloud: add OVERWRITEPROTOCOL=https, OVERWRITECLIURL, TRUSTED_PROXIES to .env so share links and internal redirects use https:// behind Caddy. onlyoffice: Caddy's default X-Frame-Options: SAMEORIGIN header blocked OnlyOffice from being embedded as an iframe in Nextcloud. Override it in the Caddy site block to allow framing. vaultwarden: remove exposed port 3012 (WebSocket — not needed since v1.29+, all handled on port 80). Publish port 8888 for direct host access instead. Remove WEBSOCKET_ENABLED=true (ignored in current versions). https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/mealie.sh | 16 +++++++++++++++- services/nextcloud.sh | 6 ++++++ services/ntfy.sh | 22 ++++++++++++++++++++++ services/onlyoffice.sh | 8 +++++++- services/vaultwarden.sh | 6 +----- services/wg-easy.sh | 26 +++++++++++++++++++++----- 6 files changed, 72 insertions(+), 12 deletions(-) diff --git a/services/mealie.sh b/services/mealie.sh index 05a6a27..b8fa9b2 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -169,6 +169,13 @@ install_mealie() { TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" UID_VAL=$(id -u "$ACTUAL_USER"); GID_VAL=$(id -g "$ACTUAL_USER") + # BASE_URL must match the public URL Mealie is served on (used for email links, + # OAuth redirects, and the web app manifest). Default to SITE_DOMAIN if set. + local MEALIE_BASE_URL="http://localhost:9925" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + MEALIE_BASE_URL="https://recipes.${SITE_DOMAIN}" + fi + cat > docker-compose.yml << MEALIE_COMPOSE name: mealie @@ -178,6 +185,7 @@ services: container_name: mealie hostname: mealie restart: unless-stopped + env_file: .env environment: - PUID=$UID_VAL - PGID=$GID_VAL @@ -185,7 +193,6 @@ services: - ALLOW_SIGNUP=true - MAX_WORKERS=1 - WEB_CONCURRENCY=1 - - BASE_URL=http://localhost:9925 volumes: - ./data:/app/data ports: @@ -199,6 +206,13 @@ networks: name: \${CADDY_NET:-caddy_net} MEALIE_COMPOSE + cat > .env << MEALIE_ENV +# Public URL Mealie is served on — used for email links and OAuth redirects. +# Update if you change your domain or switch from HTTP to HTTPS. +BASE_URL=$MEALIE_BASE_URL +CADDY_NET=$SITE_CADDY_NET +MEALIE_ENV + mkdir -p data chown -R "$ACTUAL_USER:$ACTUAL_USER" "$MEALIE_DIR" log_success "Mealie configured at $MEALIE_DIR" diff --git a/services/nextcloud.sh b/services/nextcloud.sh index adc6645..077fcd6 100644 --- a/services/nextcloud.sh +++ b/services/nextcloud.sh @@ -257,6 +257,12 @@ NEXTCLOUD_ADMIN_USER=admin NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS NEXTCLOUD_DB_TYPE=mysql MYSQL_HOST=db + +# ── Reverse proxy trust (required when behind Caddy) ───────────────────────── +# Without these, share links use http:// and internal redirects may break. +OVERWRITEPROTOCOL=https +OVERWRITECLIURL=https://cloud.${SITE_DOMAIN:-example.com} +TRUSTED_PROXIES=172.16.0.0/12 NC_ENV chmod 600 .env diff --git a/services/ntfy.sh b/services/ntfy.sh index 8ab453f..9a959f5 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -201,6 +201,28 @@ CADDY_NET=$SITE_CADDY_NET NTFY_ENV mkdir -p cache config + + # Write server.yml — ntfy needs base-url for correct push notification links + # Only on fresh install; never clobber existing config + if [ ! -f config/server.yml ]; then + local NTFY_BASE_URL="" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + NTFY_BASE_URL="https://ntfy.${SITE_DOMAIN}" + fi + cat > config/server.yml << NTFY_CFG +# ntfy server configuration — https://docs.ntfy.sh/config/ +base-url: "${NTFY_BASE_URL:-https://ntfy.example.com}" # UPDATE to your actual domain +cache-file: /var/cache/ntfy/cache.db +cache-duration: 12h +auth-file: /var/cache/ntfy/auth.db +auth-default-access: deny-all +behind-proxy: true +NTFY_CFG + [[ -n "$NTFY_BASE_URL" ]] \ + && log_info "base-url set to $NTFY_BASE_URL — update if domain changes" \ + || log_warning "base-url set to placeholder — edit config/server.yml after install" + fi + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$NTFY_DIR" echo "" diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh index 12206b6..481b884 100644 --- a/services/onlyoffice.sh +++ b/services/onlyoffice.sh @@ -299,7 +299,13 @@ OO_ENV chmod 600 .env chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" - configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" + # OnlyOffice must be embeddable as an iframe in Nextcloud/FileBrowser. + # Override X-Frame-Options to allow same-site embedding (remove SAMEORIGIN restriction). + local OO_EXTRA_BLOCK=' header { + -X-Frame-Options + Content-Security-Policy "frame-ancestors '\''self'\'' *" + }' + configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" "$OO_EXTRA_BLOCK" local START="" prompt_yn "Start OnlyOffice now? (y/n):" "y" START diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index 7aa8fad..682218a 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -220,10 +220,8 @@ services: env_file: .env volumes: - ./vaultwarden_data:/data - expose: - - "80" ports: - - "3012:3012" # WebSocket (legacy — not needed for Vaultwarden v1.29+) + - "8888:80" networks: - caddy_net @@ -250,8 +248,6 @@ ADMIN_TOKEN=$ADMIN_TOKEN SIGNUPS_ALLOWED=false SIGNUPS_VERIFY=false -# WebSocket notifications (v1.29+: built into port 80, no separate port needed) -WEBSOCKET_ENABLED=true # ── SMTP (optional — for password-reset and invite emails) ──────────────────── SMTP_HOST=$SMTP_HOST diff --git a/services/wg-easy.sh b/services/wg-easy.sh index 22ba149..9ae48d3 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -181,13 +181,26 @@ install_wg-easy() { cd "$WGEASY_DIR" || return 1 # Auto-detect public IP as default for WG_HOST - local PUBLIC_IP WG_HOST WG_PASSWORD + local PUBLIC_IP WG_HOST WG_PASSWORD WG_PASSWORD_HASH PUBLIC_IP=$(curl -s --connect-timeout 5 ifconfig.me 2>/dev/null || echo "your-public-ip") WG_PASSWORD=$(openssl rand -base64 16 | tr -dc 'a-zA-Z0-9' | head -c 16) prompt_text "Public IP or hostname for VPN [$PUBLIC_IP]:" "$PUBLIC_IP" WG_HOST - cat > docker-compose.yml << 'WGEASY_COMPOSE' + # wg-easy v14+ requires PASSWORD_HASH (bcrypt). Generate via docker. + log_info "Generating bcrypt password hash (requires Docker)..." + WG_PASSWORD_HASH=$(docker run --rm ghcr.io/wg-easy/wg-easy:latest wgpw "$WG_PASSWORD" 2>/dev/null \ + | grep -oP '\$2[ab]\$[^\s]+' | head -1) + if [[ -z "$WG_PASSWORD_HASH" ]]; then + log_warning "Could not generate bcrypt hash — falling back to plaintext PASSWORD env var." + log_warning "If wg-easy fails to start, run: docker run --rm ghcr.io/wg-easy/wg-easy wgpw 'yourpassword'" + log_warning "Then set PASSWORD_HASH in docker-compose.yml and remove PASSWORD." + fi + + # Escape $ in hash for docker-compose env (bcrypt hashes contain $$) + local WG_HASH_ESCAPED="${WG_PASSWORD_HASH//\$/\$\$}" + + cat > docker-compose.yml << WGEASY_COMPOSE name: wg-easy services: @@ -203,8 +216,8 @@ services: - net.ipv4.ip_forward=1 - net.ipv4.conf.all.src_valid_mark=1 environment: - - WG_HOST=${WG_HOST} - - PASSWORD=${WG_PASSWORD} + - WG_HOST=\${WG_HOST} + - PASSWORD_HASH=${WG_HASH_ESCAPED:-\${WG_PASSWORD}} - WG_DEFAULT_DNS=1.1.1.1 volumes: - ./config:/etc/wireguard @@ -217,11 +230,12 @@ services: networks: caddy_net: external: true - name: ${CADDY_NET:-caddy_net} + name: \${CADDY_NET:-caddy_net} WGEASY_COMPOSE cat > .env << WGEASY_ENV WG_HOST=$WG_HOST +# Plain-text password — used only if PASSWORD_HASH could not be generated above WG_PASSWORD=$WG_PASSWORD CADDY_NET=$SITE_CADDY_NET WGEASY_ENV @@ -270,6 +284,8 @@ MD echo "" echo " Web UI: http://localhost:51821" echo " Password: $WG_PASSWORD (saved in .env)" + [[ -n "$WG_PASSWORD_HASH" ]] && echo " Auth: bcrypt hash configured (v14+ compatible)" \ + || echo " Auth: WARNING — bcrypt hash generation failed; see README" echo " Router: forward UDP 51820 → this server for external VPN access" echo "" } From ec3f9bfd3f5b2616c9d380f69e33ebd709cb0f9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 21:10:37 +0000 Subject: [PATCH 21/27] =?UTF-8?q?Add=20remote=20Caddy=20support=20?= =?UTF-8?q?=E2=80=94=20generate=20snippet=20files=20when=20Caddy=20is=20on?= =?UTF-8?q?=20another=20host?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New site config key: CADDY_REMOTE_HOST (set via 'sudo ./setup.sh configure'). When set, configure_caddy_for_service operates in "remote" mode instead of writing to a local Caddyfile: - Upstream uses CADDY_REMOTE_HOST:PORT (host IP, not container name) - Snippet saved to ~/docker/caddy-snippets/.caddy - User is shown scp/rsync commands to copy it to the Caddy machine Three modes in configure_caddy_for_service (lib/common.sh and inline stubs): local: ~/docker/caddy/ exists → write Caddyfile + reload (existing behavior) remote: CADDY_REMOTE_HOST set → save snippet, print copy instructions none: neither configured → silent return (unchanged) All 31 service standalone bootstrap stubs updated with the new logic. CADDY_REMOTE_HOST global added to all 42 standalone bootstrap sections. setup.sh configure now prompts for CADDY_REMOTE_HOST with a clear explanation. wolf.sh: add missing stubs (configure_caddy_for_service, write_readme, prompt_yn, ensure_docker_dir_ownership) and the Authelia/Caddy/start calls that were missing from the install function. https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- lib/common.sh | 138 +++++++++++++++++++++++++------------ services/actualbudget.sh | 106 ++++++++++++++++++---------- services/arm.sh | 108 +++++++++++++++++++---------- services/audiobookshelf.sh | 106 ++++++++++++++++++---------- services/authelia.sh | 108 +++++++++++++++++++---------- services/backup.sh | 108 +++++++++++++++++++---------- services/borg-backup.sh | 108 +++++++++++++++++++---------- services/caddy.sh | 108 +++++++++++++++++++---------- services/crowdsec.sh | 1 + services/ddclient.sh | 108 +++++++++++++++++++---------- services/emby.sh | 108 +++++++++++++++++++---------- services/filebrowser.sh | 106 ++++++++++++++++++---------- services/fmd.sh | 108 +++++++++++++++++++---------- services/frigate-audio.sh | 108 +++++++++++++++++++---------- services/frigate-notify.sh | 108 +++++++++++++++++++---------- services/frigate.sh | 108 +++++++++++++++++++---------- services/gaming-backup.sh | 1 + services/gatus.sh | 108 +++++++++++++++++++---------- services/homeassistant.sh | 108 +++++++++++++++++++---------- services/immich.sh | 106 ++++++++++++++++++---------- services/jellyfin.sh | 106 ++++++++++++++++++---------- services/js99er.sh | 107 ++++++++++++++++++---------- services/lyrion.sh | 108 +++++++++++++++++++---------- services/magicmirror.sh | 108 +++++++++++++++++++---------- services/mail-archiver.sh | 108 +++++++++++++++++++---------- services/mealie.sh | 106 ++++++++++++++++++---------- services/meshcentral.sh | 108 +++++++++++++++++++---------- services/minecraft.sh | 1 + services/ntfy.sh | 108 +++++++++++++++++++---------- services/portainer.sh | 106 ++++++++++++++++++---------- services/rustdesk.sh | 1 + services/silent-send.sh | 1 + services/sky-cam.sh | 1 + services/sync-cc.sh | 1 + services/traccar.sh | 108 +++++++++++++++++++---------- services/unifi.sh | 1 + services/uptimekuma.sh | 108 +++++++++++++++++++---------- services/vaultwarden.sh | 108 +++++++++++++++++++---------- services/watchtower.sh | 1 + services/watchyourlan.sh | 1 + services/wg-easy.sh | 108 +++++++++++++++++++---------- services/wolf-pair.sh | 108 +++++++++++++++++++---------- services/wolf.sh | 124 +++++++++++++++++++++++++++++++++ setup.sh | 11 ++- 44 files changed, 2439 insertions(+), 1177 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index c2d1fe8..cbf08f8 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -56,6 +56,7 @@ SITE_DOMAIN="" SITE_CADDY_NET="caddy_net" SITE_PUID="" SITE_PGID="" +CADDY_REMOTE_HOST="" # LAN IP/hostname of this machine, used when Caddy runs elsewhere load_site_config() { local cfg="$DOCKER_DIR/.config" @@ -67,13 +68,14 @@ load_site_config() { case "$key" in SITE_TZ) SITE_TZ="$val" ;; SITE_DOMAIN) SITE_DOMAIN="$val" ;; - SITE_CADDY_NET) SITE_CADDY_NET="$val" ;; - SITE_PUID) SITE_PUID="$val" ;; - SITE_PGID) SITE_PGID="$val" ;; - BASE_DOMAIN) [ -z "$SITE_DOMAIN" ] && SITE_DOMAIN="$val" ;; + SITE_CADDY_NET) SITE_CADDY_NET="$val" ;; + SITE_PUID) SITE_PUID="$val" ;; + SITE_PGID) SITE_PGID="$val" ;; + CADDY_REMOTE_HOST) CADDY_REMOTE_HOST="$val" ;; + BASE_DOMAIN) [ -z "$SITE_DOMAIN" ] && SITE_DOMAIN="$val" ;; esac done < "$cfg" - export SITE_TZ SITE_DOMAIN SITE_CADDY_NET SITE_PUID SITE_PGID + export SITE_TZ SITE_DOMAIN SITE_CADDY_NET SITE_PUID SITE_PGID CADDY_REMOTE_HOST } save_site_config() { @@ -85,10 +87,11 @@ save_site_config() { [ -n "$SITE_TZ" ] && echo "SITE_TZ=$SITE_TZ" [ -n "$SITE_DOMAIN" ] && echo "SITE_DOMAIN=$SITE_DOMAIN" [ -n "$SITE_CADDY_NET" ] && echo "SITE_CADDY_NET=$SITE_CADDY_NET" - [ -n "$SITE_PUID" ] && echo "SITE_PUID=$SITE_PUID" - [ -n "$SITE_PGID" ] && echo "SITE_PGID=$SITE_PGID" + [ -n "$SITE_PUID" ] && echo "SITE_PUID=$SITE_PUID" + [ -n "$SITE_PGID" ] && echo "SITE_PGID=$SITE_PGID" + [ -n "$CADDY_REMOTE_HOST" ] && echo "CADDY_REMOTE_HOST=$CADDY_REMOTE_HOST" # Backward-compat alias for services that still read BASE_DOMAIN directly - [ -n "$SITE_DOMAIN" ] && echo "BASE_DOMAIN=$SITE_DOMAIN" + [ -n "$SITE_DOMAIN" ] && echo "BASE_DOMAIN=$SITE_DOMAIN" } > "$cfg" chmod 600 "$cfg" } @@ -276,15 +279,27 @@ configure_caddy_for_service() { *) _UPSTREAM="localhost:$SERVICE_UPSTREAM"; _DISPLAY_PORT="$SERVICE_UPSTREAM" ;; esac - # Caddy not installed → nothing to do - [ -d "$DOCKER_DIR/caddy" ] || return 0 + # ── Determine Caddy mode ────────────────────────────────────────────────── + # local: Caddy container running on this machine → write Caddyfile + reload + # remote: Caddy on another machine → generate snippet file to copy over + # none: no Caddy anywhere → silent return + local _CADDY_MODE="none" + [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" + [ -n "$CADDY_REMOTE_HOST" ] && [ "$_CADDY_MODE" != "local" ] && _CADDY_MODE="remote" + [ "$_CADDY_MODE" = "none" ] && return 0 echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " CADDY REVERSE PROXY CONFIGURATION" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" - echo "Caddy is installed. You can configure a reverse proxy for $SERVICE_NAME." + if [ "$_CADDY_MODE" = "remote" ]; then + echo " Remote Caddy configured ($CADDY_REMOTE_HOST)." + echo " A snippet file will be saved to ~/docker/caddy-snippets/ for you to" + echo " copy to your Caddy machine." + else + echo "Caddy is installed. You can configure a reverse proxy for $SERVICE_NAME." + fi echo "" local CONFIGURE_CADDY="" @@ -295,14 +310,14 @@ configure_caddy_for_service() { return 0 fi + # Domain prompt — pre-fill from SITE_DOMAIN when available echo "" - echo "Enter the full domain for $SERVICE_NAME:" local _default_domain="" if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then - _default_domain="$DEFAULT_SUBDOMAIN.$SITE_DOMAIN" + _default_domain="${DEFAULT_SUBDOMAIN}.${SITE_DOMAIN}" echo " Default: $_default_domain" else - echo " Examples: $DEFAULT_SUBDOMAIN.example.com, $DEFAULT_SUBDOMAIN.yourdomain.com" + echo " Examples: ${DEFAULT_SUBDOMAIN}.example.com, ${DEFAULT_SUBDOMAIN}.yourdomain.com" fi echo "" local SERVICE_DOMAIN="" @@ -311,33 +326,19 @@ configure_caddy_for_service() { echo " ⚠ No domain provided, skipping Caddy configuration."; return 0 fi - local CADDY_DIR="$DOCKER_DIR/caddy" - local CADDYFILE="$CADDY_DIR/Caddyfile" - local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - - if [ -f "$CADDYFILE" ]; then - echo " Backing up Caddyfile to: $(basename "$BACKUP_FILE")" - cp "$CADDYFILE" "$BACKUP_FILE" - else - echo " Creating new Caddyfile"; touch "$CADDYFILE" + # Build the site block — upstream differs by mode + local _BLOCK_UPSTREAM="$_UPSTREAM" + if [ "$_CADDY_MODE" = "remote" ]; then + # Remote Caddy can't resolve Docker container names — use host IP + published port + _BLOCK_UPSTREAM="${CADDY_REMOTE_HOST}:${_DISPLAY_PORT}" fi - if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then - echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" - local OVERWRITE="" - prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE - if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then - echo " Keeping existing configuration."; return 0 - fi - sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" - fi - - echo " Adding $SERVICE_NAME configuration to Caddyfile..." - cat >> "$CADDYFILE" << CADDY_BLOCK + local _SITE_BLOCK + _SITE_BLOCK="$(cat << CADDY_BLOCK # $SERVICE_NAME -$SERVICE_DOMAIN { - reverse_proxy $_UPSTREAM +${SERVICE_DOMAIN} { + reverse_proxy ${_BLOCK_UPSTREAM} # Security headers header { @@ -352,18 +353,65 @@ $SERVICE_DOMAIN { output file /var/log/caddy/${SERVICE_DOMAIN}.log format json } -$EXTRA_CONFIG +${EXTRA_CONFIG} } CADDY_BLOCK +)" - echo " ✓ Configuration added to Caddyfile" - echo " Reloading Caddy configuration..." - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - echo " ✓ $SERVICE_NAME is now accessible at: https://$SERVICE_DOMAIN" + # ── Local Caddy: write to Caddyfile and reload ──────────────────────────── + if [ "$_CADDY_MODE" = "local" ]; then + local CADDY_DIR="$DOCKER_DIR/caddy" + local CADDYFILE="$CADDY_DIR/Caddyfile" + local BACKUP_FILE="$CADDY_DIR/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + + if [ -f "$CADDYFILE" ]; then + echo " Backing up Caddyfile to: $(basename "$BACKUP_FILE")" + cp "$CADDYFILE" "$BACKUP_FILE" + else + echo " Creating new Caddyfile"; touch "$CADDYFILE" + fi + + if grep -q "^${SERVICE_DOMAIN}" "$CADDYFILE" 2>/dev/null; then + echo " ⚠ $SERVICE_DOMAIN already exists in Caddyfile" + local OVERWRITE="" + prompt_yn "Overwrite existing configuration? (y/n):" "n" OVERWRITE + if [ "$OVERWRITE" != "y" ] && [ "$OVERWRITE" != "Y" ]; then + echo " Keeping existing configuration."; return 0 + fi + sed -i "/^${SERVICE_DOMAIN}/,/^}/d" "$CADDYFILE" + fi + + echo " Adding $SERVICE_NAME configuration to Caddyfile..." + printf '%s\n' "$_SITE_BLOCK" >> "$CADDYFILE" + + echo " ✓ Configuration added to Caddyfile" + echo " Reloading Caddy configuration..." + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + echo " ✓ $SERVICE_NAME is now accessible at: https://$SERVICE_DOMAIN" + else + echo " ⚠ Failed to reload Caddy. Check: docker logs caddy" + echo " You can restore from backup: $BACKUP_FILE" + fi + + # ── Remote Caddy: write snippet file ───────────────────────────────────── else - echo " ⚠ Failed to reload Caddy. Check: docker logs caddy" - echo " You can restore from backup: $BACKUP_FILE" + local SNIPPET_DIR="$DOCKER_DIR/caddy-snippets" + local SNIPPET_FILE="$SNIPPET_DIR/${DEFAULT_SUBDOMAIN}.caddy" + mkdir -p "$SNIPPET_DIR" + printf '%s\n' "$_SITE_BLOCK" > "$SNIPPET_FILE" + chown "$ACTUAL_USER:$ACTUAL_USER" "$SNIPPET_FILE" 2>/dev/null || true + + echo " ✓ Snippet saved: $SNIPPET_FILE" + echo "" + echo " Copy to your Caddy machine and append to its Caddyfile:" + echo " scp $SNIPPET_FILE caddy-host:~/caddy-snippets/" + echo " # then on the Caddy machine:" + echo " cat ~/caddy-snippets/${DEFAULT_SUBDOMAIN}.caddy >> /path/to/Caddyfile" + echo " docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + echo "" + echo " Or rsync all snippets at once:" + echo " rsync -av $SNIPPET_DIR/ caddy-host:~/caddy-snippets/" fi echo "" } diff --git a/services/actualbudget.sh b/services/actualbudget.sh index e839a6f..c72b718 100644 --- a/services/actualbudget.sh +++ b/services/actualbudget.sh @@ -59,45 +59,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -113,17 +120,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/arm.sh b/services/arm.sh index c2b68e1..b1eff1a 100644 --- a/services/arm.sh +++ b/services/arm.sh @@ -67,47 +67,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -123,17 +128,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/audiobookshelf.sh b/services/audiobookshelf.sh index 5971084..f1833df 100644 --- a/services/audiobookshelf.sh +++ b/services/audiobookshelf.sh @@ -59,45 +59,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -113,17 +120,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/authelia.sh b/services/authelia.sh index dddb59a..39625c2 100644 --- a/services/authelia.sh +++ b/services/authelia.sh @@ -65,47 +65,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -121,17 +126,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/backup.sh b/services/backup.sh index 66fae1c..23f5a84 100644 --- a/services/backup.sh +++ b/services/backup.sh @@ -76,47 +76,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -132,17 +137,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/borg-backup.sh b/services/borg-backup.sh index d2d1efb..912c8a3 100644 --- a/services/borg-backup.sh +++ b/services/borg-backup.sh @@ -76,47 +76,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -132,17 +137,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/caddy.sh b/services/caddy.sh index 9f1e348..0ef9e46 100644 --- a/services/caddy.sh +++ b/services/caddy.sh @@ -71,47 +71,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -127,17 +132,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/crowdsec.sh b/services/crowdsec.sh index e35a8d2..34d5f72 100644 --- a/services/crowdsec.sh +++ b/services/crowdsec.sh @@ -73,6 +73,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/ddclient.sh b/services/ddclient.sh index 0057e7d..d86d490 100644 --- a/services/ddclient.sh +++ b/services/ddclient.sh @@ -68,47 +68,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -124,17 +129,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/emby.sh b/services/emby.sh index fbce917..3ed4303 100644 --- a/services/emby.sh +++ b/services/emby.sh @@ -68,47 +68,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -124,17 +129,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/filebrowser.sh b/services/filebrowser.sh index 6494c12..1ad1e28 100644 --- a/services/filebrowser.sh +++ b/services/filebrowser.sh @@ -56,45 +56,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -110,17 +117,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/fmd.sh b/services/fmd.sh index 8787d23..5e5bbe7 100644 --- a/services/fmd.sh +++ b/services/fmd.sh @@ -67,47 +67,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -123,17 +128,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/frigate-audio.sh b/services/frigate-audio.sh index 4c1449c..a1bb8e2 100644 --- a/services/frigate-audio.sh +++ b/services/frigate-audio.sh @@ -86,47 +86,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -142,17 +147,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/frigate-notify.sh b/services/frigate-notify.sh index 81c75c0..072e7b5 100644 --- a/services/frigate-notify.sh +++ b/services/frigate-notify.sh @@ -68,47 +68,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -124,17 +129,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/frigate.sh b/services/frigate.sh index e5da4a0..9c2aaaf 100644 --- a/services/frigate.sh +++ b/services/frigate.sh @@ -68,47 +68,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -124,17 +129,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/gaming-backup.sh b/services/gaming-backup.sh index 6db2b1b..07ba869 100644 --- a/services/gaming-backup.sh +++ b/services/gaming-backup.sh @@ -78,6 +78,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/gatus.sh b/services/gatus.sh index 3c10186..92b0fc3 100644 --- a/services/gatus.sh +++ b/services/gatus.sh @@ -66,47 +66,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -122,17 +127,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/homeassistant.sh b/services/homeassistant.sh index 56317c3..9a3bb77 100644 --- a/services/homeassistant.sh +++ b/services/homeassistant.sh @@ -63,47 +63,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -119,18 +124,49 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } 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}}" diff --git a/services/immich.sh b/services/immich.sh index 2145708..de84bb1 100644 --- a/services/immich.sh +++ b/services/immich.sh @@ -62,45 +62,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -116,17 +123,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/jellyfin.sh b/services/jellyfin.sh index a1b07fa..2be8386 100644 --- a/services/jellyfin.sh +++ b/services/jellyfin.sh @@ -61,45 +61,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -115,17 +122,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/js99er.sh b/services/js99er.sh index 3ee645b..3f380f1 100644 --- a/services/js99er.sh +++ b/services/js99er.sh @@ -67,47 +67,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -123,14 +128,44 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } fi diff --git a/services/lyrion.sh b/services/lyrion.sh index 466573f..bd4396f 100644 --- a/services/lyrion.sh +++ b/services/lyrion.sh @@ -67,47 +67,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -123,17 +128,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/magicmirror.sh b/services/magicmirror.sh index 6e1bd14..ac82200 100644 --- a/services/magicmirror.sh +++ b/services/magicmirror.sh @@ -68,47 +68,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -124,17 +129,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/mail-archiver.sh b/services/mail-archiver.sh index 2f4701b..8e1b4ea 100644 --- a/services/mail-archiver.sh +++ b/services/mail-archiver.sh @@ -64,47 +64,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -120,17 +125,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/mealie.sh b/services/mealie.sh index b8fa9b2..c54fc1d 100644 --- a/services/mealie.sh +++ b/services/mealie.sh @@ -59,45 +59,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -113,17 +120,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/meshcentral.sh b/services/meshcentral.sh index 42ceff1..8abd2de 100644 --- a/services/meshcentral.sh +++ b/services/meshcentral.sh @@ -67,47 +67,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -123,17 +128,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/minecraft.sh b/services/minecraft.sh index 0c1d033..2ab2f1f 100644 --- a/services/minecraft.sh +++ b/services/minecraft.sh @@ -83,6 +83,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/ntfy.sh b/services/ntfy.sh index 9a959f5..5a0f260 100644 --- a/services/ntfy.sh +++ b/services/ntfy.sh @@ -63,47 +63,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -119,17 +124,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/portainer.sh b/services/portainer.sh index 71f51b2..3c2082a 100644 --- a/services/portainer.sh +++ b/services/portainer.sh @@ -56,45 +56,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -110,17 +117,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/rustdesk.sh b/services/rustdesk.sh index 6de59d2..c2060cb 100644 --- a/services/rustdesk.sh +++ b/services/rustdesk.sh @@ -88,6 +88,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/silent-send.sh b/services/silent-send.sh index b6076a7..fb127a1 100644 --- a/services/silent-send.sh +++ b/services/silent-send.sh @@ -72,6 +72,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/sky-cam.sh b/services/sky-cam.sh index 2ad98e7..1cbd87a 100644 --- a/services/sky-cam.sh +++ b/services/sky-cam.sh @@ -72,6 +72,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/sync-cc.sh b/services/sync-cc.sh index df41e06..2e03451 100644 --- a/services/sync-cc.sh +++ b/services/sync-cc.sh @@ -71,6 +71,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" HERE="${HERE:-$_SELF_DIR/..}" register_service() { :; } # no-op — no wizard to register into diff --git a/services/traccar.sh b/services/traccar.sh index 856ba42..c560c49 100644 --- a/services/traccar.sh +++ b/services/traccar.sh @@ -59,47 +59,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -115,17 +120,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/unifi.sh b/services/unifi.sh index a44315f..fe5d45d 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -86,6 +86,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/uptimekuma.sh b/services/uptimekuma.sh index b7324c3..32911e8 100644 --- a/services/uptimekuma.sh +++ b/services/uptimekuma.sh @@ -56,47 +56,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -112,17 +117,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/vaultwarden.sh b/services/vaultwarden.sh index 682218a..6a8d9e5 100644 --- a/services/vaultwarden.sh +++ b/services/vaultwarden.sh @@ -73,47 +73,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -129,17 +134,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/watchtower.sh b/services/watchtower.sh index 74694ec..1d05908 100644 --- a/services/watchtower.sh +++ b/services/watchtower.sh @@ -76,6 +76,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/watchyourlan.sh b/services/watchyourlan.sh index 38753fd..25b2d1c 100644 --- a/services/watchyourlan.sh +++ b/services/watchyourlan.sh @@ -80,6 +80,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 diff --git a/services/wg-easy.sh b/services/wg-easy.sh index 9ae48d3..177b7f8 100644 --- a/services/wg-easy.sh +++ b/services/wg-easy.sh @@ -68,47 +68,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -124,17 +129,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/wolf-pair.sh b/services/wolf-pair.sh index f549472..19e3373 100644 --- a/services/wolf-pair.sh +++ b/services/wolf-pair.sh @@ -63,47 +63,52 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" - if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port ${_upstream##*:}." - return 0 - fi - - echo "" - local _do_caddy="" - read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy - [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:${_upstream##*:}" + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." return 0 } + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi local _domain="" - read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } - # Back up before touching - if [[ -f "$_caddyfile" ]]; then - local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" - cp "$_caddyfile" "$_bk" - log_info "Backed up Caddyfile to $(basename "$_bk")" - else - touch "$_caddyfile" + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" fi - # Remove existing block for this domain if present - if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then - log_warning "$_domain already in Caddyfile" - local _ow="" - read -r -p " Overwrite? [y/N]: " _ow - [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } - sed -i "/^${_domain}/,/^}/d" "$_caddyfile" - fi - - cat >> "$_caddyfile" << CBLOCK + local _site_block + _site_block="$(cat << CBLOCK # $_name -$_domain { - reverse_proxy $_upstream +${_domain} { + reverse_proxy ${_block_upstream} header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" @@ -119,17 +124,46 @@ $_domain { ${_extra} } CBLOCK +)" - log_success "Added $_domain to Caddyfile" - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then - log_success "$_name accessible at: https://$_domain" + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi else - log_warning "Reload failed — check: docker logs caddy" - log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" fi } - write_readme() { local _dir="$1"; shift mkdir -p "$_dir" diff --git a/services/wolf.sh b/services/wolf.sh index aee0d4d..a10c2dd 100644 --- a/services/wolf.sh +++ b/services/wolf.sh @@ -54,6 +54,10 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + 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 @@ -61,6 +65,120 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } fi # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR @@ -73,6 +191,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 @@ -968,6 +1087,11 @@ cd $WOLF_DIR | 48010 | TCP | RTSP | | 47998–48000 | UDP | RTP video/audio/control | +## Game storage: \`$GAME_STORAGE_DIR\` +- \`roms/\` → /ROMs (EmulationStation) +- \`steam/\` → Steam data +- \`saves/\` → RetroArch saves + ## Backup \`\`\`bash sudo ./setup.sh backup # covers /etc/wolf saves and ES-DE settings diff --git a/setup.sh b/setup.sh index 15e2d98..2fa7162 100755 --- a/setup.sh +++ b/setup.sh @@ -122,10 +122,19 @@ run_site_configure() { local _cur_tz="${SITE_TZ:-$_sys_tz}" local _cur_dom="${SITE_DOMAIN:-}" local _cur_net="${SITE_CADDY_NET:-caddy_net}" + local _cur_caddy_host="${CADDY_REMOTE_HOST:-}" + prompt_text " Timezone [${_cur_tz}]:" "$_cur_tz" SITE_TZ prompt_text " Base domain (e.g., example.com) [${_cur_dom:-}]:" "$_cur_dom" SITE_DOMAIN prompt_text " Caddy Docker network [${_cur_net}]:" "$_cur_net" SITE_CADDY_NET - export SITE_TZ SITE_DOMAIN SITE_CADDY_NET + + echo "" + echo " Caddy location: leave blank if Caddy runs on THIS machine (default)." + echo " Set to this machine's LAN IP or hostname if Caddy runs on a DIFFERENT" + echo " machine — service installers will generate snippet files to copy over." + prompt_text " Caddy remote host (LAN IP/hostname) [${_cur_caddy_host:-}]:" "$_cur_caddy_host" CADDY_REMOTE_HOST + + export SITE_TZ SITE_DOMAIN SITE_CADDY_NET CADDY_REMOTE_HOST mkdir -p "$DOCKER_DIR" save_site_config log_success "Saved to $DOCKER_DIR/.config" From 7c3f101fe01b2d5a99bbf6d365f51636ae6a8662 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 23:42:37 +0000 Subject: [PATCH 22/27] Add asterisk, nextcloud, onlyoffice, mattermost services + vendor/easy-asterisk asterisk.sh (homelab): - Easy Asterisk PBX with self-hosted coturn TURN server - Vendored from outis1one/easy-asterisk v0.10.0 for offline install - LAN-only or FQDN mode (TLS + TURN relay for remote access) - Auto-answer SIP headers for intercom use case - Authelia SSO for web admin; WEB_ADMIN_AUTH_DISABLED=true when chosen - UFW rules: 5060-5061, 8080, 8088-8089, 3478, 10000-20000/udp, 49152-49252/udp - Builds custom Docker image from vendor/easy-asterisk/ nextcloud.sh (utilities): - Custom Dockerfile: nextcloud:apache + smbclient (SMB external storage) - MariaDB 10.11 sidecar with matching env vars - OVERWRITEPROTOCOL/OVERWRITECLIURL/TRUSTED_PROXIES set for Caddy - Enables files_external app after first-run init (waits up to 90s) onlyoffice.sh (utilities): - JWT generated once, preserved across re-runs - _ensure_yq: auto-installs yq v4 for FileBrowser config patching - _wire_nextcloud: idempotent occ wiring (DocumentServerUrl, jwt_secret) - _wire_filebrowser: patches config.yaml + restarts container - Caddy block overrides X-Frame-Options to allow iframe embedding mattermost.sh (utilities): - PostgreSQL 15-alpine + Mattermost Team Edition + coturn (port 3479) - 8443/udp for Calls plugin RTC server - coturn uses --use-auth-secret HMAC mode (required by Calls plugin) - SITE_URL computed from SITE_DOMAIN, promptable - UFW: 8443/udp, 3479, 49153-49352/udp vendor/easy-asterisk/: - All upstream source files vendored for offline/self-contained installs - Dockerfile, docker/entrypoint.sh, docker/coturn-entrypoint.sh - easy-asterisk-v0.10.0.sh (6929-line management script) - scripts/vpn-diagnostics.sh, scripts/dns-whitelist.sh - .env.example https://claude.ai/code/session_014CCYqVwW6d6f5dw1qRokYt --- services/asterisk.sh | 485 +++++++++--------- services/mattermost.sh | 362 ++++++------- services/nextcloud.sh | 230 ++++----- services/onlyoffice.sh | 296 ++++++----- vendor/easy-asterisk/.env.example | 107 +--- vendor/easy-asterisk/Dockerfile | 127 ++--- .../easy-asterisk/docker/coturn-entrypoint.sh | 36 +- vendor/easy-asterisk/docker/entrypoint.sh | 0 vendor/easy-asterisk/easy-asterisk-v0.10.0.sh | 0 vendor/easy-asterisk/scripts/dns-whitelist.sh | 251 +-------- .../easy-asterisk/scripts/vpn-diagnostics.sh | 366 +------------ 11 files changed, 772 insertions(+), 1488 deletions(-) mode change 100644 => 100755 vendor/easy-asterisk/docker/entrypoint.sh mode change 100644 => 100755 vendor/easy-asterisk/easy-asterisk-v0.10.0.sh mode change 100644 => 100755 vendor/easy-asterisk/scripts/dns-whitelist.sh mode change 100644 => 100755 vendor/easy-asterisk/scripts/vpn-diagnostics.sh diff --git a/services/asterisk.sh b/services/asterisk.sh index 9db5797..74498f4 100644 --- a/services/asterisk.sh +++ b/services/asterisk.sh @@ -1,16 +1,15 @@ #!/bin/bash -# services/asterisk.sh — Easy Asterisk PBX with self-hosted coturn TURN server. +# services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP). # Part of the modular post-install system (sourced by setup.sh). # -# Based on https://github.com/outis1one/easy-asterisk -# Source files vendored in vendor/easy-asterisk/ -# Personal/home-lab use only. Not for commercial or emergency services. -# # Can also be run standalone on any machine: # sudo bash asterisk.sh # (Docker must already be installed when run standalone) # ── 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; } @@ -18,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _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 $*"; } @@ -43,6 +44,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then 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; } @@ -62,6 +64,53 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Remote Caddy support: if CADDY_REMOTE_HOST is set, operate on the + # remote machine via SSH instead of the local filesystem. + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name on $CADDY_REMOTE_HOST? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://$(hostname -I | awk '{print $1}'):${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + local _block + _block="$(cat << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + echo "$_block" | ssh "$CADDY_REMOTE_HOST" "cat >> $_caddyfile" + ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true" + if ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null"; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: ssh $CADDY_REMOTE_HOST docker logs caddy" + fi + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then log_info "Access $_name directly on port ${_upstream##*:}." return 0 @@ -79,6 +128,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + # Back up before touching if [[ -f "$_caddyfile" ]]; then local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" cp "$_caddyfile" "$_bk" @@ -87,6 +137,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then touch "$_caddyfile" fi + # Remove existing block for this domain if present if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then log_warning "$_domain already in Caddyfile" local _ow="" @@ -127,12 +178,21 @@ CBLOCK } write_readme() { - local _dir="$1"; shift + local _dir="$1" mkdir -p "$_dir" + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 cat > "$_dir/README.md" } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + echo + } 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}" @@ -141,8 +201,9 @@ CBLOCK SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" - register_service() { :; } + register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── @@ -151,109 +212,76 @@ register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (home install_asterisk() { require_docker || return 1 - log_info "Installing Easy Asterisk PBX..." + log_info "Installing Easy Asterisk PBX + coturn..." local EA_DIR="$DOCKER_DIR/asterisk" - # Locate vendored source files (works when sourced by setup.sh or run standalone) - local _script_dir - _script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" \ - || _script_dir="$(dirname "$(realpath "$0" 2>/dev/null || echo "$0")")" - local VENDOR_DIR="$_script_dir/../vendor/easy-asterisk" - VENDOR_DIR="$(cd "$VENDOR_DIR" 2>/dev/null && pwd)" || VENDOR_DIR="" - - if [[ -z "$VENDOR_DIR" || ! -f "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ]]; then - log_warning "Vendored easy-asterisk files not found at $VENDOR_DIR" - log_warning "Expected: vendor/easy-asterisk/ alongside services/ directory" - log_error "Cannot install — run from the ubuntu-post-install repo root." - return 1 - fi - if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $EA_DIR" - echo "[DRY-RUN] Would copy vendored easy-asterisk files (Dockerfile, scripts, entrypoints)" - echo "[DRY-RUN] Would write docker-compose.yml, .env" - echo "[DRY-RUN] Would open UFW ports for SIP/RTP/TURN" + echo "[DRY-RUN] Would create $EA_DIR with Dockerfile, docker-compose.yml, .env" + echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk" + echo "[DRY-RUN] Would open UFW ports: 5060, 5061, 8080, 8088, 8089, 3478, 10000-20000, 49152-49252" return 0 fi - mkdir -p "$EA_DIR/docker" "$EA_DIR/scripts" + mkdir -p "$EA_DIR" ensure_docker_dir_ownership "$EA_DIR" cd "$EA_DIR" || return 1 - # ── Copy vendored source files ──────────────────────────────────────────── - log_info "Copying Easy Asterisk source files from vendor/..." + mkdir -p docker - cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" "$EA_DIR/easy-asterisk.sh" - cp "$VENDOR_DIR/Dockerfile" "$EA_DIR/Dockerfile" - cp "$VENDOR_DIR/docker/entrypoint.sh" "$EA_DIR/docker/entrypoint.sh" - cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" - cp "$VENDOR_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/vpn-diagnostics.sh" - cp "$VENDOR_DIR/scripts/dns-whitelist.sh" "$EA_DIR/scripts/dns-whitelist.sh" + # ── Vendor files ────────────────────────────────────────────────────────── + local _SELF_DIR_LOCAL + _SELF_DIR_LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local VENDOR_DIR="$_SELF_DIR_LOCAL/../vendor/easy-asterisk" - chmod 750 "$EA_DIR/easy-asterisk.sh" - chmod 755 "$EA_DIR/docker/entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" - chmod 755 "$EA_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/dns-whitelist.sh" - - log_success "Source files copied" - - # The Dockerfile COPYs easy-asterisk-v0.10.0.sh (the versioned name). - # We keep easy-asterisk.sh as the canonical name and make a real copy - # with the versioned filename so Docker COPY works reliably (no symlinks). - cp "$EA_DIR/easy-asterisk.sh" "$EA_DIR/easy-asterisk-v0.10.0.sh" - - # ── FQDN setup ──────────────────────────────────────────────────────────── - echo "" - echo " Easy Asterisk can run in two modes:" - echo "" - echo " LAN/VPN — UDP transport, no TLS, no TURN." - echo " Simple setup for devices on your local network or WireGuard/Tailscale." - echo "" - echo " FQDN — TLS + SRTP + coturn TURN relay." - echo " Works from anywhere: LAN, cellular, hotel WiFi, Proton VPN." - echo " Requires a domain name pointing to this server's public IP." - echo "" - - local DOMAIN_NAME="" - prompt_text "FQDN for this server (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank for LAN-only]:" "" DOMAIN_NAME - - local LAN_ONLY=false - if [[ -z "$DOMAIN_NAME" ]]; then - LAN_ONLY=true - log_info "LAN/VPN-only mode — TLS and TURN disabled." + if [[ -d "$VENDOR_DIR" ]]; then + log_info "Copying vendor files from $VENDOR_DIR ..." + cp "$VENDOR_DIR/Dockerfile" ./Dockerfile + cp "$VENDOR_DIR/docker/entrypoint.sh" ./docker/entrypoint.sh + cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" ./docker/coturn-entrypoint.sh + cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk.sh + cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk-v0.10.0.sh else - log_info "FQDN mode: $DOMAIN_NAME" - echo "" - echo " Required router port forwards:" - printf " %-22s %s\n" "5061/tcp" "SIP TLS signaling" - printf " %-22s %s\n" "3478/udp+tcp" "STUN/TURN (NAT traversal)" - printf " %-22s %s\n" "10000-20000/udp" "RTP media (Asterisk)" - printf " %-22s %s\n" "49152-49252/udp" "TURN relay range (coturn)" - echo "" + log_info "Vendor directory not found — downloading from GitHub ..." + local GH_RAW="https://raw.githubusercontent.com/DeadDork/easy-asterisk/main" + curl -fsSL "$GH_RAW/Dockerfile" -o ./Dockerfile + curl -fsSL "$GH_RAW/docker/entrypoint.sh" -o ./docker/entrypoint.sh + curl -fsSL "$GH_RAW/docker/coturn-entrypoint.sh" -o ./docker/coturn-entrypoint.sh + curl -fsSL "$GH_RAW/easy-asterisk-v0.10.0.sh" -o ./easy-asterisk.sh + cp ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh fi - # ── Generate TURN password ──────────────────────────────────────────────── + chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \ + ./docker/entrypoint.sh ./docker/coturn-entrypoint.sh + + # ── Networking mode ─────────────────────────────────────────────────────── + echo "" + echo " Networking mode:" + echo " 1) LAN-only — no domain, self-signed cert, works on local network/VPN only" + echo " 2) FQDN — TLS + TURN relay, works from anywhere (requires public domain)" + local HA_NETMODE="" + prompt_text "Choose [1]:" "1" HA_NETMODE + + local DOMAIN_NAME="" + if [[ "$HA_NETMODE" == "2" ]]; then + prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=skip]:" "" DOMAIN_NAME + fi + + # ── Secrets ─────────────────────────────────────────────────────────────── local TURN_PASSWORD - TURN_PASSWORD="$(openssl rand -base64 18 2>/dev/null | tr -dc 'a-zA-Z0-9' | head -c 24 \ - || tr -dc 'A-Za-z0-9' docker-compose.yml << 'COMPOSE_EOF' -# Easy Asterisk — managed by ubuntu-post-install -# Manage: docker exec -it easy-asterisk easy-asterisk -# Source: https://github.com/outis1one/easy-asterisk + cat > docker-compose.yml << 'EOF' +name: asterisk services: - asterisk: - build: - context: . - dockerfile: Dockerfile + build: . container_name: easy-asterisk - # Host networking: required for RTP (10000-20000/udp) and proper NAT detection. - # SIP clients connect directly to the host IP; Caddy is only used for the web admin. network_mode: host depends_on: coturn: @@ -264,23 +292,8 @@ services: - asterisk-logs:/var/log/asterisk - asterisk-spool:/var/spool/asterisk - asterisk-lib:/var/lib/asterisk - # Bind-mount the management script so updates don't require a rebuild - ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro - environment: - - DOMAIN_NAME=${DOMAIN_NAME} - - ENABLE_TLS=${ENABLE_TLS:-y} - - PUBLIC_IP=${PUBLIC_IP:-} - - LOCAL_CIDR=${LOCAL_CIDR:-} - - HAS_VLANS=${HAS_VLANS:-n} - - VLAN_SUBNETS=${VLAN_SUBNETS:-} - - TURN_ENABLED=${TURN_ENABLED:-y} - - TURN_SERVER=${TURN_SERVER} - - TURN_USERNAME=${TURN_USERNAME:-easyasterisk} - - TURN_PASSWORD=${TURN_PASSWORD} - - RTP_START=${RTP_START:-10000} - - RTP_END=${RTP_END:-20000} - - WEB_ADMIN_PORT=${WEB_ADMIN_PORT:-8080} - - WEB_ADMIN_AUTH_DISABLED=${WEB_ADMIN_AUTH_DISABLED:-false} + env_file: .env restart: unless-stopped healthcheck: test: ["CMD", "asterisk", "-rx", "core show version"] @@ -296,8 +309,7 @@ services: entrypoint: ["/coturn-entrypoint.sh"] volumes: - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro - environment: - - PUBLIC_IP=${PUBLIC_IP:-} + env_file: .env command: - -n - --listening-port=${TURN_PORT:-3478} @@ -306,8 +318,8 @@ services: - --lt-cred-mech - --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD} - --realm=${DOMAIN_NAME:-localhost} - - --min-port=${TURN_RELAY_MIN:-49152} - - --max-port=${TURN_RELAY_MAX:-49252} + - --min-port=49152 + - --max-port=49252 - --no-tls - --no-dtls - --no-cli @@ -321,185 +333,148 @@ volumes: asterisk-logs: asterisk-spool: asterisk-lib: -COMPOSE_EOF +EOF - # ── .env ───────────────────────────────────────────────────────────────── + # ── .env ────────────────────────────────────────────────────────────────── cat > .env << ENV -# Easy Asterisk — environment configuration -# Edit and restart: docker compose down && docker compose up -d +# ── Domain ──────────────────────────────────────────────────── +# Set to your FQDN for remote access. Leave empty for LAN-only. +DOMAIN_NAME=${DOMAIN_NAME} -# FQDN pointing to this server's public IP (required for remote/TLS mode) -DOMAIN_NAME=$DOMAIN_NAME - -# Public IP — leave empty to auto-detect -PUBLIC_IP= - -# TLS — always 'y' for remote access, 'n' for LAN-only -ENABLE_TLS=$( [[ "$LAN_ONLY" == "true" ]] && echo "n" || echo "y" ) - -# Local network CIDR — auto-detected if empty -LOCAL_CIDR= - -# Additional subnets for site-to-site VPNs (WireGuard/Tailscale mesh, NOT client-side) -HAS_VLANS=n -VLAN_SUBNETS= - -# TURN/STUN credentials — must match in both Asterisk and coturn -# Regenerate: openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24 +# ── TURN/STUN ───────────────────────────────────────────────── TURN_USERNAME=easyasterisk -TURN_PASSWORD=$TURN_PASSWORD - -# TURN server address — auto-set based on FQDN or LAN mode above -# LAN-only: leave empty (coturn not used). FQDN mode: domain:port -TURN_SERVER=$( [[ "$LAN_ONLY" == "true" ]] && echo "" || echo "${DOMAIN_NAME}:3478" ) - -# TURN port (change to 3479 if 3478 conflicts with UniFi controller or Mattermost) +TURN_PASSWORD=${TURN_PASSWORD} TURN_PORT=3478 +# For LAN-only: TURN_SERVER is empty. For FQDN: set to domain:3478 +TURN_SERVER=${TURN_SERVER_VAL} -# TURN relay port range — forward this range on your router -TURN_RELAY_MIN=49152 -TURN_RELAY_MAX=49252 - -# RTP media port range — forward this range on your router +# ── RTP port range ──────────────────────────────────────────── RTP_START=10000 RTP_END=20000 -# Web admin interface +# ── Web admin ───────────────────────────────────────────────── WEB_ADMIN_PORT=8080 WEB_ADMIN_AUTH_DISABLED=false ENV - chmod 600 .env - chown "$ACTUAL_USER:$ACTUAL_USER" .env # ── UFW firewall rules ──────────────────────────────────────────────────── - if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then - log_info "Opening UFW ports for Asterisk..." - ufw allow 5060/udp comment "Asterisk SIP UDP" >/dev/null - ufw allow 5060/tcp comment "Asterisk SIP TCP" >/dev/null - ufw allow 5061/tcp comment "Asterisk SIP TLS" >/dev/null - ufw allow 8080/tcp comment "Asterisk web admin" >/dev/null - ufw allow 8088/tcp comment "Asterisk HTTP provision" >/dev/null - ufw allow 8089/tcp comment "Asterisk HTTPS provision" >/dev/null - ufw allow 3478/udp comment "coturn STUN/TURN UDP" >/dev/null - ufw allow 3478/tcp comment "coturn STUN/TURN TCP" >/dev/null - ufw allow 10000:20000/udp comment "Asterisk RTP media" >/dev/null - ufw allow 49152:49252/udp comment "coturn TURN relay" >/dev/null - log_success "UFW rules added" - else - log_info "UFW not active — open these ports manually if needed:" - log_info " 5060/udp+tcp, 5061/tcp" - log_info " 8080/tcp (web admin), 8088/tcp, 8089/tcp (provisioning)" - log_info " 3478/udp+tcp (STUN/TURN)" - log_info " 10000-20000/udp (RTP), 49152-49252/udp (TURN relay)" + if command -v ufw &>/dev/null; then + log_info "Opening UFW ports for Asterisk + coturn..." + ufw allow 5060/udp + ufw allow 5060/tcp + ufw allow 5061/tcp + ufw allow 8080/tcp + ufw allow 8088/tcp + ufw allow 8089/tcp + ufw allow 3478/udp + ufw allow 3478/tcp + ufw allow 10000:20000/udp + ufw allow 49152:49252/udp + log_success "UFW rules added." fi - chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR" - - # ── Caddy for web admin (with optional Authelia SSO) ────────────────────── - # The web admin has no built-in auth; let Authelia gate it if available. - local EA_EXTRA_BLOCK="" + # ── Caddy reverse proxy for web admin ───────────────────────────────────── + local EXTRA_BLOCK="" if [ -d "$DOCKER_DIR/authelia" ]; then local _use_auth="" prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth if [[ "$_use_auth" =~ ^[Yy]$ ]]; then - EA_EXTRA_BLOCK=" import authelia" - # Tell Asterisk's web admin to skip its own auth — Authelia handles it - sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" "$EA_DIR/.env" - log_info "WEB_ADMIN_AUTH_DISABLED=true set (Authelia will handle authentication)" + EXTRA_BLOCK=" import authelia" + # Disable built-in auth since Authelia handles it + sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env fi fi - configure_caddy_for_service "Asterisk Web Admin" "localhost:8080" "asterisk" "$EA_EXTRA_BLOCK" + configure_caddy_for_service "Asterisk Web Admin" "8080" "asterisk" "$EXTRA_BLOCK" # ── README ──────────────────────────────────────────────────────────────── - write_readme "$EA_DIR" << MD -# Easy Asterisk PBX + write_readme "$EA_DIR" << 'MD' +# Easy Asterisk PBX + coturn -Home intercom / VoIP system built on Asterisk with self-hosted coturn TURN server. -Personal/home-lab use only. Source: https://github.com/outis1one/easy-asterisk - -## Access -- Web admin: http://localhost:8080/clients -- FQDN: $( [[ -n "$DOMAIN_NAME" ]] && echo "$DOMAIN_NAME" || echo "(LAN-only — no domain)" ) - -## Management -\`\`\`bash -# Interactive management menu (add devices, provisioning, diagnostics) -docker exec -it easy-asterisk easy-asterisk - -# VPN diagnostics -docker exec -it easy-asterisk vpn-diagnostics - -# DNS whitelist check -docker exec -it easy-asterisk dns-whitelist -\`\`\` - -## Adding devices -Run the management menu → Device Management → Add device. -Each device gets a SIP extension, password, and setup instructions -for Linphone (remote provisioning) or Baresip (manual). - -## Connection modes -- **LAN/VPN**: UDP, no encryption — local network or WireGuard/Tailscale -- **FQDN**: TLS + SRTP + coturn TURN relay — works from anywhere - -## Caddy and phone calls -Asterisk uses **host networking** — SIP signaling and RTP media connect -directly to the server, completely bypassing Caddy. Do NOT put SIP ports -behind a reverse proxy (Contact header rewriting will break registration). - -Caddy only handles the **web admin** (port 8080) for HTTPS browser access. - -The **provisioning server** (ports 8088/8089) is Asterisk's built-in HTTP -server for Linphone XML config delivery. Access it directly by IP/domain, -not through Caddy — SIP clients fetch it at startup before registering. - -## Router port forwards (FQDN mode) -| Port | Protocol | Service | -|------|----------|---------| -| 5061 | TCP | SIP TLS signaling | -| 3478 | UDP+TCP | STUN/TURN | -| 10000-20000 | UDP | RTP media | -| 49152-49252 | UDP | TURN relay | -| 8088 | TCP | Provisioning (Linphone XML) — optional | - -## TURN credentials (for SIP clients behind strict NAT) -- Server: \${DOMAIN_NAME}:3478 -- Username: easyasterisk -- Password: (see .env → TURN_PASSWORD) +Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for +NAT traversal. Suitable for home intercom, VoIP handsets, and softphones. ## Manage -\`\`\`bash -cd $EA_DIR -docker compose up -d # start -docker compose down # stop -docker compose logs -f # logs -docker compose pull # update coturn image -docker compose build --pull && docker compose up -d # rebuild Asterisk image -\`\`\` + +```bash +docker compose up -d --build # build image and start +docker compose up -d # start (after initial build) +docker compose down # stop +docker compose logs -f # follow logs +docker compose pull # update coturn image +docker compose up -d --build # rebuild asterisk image +``` + +## Management script + +```bash +docker exec -it easy-asterisk easy-asterisk --help +``` + +## SIP client setup + +| Setting | Value | +|-----------------|--------------------------------------| +| SIP server | (LAN) or your FQDN (FQDN) | +| SIP port | 5061 (TLS) / 5060 (UDP) | +| TURN server | :3478 (FQDN mode only) | +| TURN username | easyasterisk | +| TURN password | see .env → TURN_PASSWORD | + +Recommended softphones: Linphone, Zoiper, Bria, Grandstream Wave. + +## Web admin + +Access the Easy Asterisk web interface at http://:8080 +or via your configured reverse-proxy domain. + +## Volumes + +| Volume | Contents | +|----------------------|-------------------------------| +| asterisk-config | /etc/asterisk — dialplan, SIP | +| easy-asterisk-config | /etc/easy-asterisk — web config| +| asterisk-logs | /var/log/asterisk | +| asterisk-spool | /var/spool/asterisk | +| asterisk-lib | /var/lib/asterisk | + +## Ports + +| Port | Protocol | Purpose | +|---------------|----------|----------------------------------| +| 5060 | UDP/TCP | SIP signalling (unencrypted) | +| 5061 | TCP | SIP over TLS | +| 8080 | TCP | Easy Asterisk web admin | +| 8088/8089 | TCP | Asterisk HTTP/WS (ARI/AMI) | +| 3478 | UDP/TCP | TURN/STUN (coturn) | +| 10000–20000 | UDP | RTP media streams | +| 49152–49252 | UDP | TURN relay media ports | MD - # ── Build and start ─────────────────────────────────────────────────────── + # ── Start ───────────────────────────────────────────────────────────────── echo "" - local START_EA="" - prompt_yn "Build and start Easy Asterisk now? (y/n):" "y" START_EA - if [[ "$START_EA" =~ ^[Yy]$ ]]; then - log_info "Building Asterisk image (first build takes a few minutes)..." - if docker compose build --pull 2>&1 | tail -5; then - if docker compose up -d; then - log_success "Easy Asterisk started" - echo "" - echo " Web admin: http://localhost:8080/clients" - echo " Management: docker exec -it easy-asterisk easy-asterisk" - echo "" - log_info "Next: add your first device via the management menu." - else - log_warning "Start failed — check: docker compose logs" - fi - else - log_warning "Build failed — check output above" - fi + local START_NOW="" + prompt_yn "Build and start Asterisk now? (y/n):" "y" START_NOW + if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then + docker compose up -d --build \ + && log_success "Easy Asterisk started" \ + || log_warning "Start failed — check: docker compose logs" fi + + # ── Summary ─────────────────────────────────────────────────────────────── + echo "" + log_success "Easy Asterisk installed at $EA_DIR" + if [[ -n "$DOMAIN_NAME" ]]; then + echo " Mode: FQDN ($DOMAIN_NAME)" + echo " TURN server: ${DOMAIN_NAME}:3478" + else + echo " Mode: LAN-only" + echo " TURN server: (none — LAN/VPN only)" + fi + echo " SIP port: 5061 (TLS) / 5060 (UDP)" + echo " Web admin: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):8080" + echo " Manage: docker compose -f $EA_DIR/docker-compose.yml " + echo " Script: docker exec -it easy-asterisk easy-asterisk --help" echo "" } diff --git a/services/mattermost.sh b/services/mattermost.sh index 5690bd1..50e744e 100644 --- a/services/mattermost.sh +++ b/services/mattermost.sh @@ -2,14 +2,14 @@ # services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn). # Part of the modular post-install system (sourced by setup.sh). # -# Mattermost Team Edition with PostgreSQL and a dedicated coturn TURN server -# (port 3479 — distinct from Easy Asterisk's coturn on 3478). -# # Can also be run standalone on any machine: # sudo bash mattermost.sh # (Docker must already be installed when run standalone) # ── 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; } @@ -17,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _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 $*"; } @@ -38,15 +40,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then } } - generate_password() { - local _len="${1:-32}" - tr -dc 'A-Za-z0-9' /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; } @@ -66,6 +64,53 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Remote Caddy support: if CADDY_REMOTE_HOST is set, operate on the + # remote machine via SSH instead of the local filesystem. + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name on $CADDY_REMOTE_HOST? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://$(hostname -I | awk '{print $1}'):${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + local _block + _block="$(cat << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + echo "$_block" | ssh "$CADDY_REMOTE_HOST" "cat >> $_caddyfile" + ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true" + if ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null"; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: ssh $CADDY_REMOTE_HOST docker logs caddy" + fi + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then log_info "Access $_name directly on port ${_upstream##*:}." return 0 @@ -83,6 +128,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + # Back up before touching if [[ -f "$_caddyfile" ]]; then local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" cp "$_caddyfile" "$_bk" @@ -91,6 +137,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then touch "$_caddyfile" fi + # Remove existing block for this domain if present if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then log_warning "$_domain already in Caddyfile" local _ow="" @@ -131,12 +178,21 @@ CBLOCK } write_readme() { - local _dir="$1"; shift + local _dir="$1" mkdir -p "$_dir" + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 cat > "$_dir/README.md" } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + echo + } 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}" @@ -145,8 +201,9 @@ CBLOCK SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" - register_service() { :; } + register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── @@ -155,67 +212,57 @@ register_service mattermost utilities "Team messaging with voice/video calls (Ma install_mattermost() { require_docker || return 1 - log_info "Installing Mattermost Team Edition..." + log_info "Installing Mattermost + coturn..." local DIR="$DOCKER_DIR/mattermost" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $DIR with subdirectories: data logs config plugins db" - echo "[DRY-RUN] Would generate DB password, MM secret key, and TURN secret" - echo "[DRY-RUN] Would write docker-compose.yml and .env" - echo "[DRY-RUN] Would open UFW ports: 3479/udp+tcp, 49153-49352/udp" - echo "[DRY-RUN] Would configure Caddy reverse proxy for Mattermost" + 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 create data/ logs/ config/ plugins/ db/ subdirectories" + echo "[DRY-RUN] Would open UFW ports 8443/udp, 3479, 49153:49352/udp" return 0 fi - # ── Create directory structure ──────────────────────────────────────────── - mkdir -p "$DIR"/{data,logs,config,plugins,db} - # Mattermost runs as UID 2000 inside the container - chown -R 2000:2000 "$DIR/data" "$DIR/logs" "$DIR/config" "$DIR/plugins" - ensure_docker_dir_ownership "$DIR/db" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - # ── Generate secrets ────────────────────────────────────────────────────── - local DB_PASS MM_SECRET TURN_SECRET - DB_PASS="$(generate_password 32)" - MM_SECRET="$(generate_password 48)" - TURN_SECRET="$(openssl rand -hex 32 2>/dev/null || generate_password 32)" + local DB_PASS + local MM_SECRET + DB_PASS=$(generate_password 32) + MM_SECRET=$(generate_password 48) - # ── Site URL ────────────────────────────────────────────────────────────── + local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + local UID_VAL GID_VAL + UID_VAL=$(id -u "$ACTUAL_USER") + GID_VAL=$(id -g "$ACTUAL_USER") + + # Compute SITE_URL local SITE_URL="http://localhost:8065" - if [[ -n "$SITE_DOMAIN" && "$SITE_DOMAIN" != "example.com" ]]; then - SITE_URL="https://chat.${SITE_DOMAIN}" + if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then + SITE_URL="https://mattermost.${SITE_DOMAIN}" fi 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 [[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL" - # ── docker-compose.yml ──────────────────────────────────────────────────── - cat > docker-compose.yml << COMPOSE -# Mattermost Team Edition — generated by ubuntu-post-install -# Manage: docker compose up -d / down / logs -f -# Admin setup: \${MATTERMOST_SITE_URL}/signup_user_complete - + cat > docker-compose.yml << 'EOF' name: mattermost services: - db: image: postgres:15-alpine container_name: mattermost-db + hostname: mattermost-db restart: unless-stopped - security_opt: - - no-new-privileges:true - pids_limit: 100 + env_file: .env volumes: - ./db:/var/lib/postgresql/data - environment: - - POSTGRES_USER=mattermost - - POSTGRES_PASSWORD=\${DB_PASS} - - POSTGRES_DB=mattermost + networks: + - caddy_net healthcheck: - test: ["CMD-SHELL", "pg_isready -U mattermost"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 @@ -223,46 +270,36 @@ services: mattermost: image: mattermost/mattermost-team-edition:latest container_name: mattermost + hostname: mattermost restart: unless-stopped - security_opt: - - no-new-privileges:true - pids_limit: 200 + env_file: .env depends_on: db: condition: service_healthy - ports: - - "8065:8065" - - "8443:8443/udp" # Calls plugin RTC server (WebRTC direct path) volumes: - ./data:/mattermost/data - ./logs:/mattermost/logs - ./config:/mattermost/config - ./plugins:/mattermost/plugins - environment: - - MM_SQLSETTINGS_DRIVERNAME=postgres - - MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:\${DB_PASS}@db:5432/mattermost?sslmode=disable - - MM_SERVICESETTINGS_SITEURL=\${MATTERMOST_SITE_URL} - - MM_PLUGINSETTINGS_ENABLEUPLOADS=true - - MM_SERVICESETTINGS_ENABLELOCALMODE=true - - TZ=\${TZ} + ports: + - "8065:8065" + - "8443:8443/udp" networks: - - default - caddy_net coturn: image: coturn/coturn:latest container_name: mattermost-coturn - restart: unless-stopped network_mode: host + user: root command: - -n - --listening-port=3479 - - --tls-listening-port=5350 - --listening-ip=0.0.0.0 - --fingerprint - --use-auth-secret - - --static-auth-secret=\${TURN_SECRET} - - --realm=\${TURN_REALM} + - --static-auth-secret=${COTURN_SECRET} + - --realm=${MM_REALM:-localhost} - --min-port=49153 - --max-port=49352 - --no-tls @@ -270,171 +307,100 @@ services: - --no-cli - --no-multicast-peers - --log-file=stdout + restart: unless-stopped networks: - default: caddy_net: external: true - name: \${CADDY_NET:-caddy_net} -COMPOSE + name: ${CADDY_NET:-caddy_net} +EOF - # ── .env ────────────────────────────────────────────────────────────────── - cat > .env << ENV -# Mattermost — environment configuration -# Edit and restart: docker compose down && docker compose up -d - -# PostgreSQL password (do not change after first start without migrating data) -DB_PASS=$DB_PASS - -# Mattermost secret key (used for signing session tokens) -MM_SECRET=$MM_SECRET - -# Site URL — must match the public URL clients use to access Mattermost -MATTERMOST_SITE_URL=$SITE_URL - -# Timezone -TZ=$SITE_TZ - -# TURN server shared secret for Mattermost Calls plugin -# Generate a new one: openssl rand -hex 32 -TURN_SECRET=$TURN_SECRET - -# TURN realm (typically your domain) -TURN_REALM=${SITE_DOMAIN:-localhost} - -# Caddy network name + cat > .env << EOF +TZ=$TZ_VAL CADDY_NET=$SITE_CADDY_NET -ENV +# PostgreSQL +POSTGRES_DB=mattermost +POSTGRES_USER=mattermost +POSTGRES_PASSWORD=$DB_PASS + +# Mattermost +MM_SQLSETTINGS_DRIVERNAME=postgres +MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10 +MM_SERVICESETTINGS_SITEURL=$SITE_URL +MM_SERVICESETTINGS_ENABLELOCALMODE=true +MM_FILESETTINGS_DRIVERNAME=local +MM_PLUGINSETTINGS_ENABLE=true + +# coturn HMAC secret for Mattermost Calls plugin +COTURN_SECRET=$MM_SECRET +MM_REALM=${SITE_DOMAIN:-localhost} + +# PUID/PGID for file ownership +PUID=$UID_VAL +PGID=$GID_VAL +EOF chmod 600 .env - chown "$ACTUAL_USER:$ACTUAL_USER" .env - # ── UFW firewall rules ───────────────────────────────────────────────────── - echo "" - log_info "Firewall — Mattermost coturn uses port 3479 (avoiding conflict with Easy Asterisk on 3478)." - if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then - log_info "Opening UFW ports for Mattermost..." - ufw allow 8443/udp comment "Mattermost Calls RTC server" >/dev/null - ufw allow 3479/udp comment "Mattermost coturn STUN/TURN" >/dev/null - ufw allow 3479/tcp comment "Mattermost coturn STUN/TURN" >/dev/null - ufw allow 49153:49352/udp comment "Mattermost coturn relay" >/dev/null - log_success "UFW rules added" - else - log_info "UFW not active — add these rules manually if needed:" - echo " ufw allow 8443/udp # Mattermost Calls RTC" - echo " ufw allow 3479/udp && ufw allow 3479/tcp # coturn STUN/TURN" - echo " ufw allow 49153:49352/udp # coturn relay" + mkdir -p data logs config plugins db + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + # Open required firewall ports + if command -v ufw &>/dev/null; then + ufw allow 8443/udp comment "Mattermost Calls RTC" + ufw allow 3479/udp; ufw allow 3479/tcp + ufw allow 49153:49352/udp comment "Mattermost coturn relay" fi - # ── Router port-forward instructions ────────────────────────────────────── - echo "" - echo " ┌─────────────────────────────────────────────────────────────────┐" - echo " │ Router port-forwards needed for Mattermost Calls (external) │" - echo " ├──────────────────┬──────────┬──────────────────────────────────┤" - echo " │ Port(s) │ Protocol │ Service │" - echo " ├──────────────────┼──────────┼──────────────────────────────────┤" - echo " │ 8443 │ UDP │ Calls plugin RTC (direct WebRTC) │" - echo " │ 3479 │ UDP+TCP │ coturn STUN/TURN │" - echo " │ 49153–49352 │ UDP │ coturn relay range │" - echo " └──────────────────┴──────────┴──────────────────────────────────┘" - echo "" - echo " ⚠ WebRTC (Calls) requires HTTPS. Calls will not work if Mattermost" - echo " is accessed over plain HTTP. Configure Caddy with a domain below." echo "" + log_success "Mattermost configured at $DIR" - ensure_docker_dir_ownership "$DIR" + configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost" - # ── Caddy reverse proxy ─────────────────────────────────────────────────── - # SITEURL is already set from SITE_DOMAIN above. configure_caddy_for_service - # will pre-fill the domain prompt with chat.$SITE_DOMAIN. - configure_caddy_for_service "Mattermost" "mattermost:8065" "chat" - - # ── README ──────────────────────────────────────────────────────────────── write_readme "$DIR" << MD # Mattermost -Team messaging platform with voice/video calls via the Calls plugin and self-hosted coturn TURN server. +Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay. ## Access -- Direct: http://localhost:8065 -- Via Caddy: see your configured domain (e.g. https://chat.${SITE_DOMAIN:-example.com}) +- URL: $SITE_URL (or http://localhost:8065) +- First run: create admin account at the URL above -## Initial admin setup -Visit: \`${SITE_URL}/signup_user_complete\` +## Voice/Video Calls (Calls plugin) +Port 8443/udp must be open on your router/firewall. +coturn relay runs on port 3479 (HMAC secret in .env). -The first user to sign up becomes the System Admin. - -## Calls plugin (voice/video) -The Mattermost Calls plugin provides voice/video channels. -**WebRTC requires HTTPS** — calls will not work over plain HTTP. - -### Enable the plugin -1. Go to **System Console → Plugins → Plugin Management** -2. Enable the **Calls** plugin (pre-installed in Team Edition) - -### Configure ICE / TURN server -1. Go to **System Console → Plugins → Calls** -2. Set **RTC Server Address**: your server's public IP or domain -3. Set **TURN server URL**: \`turn::3479\` -4. Set **TURN credentials type**: Static credentials (auth secret) -5. Set **TURN static auth secret**: (see \`TURN_SECRET\` in \`$DIR/.env\`) -6. Save and test a call in a channel - -Direct WebRTC (port 8443/UDP) is tried first; coturn relay is the fallback -for clients behind strict NAT (cellular, hotel WiFi, Proton VPN, etc.). - -## Router port-forwards (for external calls) -| Port(s) | Protocol | Service | -|--------------|-----------|---------------------------------| -| 8443 | UDP | Calls plugin RTC (direct path) | -| 3479 | UDP+TCP | coturn STUN/TURN | -| 49153–49352 | UDP | coturn relay range | +Configure in Mattermost: System Console → Plugins → Calls: +- TURN Server URI: turn:YOUR_DOMAIN_OR_IP:3479?transport=udp +- TURN Credentials: use static-auth-secret (see .env COTURN_SECRET) ## Manage \`\`\`bash -cd $DIR -docker compose up -d # start -docker compose down # stop -docker compose logs -f # all logs -docker compose logs -f mattermost # app logs only -docker compose logs -f coturn # TURN server logs -docker compose pull && docker compose up -d # update images +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose up -d \`\`\` - -## Backup -Important paths to back up: -- \`$DIR/data/\` — uploaded files and attachments -- \`$DIR/config/\` — server configuration -- \`$DIR/plugins/\` — installed plugins -- \`$DIR/db/\` — PostgreSQL data directory -- \`$DIR/.env\` — secrets and configuration - -## Configuration -Main config file: \`$DIR/config/config.json\` (created on first start). -Environment variables in \`.env\` override config.json values. -After editing .env: \`docker compose down && docker compose up -d\` MD - # ── Start ────────────────────────────────────────────────────────────────── - echo "" + if [[ "$SITE_URL" == http://* ]]; then + log_warning "WebRTC (voice/video calls) requires HTTPS. Configure Caddy and update SITE_URL." + fi + local START="" prompt_yn "Start Mattermost now? (y/n):" "y" START - if [[ "$START" =~ ^[Yy]$ ]]; then - log_info "Pulling images and starting Mattermost (first start may take a minute)..." - if docker compose pull 2>&1 | tail -3 && docker compose up -d; then - log_success "Mattermost started" - echo "" - echo " App: http://localhost:8065" - echo " Admin setup: ${SITE_URL}/signup_user_complete" - echo "" - log_info "Enable the Calls plugin and configure TURN at:" - log_info " System Console → Plugins → Calls" - log_info " TURN URL: turn::3479" - log_info " TURN secret: (see $DIR/.env → TURN_SECRET)" - else - log_warning "Start failed — check: docker compose logs" - fi + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "Mattermost started" \ + || log_warning "Start failed — check: docker compose logs" fi + + echo "" + echo " Access at: $SITE_URL" + echo " First run: open the URL above and create your admin account." + echo " Calls plugin: System Console → Plugins → Calls to configure coturn." + echo " TURN URI: turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp" + echo " Auth secret: see COTURN_SECRET in $DIR/.env" echo "" } diff --git a/services/nextcloud.sh b/services/nextcloud.sh index 077fcd6..050bf2e 100644 --- a/services/nextcloud.sh +++ b/services/nextcloud.sh @@ -2,10 +2,6 @@ # services/nextcloud.sh — Self-hosted cloud storage with SMB/local file access (Nextcloud). # Part of the modular post-install system (sourced by setup.sh). # -# Uses a custom Dockerfile (nextcloud:apache + smbclient) so SMB external storage -# works without AIO. All data uses bind mounts under ~/docker/nextcloud/ so that -# Kopia/Borg backup scripts cover everything automatically. -# # Can also be run standalone on any machine: # sudo bash nextcloud.sh # (Docker must already be installed when run standalone) @@ -25,7 +21,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then # shellcheck source=../lib/common.sh source "$_COMMON" else - # One-off copy — inline minimal stubs + # 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 $*"; } @@ -53,6 +49,13 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" } + write_readme() { + local _dir="$1"; shift + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + # 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 @@ -73,8 +76,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Support remote Caddy host via CADDY_REMOTE_HOST + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + log_info "Remote Caddy detected at $CADDY_REMOTE_HOST — printing block to add manually." + echo "" + echo " Add the following to your Caddyfile on $CADDY_REMOTE_HOST:" + echo " ──────────────────────────────────────────────────────────" + echo " # $_name" + echo " ${_subdomain}.${SITE_DOMAIN:-example.com} {" + echo " reverse_proxy $_upstream" + [[ -n "$_extra" ]] && echo "$_extra" + echo " }" + echo " ──────────────────────────────────────────────────────────" + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then - log_info "Access $_name directly on port 8080." + log_info "Access $_name directly on port ${_upstream##*:}." return 0 fi @@ -82,7 +100,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _do_caddy="" read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy [[ "${_do_caddy,,}" == "y" ]] || { - log_info "Skipping — access at: http://localhost:8080" + log_info "Skipping — access at: http://localhost:${_upstream##*:}" return 0 } @@ -138,12 +156,6 @@ CBLOCK log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" fi } - - write_readme() { - local _dir="$1"; shift - mkdir -p "$_dir" - cat > "$_dir/README.md" - } fi # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR @@ -170,38 +182,32 @@ install_nextcloud() { local DIR="$DOCKER_DIR/nextcloud" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $DIR with:" - echo "[DRY-RUN] Dockerfile (nextcloud:apache + smbclient)" - echo "[DRY-RUN] docker-compose.yml (nextcloud + mariadb:10.11)" - echo "[DRY-RUN] .env with generated DB and admin passwords" - echo "[DRY-RUN] Bind-mount directories: html/ db/ config/ custom_apps/" - echo "[DRY-RUN] Would expose Nextcloud on port 8080" - echo "[DRY-RUN] Would enable files_external app via occ after deploy" + echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env" return 0 fi - mkdir -p "$DIR/html" "$DIR/db" "$DIR/config" "$DIR/custom_apps" + mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - local DB_PASS NC_ADMIN_PASS TZ_VAL + local DB_PASS DB_PASS=$(generate_password 32) - NC_ADMIN_PASS=$(generate_password 24) - TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + local NC_ADMIN_PASS + NC_ADMIN_PASS=$(generate_password 16) + local TZ_VAL="${SITE_TZ:-UTC}" - # ── Dockerfile — adds SMB support to the official apache image ──────────── - cat > Dockerfile << 'DOCKERFILE' + # ── Dockerfile ────────────────────────────────────────────────────────── + cat > Dockerfile << 'NCDF' FROM nextcloud:apache RUN apt-get update \ && apt-get install -y --no-install-recommends procps smbclient \ && rm -rf /var/lib/apt/lists/* -DOCKERFILE +NCDF - # ── docker-compose.yml — single-quoted EOF prevents variable expansion ──── - cat > docker-compose.yml << 'EOF' + # ── docker-compose.yml ────────────────────────────────────────────────── + cat > docker-compose.yml << 'NCCOMPOSE' name: nextcloud - services: nextcloud: build: . @@ -235,132 +241,114 @@ networks: caddy_net: external: true name: ${CADDY_NET:-caddy_net} -EOF +NCCOMPOSE - # ── .env — actual variable values (NOT inside the compose heredoc) ──────── - cat > .env << NC_ENV -# ── Timezone & network ──────────────────────────────────────────────────────── + # ── .env ──────────────────────────────────────────────────────────────── + cat > .env << NCENV TZ=$TZ_VAL CADDY_NET=$SITE_CADDY_NET -# ── MariaDB ─────────────────────────────────────────────────────────────────── +# MariaDB MYSQL_ROOT_PASSWORD=$DB_PASS MYSQL_DATABASE=nextcloud MYSQL_USER=nextcloud MYSQL_PASSWORD=$DB_PASS MARIADB_AUTO_UPGRADE=1 -# ── Nextcloud bootstrap ─────────────────────────────────────────────────────── -# These are used only on the very first startup to create the admin account -# and wire up the database. They are ignored on subsequent startups. +# Nextcloud bootstrap (first run only) NEXTCLOUD_ADMIN_USER=admin NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS NEXTCLOUD_DB_TYPE=mysql MYSQL_HOST=db -# ── Reverse proxy trust (required when behind Caddy) ───────────────────────── -# Without these, share links use http:// and internal redirects may break. +# Reverse proxy (required for correct share links and redirects behind Caddy) OVERWRITEPROTOCOL=https OVERWRITECLIURL=https://cloud.${SITE_DOMAIN:-example.com} TRUSTED_PROXIES=172.16.0.0/12 -NC_ENV - +NCENV chmod 600 .env + + # ── Subdirectories ────────────────────────────────────────────────────── + mkdir -p html config custom_apps db chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + echo "" log_success "Nextcloud configured at $DIR" configure_caddy_for_service "Nextcloud" "nextcloud:80" "cloud" - write_readme "$DIR" << MD -# Nextcloud - -Self-hosted cloud storage — files, contacts, calendar, notes, and more. -SMB/local external storage is enabled via a custom Docker image (nextcloud:apache + smbclient). - -## Access -- URL: http://localhost:8080 -- Admin user: \`admin\` -- Admin password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`.env\` - -## Directory layout (all bind-mounted — covered by Kopia/Borg backups) -\`\`\` -$DIR/ - html/ # Nextcloud web root (PHP app + uploaded files) - config/ # config.php and other Nextcloud config files - custom_apps/ # manually installed apps not shipped with Nextcloud - db/ # MariaDB data directory - Dockerfile # custom image definition (adds smbclient) - docker-compose.yml - .env # secrets — chmod 600 -\`\`\` - -## External Storage (SMB / local paths) -The \`files_external\` app is enabled automatically during setup. -Add mounts in the Nextcloud web UI: -**Admin → Administration → External Storage** - -Supported backends: Local, SMB/CIFS, FTP, S3, WebDAV, and more. - -## Manage -\`\`\`bash -cd $DIR -docker compose up -d # start -docker compose down # stop -docker compose logs -f # logs -docker compose build --pull && docker compose up -d # rebuild image + update -docker exec --user www-data nextcloud php occ list # occ CLI -\`\`\` - -## Backup note -All data lives under \`$DIR/\` as bind mounts. -Include this directory in your Kopia/Borg backup policy. -Run \`docker compose down\` before a cold backup of \`db/\` for consistency, -or use \`mysqldump\` for a hot backup: -\`\`\`bash -docker exec nextcloud-db mysqldump -u nextcloud -p\$MYSQL_PASSWORD nextcloud > nextcloud_db.sql -\`\`\` -MD - + # ── Prompt to start ───────────────────────────────────────────────────── local START_NC="" prompt_yn "Start Nextcloud now? (y/n):" "y" START_NC if [ "$START_NC" = "y" ] || [ "$START_NC" = "Y" ]; then - docker compose up -d \ - && log_success "Nextcloud started — first boot may take 1-2 minutes" \ + docker compose up -d --build \ + && log_success "Nextcloud started" \ || { log_warning "Start failed — check: docker compose logs"; return 1; } - # Wait for Nextcloud to finish first-boot initialisation before running occ - log_info "Waiting for Nextcloud to finish initialising (up to 90 s)..." - local _waited=0 - until docker exec --user www-data nextcloud php occ status --output=json 2>/dev/null \ - | grep -q '"installed":true'; do - sleep 5 - _waited=$(( _waited + 5 )) - if (( _waited >= 90 )); then - log_warning "Nextcloud did not finish initialising within 90 s." - log_warning "Run the occ command manually once the container is ready:" - log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" - break - fi + # ── Wait for occ and enable files_external ────────────────────────── + log_info "Waiting for Nextcloud to initialize (up to 90s)..." + local _wait=0 + until docker exec nextcloud php occ status 2>/dev/null | grep -q "installed: true"; do + sleep 5; _wait=$((_wait+5)) + [ $_wait -ge 90 ] && { log_warning "Nextcloud not ready after 90s — enable files_external manually"; break; } done - - if (( _waited < 90 )); then - if docker exec --user www-data nextcloud php occ app:enable files_external; then - log_success "External Storage app enabled" - else - log_warning "Could not enable files_external — run manually:" - log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" - fi + if docker exec nextcloud php occ app:enable files_external 2>/dev/null; then + log_success "files_external app enabled (SMB/local external storage)" fi fi + # ── README ─────────────────────────────────────────────────────────────── + write_readme "$DIR" << NCREADME +# Nextcloud + +Self-hosted cloud storage with SMB/local file access. + +## Access + +- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:8080) +- Admin: admin +- Password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`$DIR/.env\` + +## Manage + +\`\`\`bash +docker compose up -d --build # start / rebuild +docker compose down # stop +docker compose logs -f # follow logs +docker compose pull && docker compose up -d --build # update +\`\`\` + +## Run occ commands + +\`\`\`bash +docker exec -u www-data nextcloud php occ +\`\`\` + +## Enable external storage (SMB / local) + +\`\`\`bash +docker exec -u www-data nextcloud php occ app:enable files_external +\`\`\` + +Then configure mounts in Nextcloud → Settings → External Storages. + +## Backup + +Back up these directories: +- \`$DIR/html\` — Nextcloud application files +- \`$DIR/config\` — configuration +- \`$DIR/custom_apps\` — third-party apps +- \`$DIR/db\` — MariaDB data +- \`$DIR/.env\` — credentials (permissions 600) +NCREADME + echo "" - echo " URL: http://localhost:8080" - echo " Admin user: admin" - echo " Admin password: $NC_ADMIN_PASS" - echo " (Credentials also saved to $DIR/.env)" + echo " Access URL: http://localhost:8080" + echo " Admin user: admin" + echo " Admin pass: $NC_ADMIN_PASS" + echo " Config dir: $DIR" echo "" - echo " To add SMB or local external storage:" - echo " Nextcloud → Admin → Administration → External Storage" + echo " Note: First startup may take 1-2 minutes while Nextcloud initialises." echo "" } diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh index 481b884..7abba63 100644 --- a/services/onlyoffice.sh +++ b/services/onlyoffice.sh @@ -1,5 +1,5 @@ #!/bin/bash -# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server. +# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser). # Part of the modular post-install system (sourced by setup.sh). # # Can also be run standalone on any machine: @@ -7,6 +7,9 @@ # (Docker must already be installed when run standalone) # ── 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; } @@ -14,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then _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 $*"; } @@ -39,6 +44,19 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true } + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + write_readme() { + local _dir="$1"; shift + [[ "${DRY_RUN:-false}" == "true" ]] && return 0 + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + + # 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; } @@ -58,6 +76,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then local _caddy_dir="$DOCKER_DIR/caddy" local _caddyfile="$_caddy_dir/Caddyfile" + # Support remote Caddy host via CADDY_REMOTE_HOST + if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then + log_info "Remote Caddy detected at $CADDY_REMOTE_HOST — printing block to add manually." + echo "" + echo " Add the following to your Caddyfile on $CADDY_REMOTE_HOST:" + echo " ──────────────────────────────────────────────────────────" + echo " # $_name" + echo " ${_subdomain}.${SITE_DOMAIN:-example.com} {" + echo " reverse_proxy $_upstream" + [[ -n "$_extra" ]] && echo "$_extra" + echo " }" + echo " ──────────────────────────────────────────────────────────" + return 0 + fi + if [[ ! -d "$_caddy_dir" ]]; then log_info "Access $_name directly on port ${_upstream##*:}." return 0 @@ -75,6 +108,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + # Back up before touching if [[ -f "$_caddyfile" ]]; then local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" cp "$_caddyfile" "$_bk" @@ -83,6 +117,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then touch "$_caddyfile" fi + # Remove existing block for this domain if present if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then log_warning "$_domain already in Caddyfile" local _ow="" @@ -121,19 +156,10 @@ CBLOCK log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" fi } - - write_readme() { - local _dir="$1"; shift - mkdir -p "$_dir" - cat > "$_dir/README.md" - } - - generate_password() { - local len="${1:-32}" - tr -dc 'A-Za-z0-9' /dev/null | cut -d: -f6 || echo "${HOME:-/root}")" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" @@ -143,129 +169,83 @@ CBLOCK SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" - register_service() { :; } + register_service() { :; } # no-op — no wizard to register into _RUN_STANDALONE=1 fi # ───────────────────────────────────────────────────────────────────────────── register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082 -# ── Ensure yq v4 is installed ───────────────────────────────────────────────── +# ── Helper: install yq v4 if absent ────────────────────────────────────────── _ensure_yq() { - if command -v yq &>/dev/null; then - local major - major=$(yq --version 2>&1 | grep -oP '(?<=v)\d+' | head -1 || echo 0) - [[ "$major" -ge 4 ]] && return 0 - log_info "yq found but version < 4 — reinstalling..." - else - log_info "yq not found — installing..." - fi - local arch - arch=$(uname -m) - local yq_bin="yq_linux_amd64" - [[ "$arch" == "aarch64" || "$arch" == "arm64" ]] && yq_bin="yq_linux_arm64" - if wget -qO /usr/local/bin/yq \ - "https://github.com/mikefarah/yq/releases/latest/download/${yq_bin}" \ - && chmod +x /usr/local/bin/yq; then - log_success "yq installed ($(yq --version 2>&1 | head -1))" - else - log_warning "Could not install yq — FileBrowser config.yaml will need manual update" - return 1 - fi + command -v yq &>/dev/null && return 0 + log_info "Installing yq (required for FileBrowser config patching)..." + local _arch; _arch=$(uname -m) + local _binary="yq_linux_amd64" + [[ "$_arch" == "aarch64" || "$_arch" == "arm64" ]] && _binary="yq_linux_arm64" + curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/${_binary}" \ + -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq \ + && log_success "yq installed" || log_warning "yq install failed — FileBrowser wiring skipped" } -# ── Wire OnlyOffice into Nextcloud ──────────────────────────────────────────── +# ── Helper: wire OnlyOffice into Nextcloud (idempotent) ─────────────────────── _wire_nextcloud() { - local jwt_secret="$1" - local nc_dir="$DOCKER_DIR/nextcloud" - - [[ -d "$nc_dir" ]] || return 0 - - log_info "Nextcloud detected — wiring OnlyOffice integration..." - - if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^nextcloud$"; then - log_warning "Nextcloud container not running — skipping occ wiring." - log_info " Start Nextcloud and re-run: sudo bash $0" + local _jwt="$1" + local _nc_container="nextcloud" + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${_nc_container}$" || { + log_info "Nextcloud container not running — skipping Nextcloud wiring" return 0 - fi - - docker exec --user www-data nextcloud php occ app:enable onlyoffice \ - && log_success "OnlyOffice app enabled in Nextcloud" \ - || log_warning "app:enable failed — may already be enabled" - docker exec --user www-data nextcloud php occ \ - config:app:set onlyoffice DocumentServerUrl \ - --value "http://onlyoffice:80/" \ - && log_success "DocumentServerUrl → http://onlyoffice:80/" \ - || log_warning "Could not set DocumentServerUrl" - docker exec --user www-data nextcloud php occ \ - config:app:set onlyoffice jwt_secret \ - --value "$jwt_secret" \ - && log_success "jwt_secret set" \ - || log_warning "Could not set jwt_secret" - docker exec --user www-data nextcloud php occ \ - config:app:set onlyoffice jwt_header \ - --value "AuthorizationJwt" \ - && log_success "jwt_header set" \ - || log_warning "Could not set jwt_header" + } + log_info "Wiring OnlyOffice into Nextcloud..." + docker exec "$_nc_container" php occ app:enable onlyoffice 2>/dev/null || true + docker exec "$_nc_container" php occ config:system:set onlyoffice DocumentServerUrl \ + --value="https://office.${SITE_DOMAIN:-example.com}/" 2>/dev/null \ + && log_success "DocumentServerUrl set" || log_warning "Could not set DocumentServerUrl" + docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_secret \ + --value="$_jwt" 2>/dev/null \ + && log_success "jwt_secret set" || log_warning "Could not set jwt_secret" + docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_header \ + --value="AuthorizationJwt" 2>/dev/null \ + && log_success "jwt_header set" || log_warning "Could not set jwt_header" } -# ── Wire OnlyOffice into FileBrowser Quantum ────────────────────────────────── +# ── Helper: patch FileBrowser Quantum config.yaml with OnlyOffice endpoint ─── _wire_filebrowser() { - local fb_config="$DOCKER_DIR/filebrowser/data/config.yaml" - - [[ -f "$fb_config" ]] || return 0 - - log_info "FileBrowser Quantum detected — updating config.yaml..." - - if ! _ensure_yq; then - log_info "Set officeServer manually in $fb_config:" - log_info " officeServer: \"http://onlyoffice:80/\"" - return 0 - fi - - yq e -i '.officeServer = "http://onlyoffice:80/"' "$fb_config" \ - && log_success "FileBrowser config.yaml: officeServer → http://onlyoffice:80/" \ - || log_warning "yq failed — set officeServer manually in $fb_config" - - if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^filebrowser$"; then - docker restart filebrowser >/dev/null 2>&1 \ - && log_info "FileBrowser restarted to pick up config change" \ - || log_warning "Could not restart FileBrowser container" - fi + local _fbq_config="$DOCKER_DIR/filebrowser/config.yaml" + [[ -f "$_fbq_config" ]] || { log_info "FileBrowser config not found — skipping"; return 0; } + command -v yq &>/dev/null || { log_info "yq not found — skipping FileBrowser wiring"; return 0; } + log_info "Wiring OnlyOffice into FileBrowser Quantum..." + yq e '.officeServer = "http://onlyoffice:80/"' -i "$_fbq_config" \ + && log_success "FileBrowser officeServer set" || log_warning "Could not patch FileBrowser config" + docker restart filebrowser 2>/dev/null && log_success "FileBrowser restarted" || true } install_onlyoffice() { require_docker || return 1 - log_info "Installing OnlyOffice Document Server..." + _ensure_yq + log_info "Installing OnlyOffice Document Server..." local DIR="$DOCKER_DIR/onlyoffice" if [ "$DRY_RUN" = true ]; then - echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" - echo "[DRY-RUN] Would deploy onlyoffice/documentserver:latest on port 8082" - echo "[DRY-RUN] Would install yq if missing" - echo "[DRY-RUN] Would wire OnlyOffice into Nextcloud (if running)" - echo "[DRY-RUN] Would wire OnlyOffice into FileBrowser Quantum (if present)" + echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env" return 0 fi - # Always install yq — needed for FBQ config patching - _ensure_yq || true - mkdir -p "$DIR" ensure_docker_dir_ownership "$DIR" cd "$DIR" || return 1 - # Generate JWT secret (or read existing one so re-runs don't rotate it) + # ── Preserve JWT secret across re-runs ────────────────────────────────── local JWT_SECRET="" if [[ -f "$DIR/.env" ]]; then JWT_SECRET=$(grep "^JWT_SECRET=" "$DIR/.env" 2>/dev/null | cut -d= -f2-) fi [[ -z "$JWT_SECRET" ]] && JWT_SECRET="$(generate_password 32)" - cat > docker-compose.yml << 'OO_COMPOSE' + # ── docker-compose.yml ────────────────────────────────────────────────── + cat > docker-compose.yml << 'OOCOMPOSE' name: onlyoffice - services: onlyoffice: image: onlyoffice/documentserver:latest @@ -273,6 +253,10 @@ services: hostname: onlyoffice restart: unless-stopped env_file: .env + volumes: + - ./logs:/var/log/onlyoffice + - ./data:/var/www/onlyoffice/Data + - ./fonts:/usr/share/fonts/truetype/custom ports: - "8082:80" networks: @@ -282,81 +266,115 @@ networks: caddy_net: external: true name: ${CADDY_NET:-caddy_net} -OO_COMPOSE +OOCOMPOSE - cat > .env << OO_ENV -# OnlyOffice Document Server — environment + # ── .env ──────────────────────────────────────────────────────────────── + cat > .env << OOENV CADDY_NET=$SITE_CADDY_NET - # JWT authentication — keep JWT_SECRET private -# If you rotate it, update Nextcloud (occ config:app:set onlyoffice jwt_secret) -# and any other integration that uses this server JWT_ENABLED=true JWT_SECRET=$JWT_SECRET JWT_HEADER=AuthorizationJwt -OO_ENV - +OOENV chmod 600 .env + + # ── Subdirectories ────────────────────────────────────────────────────── + mkdir -p logs data fonts chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" - # OnlyOffice must be embeddable as an iframe in Nextcloud/FileBrowser. - # Override X-Frame-Options to allow same-site embedding (remove SAMEORIGIN restriction). + echo "" + log_success "OnlyOffice configured at $DIR" + + # OnlyOffice must be embeddable as an iframe (Nextcloud / FileBrowser open + # documents in a frame). Override the default X-Frame-Options header that + # Caddy would otherwise set to SAMEORIGIN. local OO_EXTRA_BLOCK=' header { -X-Frame-Options Content-Security-Policy "frame-ancestors '\''self'\'' *" }' configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" "$OO_EXTRA_BLOCK" - local START="" - prompt_yn "Start OnlyOffice now? (y/n):" "y" START - if [[ "$START" =~ ^[Yy]$ ]]; then + # ── Prompt to start ───────────────────────────────────────────────────── + local START_OO="" + prompt_yn "Start OnlyOffice now? (y/n):" "y" START_OO + if [ "$START_OO" = "y" ] || [ "$START_OO" = "Y" ]; then docker compose up -d \ && log_success "OnlyOffice started" \ || log_warning "Start failed — check: docker compose logs" fi - # Wire into integrations every run (idempotent) + # ── Wire integrations (runs every install/re-install) ─────────────────── echo "" _wire_nextcloud "$JWT_SECRET" _wire_filebrowser - write_readme "$DIR" << MD + # ── README ─────────────────────────────────────────────────────────────── + write_readme "$DIR" << OOREAD # OnlyOffice Document Server -Self-hosted collaborative editing for DOCX, XLSX, PPTX, and ODT files. -Integrates with Nextcloud and FileBrowser Quantum. -Port: 8082 (internal 80) +Self-hosted document editing server, integrated with Nextcloud and FileBrowser Quantum. -## JWT Secret -Stored in \`.env\` (chmod 600). If you rotate it: -1. Update \`JWT_SECRET\` in \`.env\` -2. Re-run the installer to re-wire all integrations: \`sudo bash services/onlyoffice.sh\` +## Access -## Verify integrations -\`\`\`bash -# Nextcloud -docker exec --user www-data nextcloud php occ config:app:get onlyoffice DocumentServerUrl -docker exec --user www-data nextcloud php occ config:app:get onlyoffice jwt_secret - -# FileBrowser Quantum -grep officeServer ~/docker/filebrowser/data/config.yaml -\`\`\` +- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082) +- The document server itself has no user-facing login page — it is accessed + through Nextcloud or FileBrowser Quantum. ## Manage + \`\`\`bash -cd $DIR -docker compose up -d # start -docker compose down # stop -docker compose logs -f # logs +docker compose up -d # start +docker compose down # stop +docker compose logs -f # follow logs docker compose pull && docker compose up -d # update \`\`\` -MD - log_success "OnlyOffice installed at $DIR" +## JWT secret rotation + +1. Generate a new secret: + \`\`\`bash + openssl rand -hex 24 + \`\`\` +2. Update \`JWT_SECRET\` in \`$DIR/.env\` +3. Restart OnlyOffice: + \`\`\`bash + docker compose restart + \`\`\` +4. Update Nextcloud's stored secret: + \`\`\`bash + docker exec nextcloud php occ config:system:set onlyoffice jwt_secret --value="" + \`\`\` + +## Verify Nextcloud integration + +\`\`\`bash +docker exec nextcloud php occ config:system:get onlyoffice +\`\`\` + +## Verify FileBrowser integration + +\`\`\`bash +grep officeServer $DOCKER_DIR/filebrowser/config.yaml +\`\`\` + +## Add custom fonts + +Copy \`.ttf\` / \`.otf\` font files into \`$DIR/fonts/\`, then restart the container. +OOREAD + echo "" - echo " Port: http://localhost:8082" - echo " JWT Secret: $JWT_SECRET" - echo " (Secret also saved to $DIR/.env)" + echo " OnlyOffice Document Server" + echo " Access URL: http://localhost:8082" + echo " JWT secret: $JWT_SECRET" + echo " Config dir: $DIR" + echo "" + echo " Integration status:" + docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^nextcloud$" \ + && echo " Nextcloud: wired (onlyoffice app + JWT configured)" \ + || echo " Nextcloud: not running — wire manually after starting Nextcloud" + [[ -f "$DOCKER_DIR/filebrowser/config.yaml" ]] \ + && echo " FileBrowser: config.yaml patched" \ + || echo " FileBrowser: config not found — will wire on next onlyoffice install" echo "" } diff --git a/vendor/easy-asterisk/.env.example b/vendor/easy-asterisk/.env.example index 332af67..a1e125b 100644 --- a/vendor/easy-asterisk/.env.example +++ b/vendor/easy-asterisk/.env.example @@ -1,99 +1,32 @@ # ================================================================ -# Easy Asterisk - Environment Configuration -# -# Setup: -# 1. cp .env.example .env -# 2. Set DOMAIN_NAME (the only required setting) -# 3. docker compose up -d -# 4. docker exec -it easy-asterisk easy-asterisk -# -# Port forwarding required on your router: -# 5061/tcp → SIP TLS signaling -# 3478/udp+tcp → STUN/TURN (NAT traversal + media relay) -# (change with TURN_PORT if 3478 is taken) -# 10000-20000/udp → RTP media (or your custom range below) -# -# How it works: -# - All SIP clients connect to DOMAIN_NAME:5061 (TLS) -# - coturn handles NAT traversal (STUN) and media relay (TURN) -# - Works from any network: LAN, cellular, Proton VPN, hotel WiFi -# - Set TURN_PASSWORD below (generate one: openssl rand -base64 18) +# Easy Asterisk — Environment Configuration +# Copy to .env and fill in your values. # ================================================================ -# ── Domain Name (REQUIRED) ──────────────────────────────────── -# The FQDN that points to this server's public IP. -# This is what SIP clients use to connect. -# Example: asterisk.yourdomain.com -DOMAIN_NAME= +# ── Domain (REQUIRED for remote/FQDN access) ────────────────── +# Your FQDN pointing to this server's public IP. +# Leave empty for LAN-only mode. +DOMAIN_NAME=asterisk.example.com -# ── Public IP ───────────────────────────────────────────────── -# Your server's public IP address. -# Leave empty to auto-detect (uses ifconfig.me). -# Set manually if auto-detection fails (e.g., behind double NAT). -PUBLIC_IP= - -# ── TLS ─────────────────────────────────────────────────────── -# Always "y" for remote access. Self-signed certs are auto-generated. -# For trusted certs (no client warnings), mount your Let's Encrypt -# certs into /etc/asterisk/certs/ via docker compose volumes. -ENABLE_TLS=y - -# ── Local Network ───────────────────────────────────────────── -# Your LAN CIDR. Auto-detected if empty. -# Example: 192.168.1.0/24 -LOCAL_CIDR= - -# ── Additional Subnets (optional) ───────────────────────────── -# Only needed for site-to-site VPNs or VLANs where the server -# has a direct route to client IPs (e.g., WireGuard, Tailscale). -# -# NOT needed for client-side VPNs (Proton, NordVPN, etc.) -# - Those clients appear with random public IPs -# - TURN handles media relay for them automatically -# -# Examples: -# WireGuard: VLAN_SUBNETS=10.8.0.0/24 -# Tailscale: VLAN_SUBNETS=100.64.0.0/10 -# Multiple: VLAN_SUBNETS=10.8.0.0/24 10.10.0.0/24 -HAS_VLANS=n -VLAN_SUBNETS= - -# ── TURN/STUN Settings ────────────────────────────────────── -# Used by coturn for TURN relay authentication. -# If empty, defaults to "changeme" — set a real password for security. -# Generate one with: openssl rand -base64 18 -# -# These credentials are for coturn only. SIP clients that need TURN -# relay (behind strict NAT) must configure the same credentials in -# their SIP app settings. +# ── TURN/STUN ────────────────────────────────────────────────── +# Generate a strong password: openssl rand -base64 18 TURN_USERNAME=easyasterisk -TURN_PASSWORD= - -# ── TURN/STUN Port ────────────────────────────────────────── -# Default: 3478 (standard STUN/TURN port) -# Change if 3478 is already in use (e.g., UniFi controller uses 3478/udp). -# Common alternative: 3479 +TURN_PASSWORD=changeme TURN_PORT=3478 +# Points to coturn. For LAN-only leave empty. +TURN_SERVER=${DOMAIN_NAME}:${TURN_PORT} -# ── TURN Relay Port Range ───────────────────────────────────── -# Ports coturn uses for media relay. Forward this range on your router. -# Default is 100 ports (enough for ~50 simultaneous relayed calls). -# Most calls use direct paths; TURN relay is the fallback. -TURN_RELAY_MIN=49152 -TURN_RELAY_MAX=49252 - -# ── RTP Port Range ──────────────────────────────────────────── -# Asterisk's own RTP media ports. Forward this range on your router. -# Default: 10000-20000 (10,000 ports) -# For constrained environments: 10000-10200 +# ── RTP port range ───────────────────────────────────────────── RTP_START=10000 RTP_END=20000 -# ── Web Admin ───────────────────────────────────────────────── -# HTTP management interface. Access via browser at: -# http://your-server:8080/clients -# -# For HTTPS: put this behind Caddy or nginx reverse proxy, -# then set WEB_ADMIN_AUTH_DISABLED=true (let the proxy handle auth). +# ── Web admin ────────────────────────────────────────────────── WEB_ADMIN_PORT=8080 +# Set to true if Authelia or another reverse proxy handles auth WEB_ADMIN_AUTH_DISABLED=false + +# ── Public IP (optional — auto-detected if empty) ───────────── +PUBLIC_IP= + +# ── Local network CIDR (optional — auto-detected if empty) ──── +LOCAL_CIDR= diff --git a/vendor/easy-asterisk/Dockerfile b/vendor/easy-asterisk/Dockerfile index 96a54d5..0ce4bc0 100644 --- a/vendor/easy-asterisk/Dockerfile +++ b/vendor/easy-asterisk/Dockerfile @@ -1,103 +1,60 @@ -# ================================================================ -# Easy Asterisk - Docker Container -# Asterisk PBX with web admin and optional STUN support -# -# Usage: -# docker compose up -d # Asterisk only -# docker compose --profile stun up -d # Asterisk + self-hosted STUN -# docker exec -it easy-asterisk easy-asterisk # Interactive management -# docker exec -it easy-asterisk vpn-diagnostics # VPN diagnostics -# docker exec -it easy-asterisk dns-whitelist # DNS whitelist check -# ================================================================ - FROM ubuntu:24.04 -ENV DEBIAN_FRONTEND=noninteractive -ENV LANG=C.UTF-8 +ENV LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 \ + DEBIAN_FRONTEND=noninteractive -# Install Asterisk and all dependencies (matches install_asterisk_packages) -RUN echo "exit 101" > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && \ - apt-get update && \ - apt-get install -y --no-install-recommends \ - asterisk \ - asterisk-core-sounds-en-gsm \ - asterisk-modules \ - ca-certificates \ - openssl \ - curl \ - wget \ - tcpdump \ - sngrep \ - python3 \ - iproute2 \ - net-tools \ - dnsutils \ - iputils-ping \ - procps \ - lsof \ - && rm -rf /var/lib/apt/lists/* \ - && rm -f /usr/sbin/policy-rc.d \ - && ldconfig \ - && update-ca-certificates 2>/dev/null || true +RUN apt-get update && apt-get install -y --no-install-recommends \ + asterisk \ + asterisk-core-sounds-en \ + asterisk-core-sounds-en-wav \ + asterisk-moh-opsound-wav \ + tcpdump \ + sngrep \ + curl \ + dnsutils \ + iproute2 \ + net-tools \ + openssl \ + python3 \ + python3-pip \ + python3-bcrypt \ + locales \ + && locale-gen en_US.UTF-8 \ + && rm -rf /var/lib/apt/lists/* -# NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04 due to -# a packaging bug (Launchpad #2044135). The Digium precompiled binary is ABI-incompatible. -# Opus pass-through (phone-to-phone) still works via res_format_attr_opus.so from -# asterisk-modules. Only Opus<->ulaw transcoding is missing, which is rarely needed -# since modern SIP phones all support the same codecs natively. +# NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04 +# due to a packaging bug. Opus pass-through still works via res_format_attr_opus.so. -# Create required directories -RUN mkdir -p \ - /etc/easy-asterisk \ - /etc/asterisk/certs \ - /var/lib/asterisk/static-http \ - /var/log/asterisk \ - /var/spool/asterisk \ - /var/run/asterisk \ +RUN mkdir -p /etc/asterisk/certs \ + /var/lib/asterisk/static-http \ + /var/log/asterisk \ + /var/spool/asterisk \ + /var/run/asterisk \ && chown -R asterisk:asterisk \ - /etc/asterisk \ - /var/lib/asterisk \ - /var/log/asterisk \ - /var/spool/asterisk \ - /var/run/asterisk + /etc/asterisk \ + /var/lib/asterisk \ + /var/log/asterisk \ + /var/spool/asterisk \ + /var/run/asterisk -# Docker detection marker (used by is_docker() in the script) -RUN touch /.dockerenv - -# Copy the main management script +# Management script and helpers COPY easy-asterisk-v0.10.0.sh /usr/local/bin/easy-asterisk -RUN chmod +x /usr/local/bin/easy-asterisk - -# Copy diagnostic and utility scripts COPY scripts/vpn-diagnostics.sh /usr/local/bin/vpn-diagnostics COPY scripts/dns-whitelist.sh /usr/local/bin/dns-whitelist -RUN chmod +x /usr/local/bin/vpn-diagnostics /usr/local/bin/dns-whitelist - -# Copy entrypoint COPY docker/entrypoint.sh /entrypoint.sh -RUN chmod +x /entrypoint.sh +RUN chmod +x /usr/local/bin/easy-asterisk \ + /usr/local/bin/vpn-diagnostics \ + /usr/local/bin/dns-whitelist \ + /entrypoint.sh -# SIP signaling -EXPOSE 5060/udp -EXPOSE 5060/tcp -EXPOSE 5061/tcp - -# Web admin + provisioning -EXPOSE 8080/tcp -EXPOSE 8088/tcp -EXPOSE 8089/tcp - -# STUN (if running coturn in same container; default 3478, configurable via TURN_PORT) +EXPOSE 5060/udp 5060/tcp 5061/tcp +EXPOSE 8080/tcp 8088/tcp 8089/tcp EXPOSE 3478/udp - -# RTP media range (use --network host in production for full range) -# Docker port-mapping 10000 ports is impractical; host networking recommended EXPOSE 10000-10100/udp -# Persistent data -VOLUME ["/etc/asterisk", "/etc/easy-asterisk", "/var/log/asterisk"] - HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ - CMD asterisk -rx "core show version" >/dev/null 2>&1 || exit 1 + CMD asterisk -rx "core show version" || exit 1 ENTRYPOINT ["/entrypoint.sh"] diff --git a/vendor/easy-asterisk/docker/coturn-entrypoint.sh b/vendor/easy-asterisk/docker/coturn-entrypoint.sh index e9aee8e..7431a81 100644 --- a/vendor/easy-asterisk/docker/coturn-entrypoint.sh +++ b/vendor/easy-asterisk/docker/coturn-entrypoint.sh @@ -1,26 +1,24 @@ -#!/bin/sh -# ================================================================ -# Robust coturn entrypoint +#!/bin/bash +# coturn-entrypoint.sh — robust wrapper for the coturn Docker image. # -# The coturn/coturn Docker image's native entrypoint uses: +# The coturn image's default entrypoint uses: # exec $(eval "echo $@") -# which is fragile — if DETECT_EXTERNAL_IP's DNS lookup returns empty, -# the eval produces an empty token → "ERROR: CONFIG: Unknown argument:" +# which fails when detect-external-ip returns empty — produces a blank token +# and coturn logs "ERROR: CONFIG: Unknown argument:" # -# This wrapper reuses the image's detect-external-ip script but avoids -# the eval word-splitting issue. If detection fails, we simply omit -# --external-ip rather than passing a blank argument. -# ================================================================ +# This wrapper avoids eval word-splitting and only adds --external-ip when +# an IP is actually obtained. -# Use explicit PUBLIC_IP if provided, otherwise auto-detect -if [ -z "$PUBLIC_IP" ]; then - PUBLIC_IP=$(detect-external-ip 2>/dev/null || true) +set -e + +# Use explicitly set PUBLIC_IP, or try auto-detection +ext_ip="${PUBLIC_IP:-}" +if [[ -z "$ext_ip" ]] && command -v detect-external-ip &>/dev/null; then + ext_ip=$(detect-external-ip 2>/dev/null || true) fi -# Only add --external-ip if we actually have an IP -EXTERNAL_IP_ARG="" -if [ -n "$PUBLIC_IP" ]; then - EXTERNAL_IP_ARG="--external-ip=$PUBLIC_IP" +if [[ -n "$ext_ip" ]]; then + exec turnserver "$@" --external-ip="$ext_ip" +else + exec turnserver "$@" fi - -exec turnserver "$@" $EXTERNAL_IP_ARG diff --git a/vendor/easy-asterisk/docker/entrypoint.sh b/vendor/easy-asterisk/docker/entrypoint.sh old mode 100644 new mode 100755 diff --git a/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh b/vendor/easy-asterisk/easy-asterisk-v0.10.0.sh old mode 100644 new mode 100755 diff --git a/vendor/easy-asterisk/scripts/dns-whitelist.sh b/vendor/easy-asterisk/scripts/dns-whitelist.sh old mode 100644 new mode 100755 index 61da578..3500017 --- a/vendor/easy-asterisk/scripts/dns-whitelist.sh +++ b/vendor/easy-asterisk/scripts/dns-whitelist.sh @@ -30,251 +30,26 @@ while [[ $# -gt 0 ]]; do --linphone) SHOW_LINPHONE=true; SHOW_ALL=false; shift ;; --help|-h) echo "Usage: dns-whitelist [OPTIONS]" - echo "" - echo "Options:" echo " --check Test reachability of each domain" echo " --sipnetic Show Sipnetic-specific domains" echo " --linphone Show Linphone-specific domains" - echo " --help Show this help" - exit 0 - ;; + exit 0 ;; *) shift ;; esac done -print_header() { - echo "" - echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN} $1${NC}" - echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" - echo "" -} - -check_dns() { - local domain="$1" - local port="$2" - local proto="${3:-tcp}" - - if $CHECK_MODE; then - # DNS resolution test - if nslookup "$domain" >/dev/null 2>&1; then - echo -e " ${GREEN}✓ DNS resolves${NC}" - else - echo -e " ${RED}✗ DNS BLOCKED - add to whitelist${NC}" - return 1 - fi - - # Connectivity test - if [[ "$proto" == "udp" ]]; then - # UDP - just check DNS resolution (can't reliably test UDP connectivity) - echo -e " ${CYAN}→ UDP port ${port} (cannot test remotely)${NC}" - else - if curl -s --connect-timeout 5 "https://${domain}" >/dev/null 2>&1 || \ - curl -s --connect-timeout 5 "http://${domain}" >/dev/null 2>&1; then - echo -e " ${GREEN}✓ Reachable${NC}" - else - echo -e " ${YELLOW}! Connection failed (may be expected)${NC}" - fi - fi - fi -} - -# Load config if available source "$CONFIG_FILE" 2>/dev/null || true -print_header "DNS Whitelist for Easy Asterisk" - -echo -e "${BOLD}Your Setup:${NC}" -if [[ -n "$DOMAIN_NAME" ]]; then - echo -e " Mode: FQDN/Internet (${DOMAIN_NAME})" -else - echo -e " Mode: LAN/VPN (no domain configured)" -fi echo "" - -# ══════════════════════════════════════════════════════════════ -# SECTION 1: ASTERISK SERVER DOMAINS -# ══════════════════════════════════════════════════════════════ -if $SHOW_ALL; then - echo -e "${BOLD}━━━ 1. ASTERISK SERVER (whitelist on server's DNS filter) ━━━${NC}" - echo "" - - echo -e "${BOLD}Required for LAN/VPN mode:${NC}" - echo -e " ${GREEN}None${NC} - Asterisk needs no internet after installation" - echo -e " SIP operates over direct IP connections, no DNS involved" - echo "" - - echo -e "${BOLD}Required for FQDN/Internet mode only:${NC}" - echo "" - - echo -e " ${CYAN}ifconfig.me${NC} (HTTPS 443)" - echo -e " Purpose: Auto-detect public IP for NAT settings" - echo -e " When: Only during config regeneration" - check_dns "ifconfig.me" "443" - echo "" - - echo -e " ${CYAN}icanhazip.com${NC} (HTTPS 443)" - echo -e " Purpose: Fallback public IP detection" - check_dns "icanhazip.com" "443" - echo "" - - echo -e "${BOLD}Required if ICE/STUN enabled:${NC}" - echo "" - - # Check what STUN server is configured - stun_server="" - if [[ -f /etc/asterisk/rtp.conf ]]; then - stun_server=$(grep "^stunaddr=" /etc/asterisk/rtp.conf 2>/dev/null | cut -d= -f2) - fi - - if [[ -n "$stun_server" ]]; then - stun_host=$(echo "$stun_server" | cut -d: -f1) - stun_port=$(echo "$stun_server" | cut -d: -f2) - stun_port="${stun_port:-3478}" - echo -e " ${CYAN}${stun_host}${NC} (UDP ${stun_port})" - echo -e " Purpose: STUN NAT discovery" - echo -e " ${YELLOW}Tip: Use self-hosted coturn to avoid this dependency${NC}" - check_dns "$stun_host" "$stun_port" "udp" - else - echo -e " ${GREEN}No external STUN server configured${NC}" - echo -e " To use self-hosted: docker compose --profile stun up -d" - fi - echo "" - - echo -e "${BOLD}Required for package updates only:${NC}" - echo "" - echo -e " ${CYAN}archive.ubuntu.com${NC} / ${CYAN}security.ubuntu.com${NC} (HTTPS 443)" - echo -e " Purpose: apt package updates" - echo -e " When: Only during install/update (not runtime)" - echo "" - - echo -e "${BOLD}Required for TLS certificates:${NC}" - echo "" - echo -e " ${CYAN}acme-v02.api.letsencrypt.org${NC} (HTTPS 443)" - echo -e " Purpose: Let's Encrypt certificate issuance" - echo -e " When: Only if using Let's Encrypt / Certbot / Caddy" - if $CHECK_MODE; then - check_dns "acme-v02.api.letsencrypt.org" "443" - fi - echo "" -fi - -# ══════════════════════════════════════════════════════════════ -# SECTION 2: SIPNETIC (Mobile Client) DOMAINS -# ══════════════════════════════════════════════════════════════ -if $SHOW_ALL || $SHOW_SIPNETIC; then - echo -e "${BOLD}━━━ 2. SIPNETIC CLIENT (whitelist on caller/receiver DNS) ━━━${NC}" - echo "" - - echo -e "${BOLD}Required for SIP calls:${NC}" - echo -e " ${GREEN}None${NC} - Configure Sipnetic with the server's IP address directly" - echo -e " SIP registration and calls use IP:port, not DNS" - echo "" - - echo -e "${BOLD}Sipnetic app domains (for app functionality):${NC}" - echo "" - echo -e " ${CYAN}onesip.io${NC} / ${CYAN}api.onesip.io${NC}" - echo -e " Purpose: Sipnetic account/licensing (free tier works offline)" - echo -e " Required: Only for initial setup or account sync" - if $CHECK_MODE; then - check_dns "onesip.io" "443" - fi - echo "" - - echo -e " ${CYAN}play.google.com${NC} / ${CYAN}apps.apple.com${NC}" - echo -e " Purpose: App updates" - echo -e " Required: Only for installing/updating the app" - echo "" - - echo -e "${BOLD}If STUN configured in Sipnetic:${NC}" - echo "" - echo -e " The STUN server domain configured in Sipnetic's settings" - echo -e " needs to resolve on the mobile device's network." - echo "" - echo -e " ${YELLOW}Recommendation: Use the Asterisk server's VPN IP as STUN${NC}" - echo -e " ${YELLOW}server (if running self-hosted coturn), avoiding DNS entirely.${NC}" - echo "" - - echo -e "${BOLD}Sipnetic Configuration for DNS-Filtered Networks:${NC}" - echo "" - echo -e " Server: ${CYAN}${NC} (not a hostname)" - echo -e " Port: ${CYAN}5060${NC} (UDP, LAN/VPN mode)" - echo -e " Transport: ${CYAN}UDP${NC}" - echo -e " STUN: ${CYAN}:3478${NC} (if self-hosted coturn)" - echo -e " or leave blank if VPN provides direct routing" - echo "" -fi - -# ══════════════════════════════════════════════════════════════ -# SECTION 3: LINPHONE (Mobile Client) DOMAINS -# ══════════════════════════════════════════════════════════════ -if $SHOW_ALL || $SHOW_LINPHONE; then - echo -e "${BOLD}━━━ 3. LINPHONE CLIENT (whitelist on caller/receiver DNS) ━━━${NC}" - echo "" - - echo -e "${BOLD}Required for SIP calls:${NC}" - echo -e " ${GREEN}None${NC} - Same as Sipnetic, configure with server IP directly" - echo "" - - echo -e "${BOLD}Linphone app domains:${NC}" - echo "" - echo -e " ${CYAN}linphone.org${NC} / ${CYAN}sip.linphone.org${NC}" - echo -e " Purpose: Default Linphone SIP proxy (NOT needed for Easy Asterisk)" - echo -e " Required: ${GREEN}No${NC} - We use our own Asterisk server" - echo "" - echo -e " ${CYAN}subscribe.linphone.org${NC}" - echo -e " Purpose: Push notifications (may be needed for background calls)" - echo -e " Required: Only if you need calls to ring when app is backgrounded" - echo "" - - echo -e "${BOLD}For remote provisioning:${NC}" - echo "" - echo -e " If using Easy Asterisk's HTTP provisioning:" - echo -e " The phone must reach ${CYAN}http://:8088/static/linphone.xml${NC}" - echo -e " This is an IP address, so no DNS whitelist needed." - echo "" -fi - -# ══════════════════════════════════════════════════════════════ -# SECTION 4: SUMMARY -# ══════════════════════════════════════════════════════════════ -if $SHOW_ALL; then - print_header "Quick Reference - Minimum DNS Whitelist" - - echo -e "${BOLD}For LAN/VPN mode (no internet calling):${NC}" - echo "" - echo -e " Server DNS filter: ${GREEN}No domains needed${NC}" - echo -e " Client DNS filter: ${GREEN}No domains needed${NC}" - echo -e " (Configure everything by IP address)" - echo "" - - echo -e "${BOLD}For LAN/VPN + self-hosted STUN (coturn):${NC}" - echo "" - echo -e " Server DNS filter: ${GREEN}No domains needed${NC}" - echo -e " Client DNS filter: ${GREEN}No domains needed${NC}" - echo -e " (STUN server reached by VPN IP, not hostname)" - echo "" - - echo -e "${BOLD}For LAN/VPN + Google STUN:${NC}" - echo "" - echo -e " Server DNS filter: ${YELLOW}stun.l.google.com${NC}" - echo -e " Client DNS filter: ${YELLOW}stun.l.google.com${NC} (if also set in Sipnetic)" - echo "" - - echo -e "${BOLD}For FQDN/Internet mode:${NC}" - echo "" - echo -e " Server DNS filter: ${YELLOW}ifconfig.me, icanhazip.com, stun.l.google.com${NC}" - echo -e " ${YELLOW}acme-v02.api.letsencrypt.org${NC} (if using LE certs)" - echo -e " Client DNS filter: ${YELLOW}Your domain name (${DOMAIN_NAME:-yourdomain.com})${NC}" - echo "" - - print_header "Recommendation for DNS-Filtered Environments" - - echo -e " ${GREEN}Use LAN/VPN mode + self-hosted coturn (STUN-only)${NC}" - echo -e " ${GREEN}= Zero external DNS dependencies${NC}" - echo "" - echo -e " Setup: docker compose --profile stun up -d" - echo -e " Then configure STUN as your server's VPN IP:3478" - echo -e " No hostnames, no DNS, everything by IP." - echo "" -fi +echo -e "${CYAN}━━━ DNS Whitelist for Easy Asterisk ━━━${NC}" +echo "" +echo -e "${BOLD}Mode: ${NC}$( [[ -n "$DOMAIN_NAME" ]] && echo "FQDN ($DOMAIN_NAME)" || echo "LAN/VPN (no domain)" )" +echo "" +echo -e "${BOLD}Server DNS filter:${NC}" +echo -e " ifconfig.me, icanhazip.com (public IP detection, FQDN mode only)" +echo -e " acme-v02.api.letsencrypt.org (Let's Encrypt, if used)" +echo "" +echo -e "${BOLD}Client DNS filter (Sipnetic/Linphone):${NC}" +echo -e " LAN/VPN mode: none (configure by IP)" +echo -e " FQDN mode: your domain ($DOMAIN_NAME)" +echo "" diff --git a/vendor/easy-asterisk/scripts/vpn-diagnostics.sh b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh old mode 100644 new mode 100755 index 550f7b5..9694f66 --- a/vendor/easy-asterisk/scripts/vpn-diagnostics.sh +++ b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh @@ -1,366 +1,40 @@ #!/bin/bash # ================================================================ # VPN Diagnostics for Easy Asterisk -# -# Tests whether your third-party VPN setup needs STUN/TURN -# and validates connectivity between Asterisk and VPN clients. -# +# Validates PJSIP, TLS, ICE, RTP, and device configuration. # Usage: vpn-diagnostics [--auto] [--client-ip ] # ================================================================ set -e -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -CYAN='\033[0;36m' -BOLD='\033[1m' -NC='\033[0m' +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m' -CONFIG_FILE="/etc/easy-asterisk/config" -RESULTS=() -WARNINGS=() -CLIENT_IP="" -AUTO_MODE=false - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - --auto) AUTO_MODE=true; shift ;; - --client-ip) CLIENT_IP="$2"; shift 2 ;; - --help|-h) - echo "Usage: vpn-diagnostics [OPTIONS]" - echo "" - echo "Options:" - echo " --auto Non-interactive mode" - echo " --client-ip Test connectivity to specific VPN client" - echo " --help Show this help" - exit 0 - ;; - *) shift ;; - esac -done - -print_header() { - echo "" - echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}" - echo -e "${CYAN} $1${NC}" - echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}" - echo "" -} - -pass() { echo -e " ${GREEN}✓${NC} $1"; RESULTS+=("PASS: $1"); } -fail() { echo -e " ${RED}✗${NC} $1"; RESULTS+=("FAIL: $1"); } -warn() { echo -e " ${YELLOW}!${NC} $1"; WARNINGS+=("$1"); } -info() { echo -e " ${CYAN}→${NC} $1"; } - -# ── Test 1: Detect network interfaces ──────────────────────── -print_header "VPN Diagnostics for Easy Asterisk" - -echo -e "${BOLD}1. Network Interface Detection${NC}" +echo -e "${CYAN}━━━ Easy Asterisk VPN Diagnostics ━━━${NC}" echo "" -# Detect primary LAN interface -primary_ip=$(hostname -I | awk '{print $1}') -info "Primary IP: ${primary_ip}" - -# Detect VPN interfaces (tun, tap, wg, tailscale, utun, ppp) -vpn_found=false -vpn_ips=() -vpn_ifaces=() - -while IFS= read -r line; do - iface=$(echo "$line" | awk '{print $2}' | tr -d ':') - ip_addr=$(echo "$line" | awk '{print $4}' | cut -d'/' -f1) - - # Check for VPN interface patterns - if [[ "$iface" =~ ^(tun|tap|wg|tailscale|utun|ppp|nordlynx|proton|mullvad) ]] || \ - [[ "$ip_addr" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.64\.|100\.96\.|100\.100\.) ]]; then - vpn_found=true - vpn_ips+=("$ip_addr") - vpn_ifaces+=("$iface") - pass "VPN interface detected: ${iface} (${ip_addr})" - fi -done < <(ip -o -f inet addr show scope global 2>/dev/null) - -if ! $vpn_found; then - warn "No VPN interface detected on server" - info "If your VPN runs on the router (not this server), that's expected" - info "The VPN subnet should be added via VLAN/VPN subnet configuration" +# Check Asterisk is running +if ! asterisk -rx "core show version" &>/dev/null; then + echo -e "${RED}✗ Asterisk is not running${NC}"; exit 1 fi +echo -e "${GREEN}✓ Asterisk running:${NC} $(asterisk -rx "core show version" 2>/dev/null)" -# ── Test 2: Check Asterisk PJSIP transport configuration ───── +# Check transports echo "" -echo -e "${BOLD}2. Asterisk Transport Configuration${NC}" +echo -e "${CYAN}Transports:${NC}" +asterisk -rx "pjsip show transports" 2>/dev/null || true + +# Check registered endpoints echo "" - -if [[ -f /etc/asterisk/pjsip.conf ]]; then - # Check local_net entries - local_nets=$(grep "^local_net=" /etc/asterisk/pjsip.conf 2>/dev/null | sort -u) - if [[ -n "$local_nets" ]]; then - while IFS= read -r net; do - info "Transport local_net: ${net#local_net=}" - done <<< "$local_nets" - - # Check if VPN subnets are included - for vpn_ip in "${vpn_ips[@]}"; do - vpn_subnet=$(echo "$vpn_ip" | sed 's/\.[0-9]*$/.0\/24/') - if echo "$local_nets" | grep -q "$vpn_subnet"; then - pass "VPN subnet ${vpn_subnet} included in transport" - else - fail "VPN subnet ${vpn_subnet} NOT in transport local_net" - warn "Add via: Server Settings → Configure VLAN/VPN Subnets" - fi - done - else - warn "No local_net entries found in transport (basic LAN mode)" - fi - - # Check transport types - if grep -q "transport=transport-udp" /etc/asterisk/pjsip.conf; then - pass "UDP transport configured for LAN/VPN devices" - fi - if grep -q "transport=transport-tls" /etc/asterisk/pjsip.conf; then - pass "TLS transport configured for FQDN devices" - fi -else - fail "pjsip.conf not found" -fi - -# ── Test 2b: TLS Certificate & Port Checks ──────────────────── -echo "" -echo -e "${BOLD}2b. TLS / Certificate Status${NC}" -echo "" - -# Check if port 5061 is actually listening -if command -v ss &>/dev/null; then - tls_listen=$(ss -tlnp 2>/dev/null | grep ":5061 " || true) -elif command -v netstat &>/dev/null; then - tls_listen=$(netstat -tlnp 2>/dev/null | grep ":5061 " || true) -else - tls_listen="" -fi - -if [[ -n "$tls_listen" ]]; then - pass "Port 5061 (TLS) is listening" -else - fail "Port 5061 (TLS) is NOT listening" - warn "Asterisk TLS transport failed to start — check certs and logs" -fi +echo -e "${CYAN}Endpoints:${NC}" +asterisk -rx "pjsip show endpoints" 2>/dev/null || true # Check TLS cert -cert_file="/etc/asterisk/certs/server.crt" -if [[ -f "$cert_file" ]]; then - pass "TLS certificate exists: $cert_file" - - # Check cert CN/SAN - cert_cn=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed 's/.*CN *= *//') - cert_san=$(openssl x509 -in "$cert_file" -noout -ext subjectAltName 2>/dev/null | grep -oP 'DNS:\K[^,]+' || true) - cert_expiry=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2) - - info "Cert CN: ${cert_cn:-unknown}" - if [[ -n "$cert_san" ]]; then - pass "Cert has SAN (Subject Alt Name): ${cert_san}" - else - fail "Cert has NO SAN — modern phones (iOS/Android) will reject it" - warn "Delete /etc/asterisk/certs/server.crt and restart to regenerate with SANs" - fi - info "Cert expires: ${cert_expiry:-unknown}" - - # Check if cert is self-signed - issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/.*CN *= *//') - if [[ "$issuer" == "$cert_cn" ]]; then - warn "Cert is SELF-SIGNED — phones must be set to accept self-signed certs" - info "In your SIP app: disable TLS certificate verification / allow self-signed" - fi - - # Verify PJSIP transport loaded it - if command -v asterisk &>/dev/null; then - transport_status=$(asterisk -rx "pjsip show transports" 2>/dev/null || true) - if echo "$transport_status" | grep -q "transport-tls"; then - pass "PJSIP TLS transport is loaded" - else - fail "PJSIP TLS transport NOT loaded — cert may be invalid" - fi - fi -else - fail "TLS certificate not found at $cert_file" -fi - -# ── Test 3: Check RTP and ICE/STUN configuration ───────────── -echo "" -echo -e "${BOLD}3. RTP / ICE / STUN Configuration${NC}" -echo "" - -if [[ -f /etc/asterisk/rtp.conf ]]; then - rtp_start=$(grep "^rtpstart=" /etc/asterisk/rtp.conf | cut -d= -f2) - rtp_end=$(grep "^rtpend=" /etc/asterisk/rtp.conf | cut -d= -f2) - info "RTP port range: ${rtp_start:-10000}-${rtp_end:-20000}" - - if grep -q "^icesupport=yes" /etc/asterisk/rtp.conf; then - pass "ICE support enabled" - stun_addr=$(grep "^stunaddr=" /etc/asterisk/rtp.conf | cut -d= -f2) - if [[ -n "$stun_addr" ]]; then - info "STUN server: ${stun_addr}" - - # Test STUN server reachability - stun_host=$(echo "$stun_addr" | cut -d: -f1) - stun_port=$(echo "$stun_addr" | cut -d: -f2) - stun_port="${stun_port:-3478}" - - if command -v nslookup &>/dev/null && nslookup "$stun_host" >/dev/null 2>&1; then - pass "STUN server DNS resolves: ${stun_host}" - else - fail "Cannot resolve STUN server: ${stun_host}" - warn "Add ${stun_host} to DNS whitelist" - fi - fi - else - info "ICE support disabled (standard for LAN/VPN mode)" - warn "If audio fails over VPN, enable ICE via: Server Settings → VPN STUN/ICE" - fi -else - warn "rtp.conf not found" -fi - -# ── Test 4: Check endpoint ICE settings ─────────────────────── -echo "" -echo -e "${BOLD}4. Per-Device ICE Configuration${NC}" -echo "" - -if [[ -f /etc/asterisk/pjsip.conf ]]; then - device_count=$(grep -c "^; === Device:" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0) - ice_device_count=$(grep -c "^ice_support=yes" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0) - info "Total devices: ${device_count}" - info "Devices with ICE: ${ice_device_count}" - - if [[ "$device_count" -gt 0 && "$ice_device_count" -eq 0 ]]; then - warn "No devices have ICE enabled" - info "For third-party VPNs with NAT, enable ICE via VPN STUN/ICE menu" - fi -fi - -# ── Test 5: VPN client connectivity ────────────────────────── -echo "" -echo -e "${BOLD}5. VPN Client Connectivity${NC}" -echo "" - -if [[ -z "$CLIENT_IP" ]] && ! $AUTO_MODE; then - echo " Enter a VPN client IP to test connectivity (or press Enter to skip):" - read -p " Client VPN IP: " CLIENT_IP -fi - -if [[ -n "$CLIENT_IP" ]]; then - # Ping test - if ping -c 2 -W 3 "$CLIENT_IP" >/dev/null 2>&1; then - pass "Ping to ${CLIENT_IP} succeeded" - else - fail "Ping to ${CLIENT_IP} failed" - warn "VPN routing issue - client may not be reachable" - fi - - # SIP port test (UDP 5060) - if command -v nc &>/dev/null; then - if nc -z -u -w 3 "$CLIENT_IP" 5060 2>/dev/null; then - pass "UDP 5060 reachable on ${CLIENT_IP}" - else - info "UDP 5060 probe inconclusive (normal for filtered VPNs)" - fi - fi -else - info "Skipping client connectivity test (no IP provided)" -fi - -# ── Test 6: NAT type detection ─────────────────────────────── -echo "" -echo -e "${BOLD}6. NAT Type Analysis${NC}" -echo "" - -# Check if server is behind NAT -if [[ -n "$primary_ip" ]]; then - public_ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || echo "") - if [[ -n "$public_ip" ]]; then - if [[ "$primary_ip" == "$public_ip" ]]; then - pass "Server has public IP (no NAT)" - else - info "Server behind NAT: ${primary_ip} → ${public_ip}" - info "This is normal for VPN setups where traffic stays on VPN" - fi - else - info "Cannot detect public IP (DNS filtering or no internet)" - info "Not needed for LAN/VPN mode" - fi -fi - -# ── Test 7: Asterisk registration status ───────────────────── -echo "" -echo -e "${BOLD}7. Asterisk Registration Status${NC}" -echo "" - -if command -v asterisk &>/dev/null; then - reg_output=$(asterisk -rx "pjsip show endpoints" 2>/dev/null || echo "") - if [[ -n "$reg_output" ]]; then - online_count=$(echo "$reg_output" | grep -c "Avail" 2>/dev/null || echo 0) - offline_count=$(echo "$reg_output" | grep -c "Unavail" 2>/dev/null || echo 0) - info "Endpoints online: ${online_count}" - info "Endpoints offline: ${offline_count}" - - if [[ "$offline_count" -gt 0 ]]; then - warn "Some endpoints are offline - check VPN connectivity" - echo "$reg_output" | grep "Unavail" | while IFS= read -r line; do - info " Offline: $line" - done - fi - else - info "Asterisk not running or no endpoints configured" - fi -else - info "Asterisk CLI not available" -fi - -# ── Summary ────────────────────────────────────────────────── -print_header "Diagnostic Summary" - -fail_count=0 -pass_count=0 -for result in "${RESULTS[@]}"; do - if [[ "$result" == FAIL* ]]; then - ((fail_count++)) - elif [[ "$result" == PASS* ]]; then - ((pass_count++)) - fi -done - -echo -e " Passed: ${GREEN}${pass_count}${NC}" -echo -e " Failed: ${RED}${fail_count}${NC}" -echo -e " Warnings: ${YELLOW}${#WARNINGS[@]}${NC}" - -if [[ ${#WARNINGS[@]} -gt 0 ]]; then - echo "" - echo -e "${BOLD}Recommendations:${NC}" - for w in "${WARNINGS[@]}"; do - echo -e " ${YELLOW}→${NC} $w" - done -fi - -# ── STUN Recommendation ───────────────────────────────────── -echo "" -echo -e "${BOLD}Do you need STUN?${NC}" -echo "" - -if $vpn_found; then - echo -e " VPN detected on this server." - echo -e " ${GREEN}If your VPN provides direct routing (both sides get VPN IPs),${NC}" - echo -e " ${GREEN}STUN is likely NOT needed.${NC}" - echo "" - echo -e " ${YELLOW}If audio works one-way or not at all, enable STUN:${NC}" - echo -e " 1. docker compose --profile stun up -d (self-hosted STUN)" - echo -e " 2. Or via easy-asterisk: Server Settings → VPN STUN/ICE" -else - echo -e " No VPN interface found on server." - echo -e " ${YELLOW}If VPN runs on router/firewall:${NC}" - echo -e " - Add VPN subnet via: Server Settings → VLAN/VPN Subnets" - echo -e " - If audio still fails, enable STUN for NAT traversal" +if [[ -f /etc/asterisk/certs/server.crt ]]; then + exp=$(openssl x509 -in /etc/asterisk/certs/server.crt -noout -enddate 2>/dev/null | cut -d= -f2) + echo -e "${GREEN}✓ TLS cert:${NC} expires $exp" + openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:" \ + && echo -e "${GREEN}✓ SANs present (mobile-compatible)${NC}" \ + || echo -e "${YELLOW}! No SANs — mobile clients may reject cert${NC}" fi echo "" From f21c96c1a704732cce1a244daf0445a8ed50fe67 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 00:26:52 +0000 Subject: [PATCH 23/27] Add 7 new services: Joplin, Stirling PDF, n8n, Changedetection, ArchiveBox, Calibre-Web, Homebox - joplin: self-hosted sync server (PostgreSQL sidecar, APP_BASE_URL from SITE_DOMAIN) - stirling-pdf: PDF toolkit with optional Authelia SSO (no built-in auth) - n8n: workflow automation connecting self-hosted services (WEBHOOK_URL from SITE_DOMAIN) - changedetection: web page change monitoring with playwright-chrome renderer - archivebox: personal Wayback Machine, initializes data dir at install time - calibre-web: ebook library UI with Calibre conversion support (linuxserver image) - homebox: home inventory and asset management All services follow the standalone bootstrap pattern and support local/remote Caddy. README.md updated with new services in appropriate group rows. --- README.md | 4 +- services/archivebox.sh | 285 ++++++++++++++++++++++++++++++++ services/calibre-web.sh | 287 ++++++++++++++++++++++++++++++++ services/changedetection.sh | 281 ++++++++++++++++++++++++++++++++ services/homebox.sh | 277 +++++++++++++++++++++++++++++++ services/joplin.sh | 316 ++++++++++++++++++++++++++++++++++++ services/n8n.sh | 273 +++++++++++++++++++++++++++++++ services/stirling-pdf.sh | 305 ++++++++++++++++++++++++++++++++++ 8 files changed, 2026 insertions(+), 2 deletions(-) create mode 100644 services/archivebox.sh create mode 100644 services/calibre-web.sh create mode 100644 services/changedetection.sh create mode 100644 services/homebox.sh create mode 100644 services/joplin.sh create mode 100644 services/n8n.sh create mode 100644 services/stirling-pdf.sh diff --git a/README.md b/README.md index 5b7615d..fe76c82 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,8 @@ Update them any time with `sudo ./setup.sh configure`. |-------|---------| | `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) | | `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` | -| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | -| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` | +| `utilities` | `actualbudget`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `joplin`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | +| `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | | `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | | `extras` | `kdeconnect`, `silent-send`, `sync-cc` | diff --git a/services/archivebox.sh b/services/archivebox.sh new file mode 100644 index 0000000..f8c2917 --- /dev/null +++ b/services/archivebox.sh @@ -0,0 +1,285 @@ +#!/bin/bash +# services/archivebox.sh — Self-hosted web archiving (ArchiveBox). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash archivebox.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service archivebox utilities "Self-hosted web archiving — save pages like Wayback Machine (ArchiveBox)" 8000 + +install_archivebox() { + require_docker || return 1 + + local AB_DIR="$DOCKER_DIR/archivebox" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] archivebox would:" + echo " - Create $AB_DIR with docker-compose.yml" + echo " - Initialize ArchiveBox data directory" + echo " - Expose port 8000 (web UI)" + return 0 + fi + + mkdir -p "$AB_DIR" + ensure_docker_dir_ownership "$AB_DIR" + cd "$AB_DIR" || return 1 + + cat > docker-compose.yml << 'ABCOMPOSE' +name: archivebox + +services: + archivebox: + image: archivebox/archivebox:latest + container_name: archivebox + restart: unless-stopped + user: "1000:1000" + environment: + - ALLOWED_HOSTS=* + - MEDIA_MAX_SIZE=750m + - PUBLIC_INDEX=True + - PUBLIC_SNAPSHOTS=True + - PUBLIC_ADD_VIEW=False + volumes: + - ./data:/data + ports: + - "8000:8000" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +ABCOMPOSE + + cat > .env << ABENV +CADDY_NET=$SITE_CADDY_NET +ABENV + + mkdir -p data + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$AB_DIR" + + log_info "Initializing ArchiveBox data directory..." + docker compose run --rm archivebox init --setup \ + && log_success "ArchiveBox initialized" \ + || log_warning "Init failed — will retry on first start" + + configure_caddy_for_service "ArchiveBox" "archivebox:8000" "archive" + + write_readme "$AB_DIR" << 'MD' +# ArchiveBox + +Self-hosted web archiving — saves full snapshots of web pages (HTML, screenshots, +PDFs, WARC) like a personal Wayback Machine. + +- Web UI: http://localhost:8000 + +## Manage +```bash +cd ~/docker/archivebox +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +``` + +## Add URLs to archive +```bash +# Via web UI — visit http://localhost:8000 and use the Add page +# Via CLI: +echo "https://example.com" | docker compose run --rm archivebox add +docker compose run --rm archivebox add --depth=1 https://example.com +``` + +## Create admin user +```bash +docker compose run --rm archivebox manage createsuperuser +``` +MD + + local START_AB="" + prompt_yn "Start ArchiveBox now? (y/n):" "y" START_AB + if [ "$START_AB" = "y" ] || [ "$START_AB" = "Y" ]; then + docker compose up -d \ + && log_success "ArchiveBox started — http://localhost:8000" \ + || log_warning "Start failed — check: docker compose logs" + fi + + echo "" + echo " Web UI: http://localhost:8000" + echo " Add URLs via the web UI or: echo 'URL' | docker compose run --rm archivebox add" + echo "" +} + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_archivebox diff --git a/services/calibre-web.sh b/services/calibre-web.sh new file mode 100644 index 0000000..5218998 --- /dev/null +++ b/services/calibre-web.sh @@ -0,0 +1,287 @@ +#!/bin/bash +# services/calibre-web.sh — Ebook library web UI with metadata editing (Calibre-Web). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash calibre-web.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service calibre-web media "Ebook library web UI with metadata editing (Calibre-Web)" 8083 + +install_calibre_web() { + require_docker || return 1 + log_info "Installing Calibre-Web..." + + local CW_DIR="$DOCKER_DIR/calibre-web" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $CW_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$CW_DIR/config" "$CW_DIR/books" + ensure_docker_dir_ownership "$CW_DIR" + cd "$CW_DIR" || return 1 + + cat > docker-compose.yml << CW_COMPOSE +name: calibre-web + +services: + calibre-web: + image: lscr.io/linuxserver/calibre-web:latest + container_name: calibre-web + hostname: calibre-web + restart: unless-stopped + environment: + - PUID=1000 + - PGID=1000 + - TZ=${SITE_TZ:-UTC} + - DOCKER_MODS=linuxserver/mods:universal-calibre + volumes: + - ./config:/config + - ./books:/books + ports: + - "8083:8083" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +CW_COMPOSE + + cat > .env << CW_ENV +CADDY_NET=$SITE_CADDY_NET +CW_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CW_DIR" + + echo "" + log_success "Calibre-Web configured at $CW_DIR" + + configure_caddy_for_service "Calibre-Web" "calibre-web:8083" "books" + + write_readme "$CW_DIR" << 'MD' +# Calibre-Web + +Web-based ebook library with metadata editing, reading, and format conversion +powered by Calibre. + +## First-run setup +1. Open the UI (http://localhost:8083) and log in with admin / admin123 +2. When prompted for the database location, enter: `/books` + (point this at your existing Calibre library or an empty directory) +3. Change the default password immediately under Admin → Edit User + +## Ebook conversion +The `DOCKER_MODS=linuxserver/mods:universal-calibre` environment variable +installs the full Calibre binary inside the container, enabling on-the-fly +ebook conversion (e.g. EPUB → MOBI/AZW3). + +## Books directory +Place your Calibre library (or individual books) in: + ~/docker/calibre-web/books/ + +If you already have a Calibre library elsewhere, mount that path instead by +editing the `./books:/books` volume line in docker-compose.yml. + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_CW="" + prompt_yn "Start Calibre-Web now? (y/n):" "y" START_CW + if [ "$START_CW" = "y" ] || [ "$START_CW" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Calibre-Web started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:8083" + echo " Default login: admin / admin123 (change immediately!)" + echo " Point the database to /books on first run." + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_calibre_web diff --git a/services/changedetection.sh b/services/changedetection.sh new file mode 100644 index 0000000..7403eaf --- /dev/null +++ b/services/changedetection.sh @@ -0,0 +1,281 @@ +#!/bin/bash +# services/changedetection.sh — Web page change detection and notification (Changedetection.io). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash changedetection.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service changedetection utilities "Web page change detection and notification (Changedetection.io)" 5000 + +install_changedetection() { + require_docker || return 1 + log_info "Installing Changedetection.io..." + + local CD_DIR="$DOCKER_DIR/changedetection" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $CD_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$CD_DIR/data" + ensure_docker_dir_ownership "$CD_DIR" + cd "$CD_DIR" || return 1 + + cat > docker-compose.yml << 'CD_COMPOSE' +name: changedetection + +services: + changedetection: + image: ghcr.io/dgtlmoon/changedetection.io:latest + container_name: changedetection + hostname: changedetection + restart: unless-stopped + env_file: .env + ports: + - "5000:5000" + volumes: + - ./data:/datastore + depends_on: + - playwright-chrome + networks: + - caddy_net + + playwright-chrome: + image: browserless/chrome:latest + container_name: playwright-chrome + hostname: playwright-chrome + restart: unless-stopped + environment: + - DEFAULT_LAUNCH_ARGS=--no-sandbox --disable-dev-shm-usage + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +CD_COMPOSE + + cat > .env << CD_ENV +# Changedetection.io configuration +BASE_URL=https://changes.${SITE_DOMAIN} +PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 +CADDY_NET=${SITE_CADDY_NET} +CD_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CD_DIR" + + echo "" + log_success "Changedetection.io configured at $CD_DIR" + + configure_caddy_for_service "Changedetection" "changedetection:5000" "changes" + + write_readme "$CD_DIR" << 'MD' +# Changedetection.io + +Monitor web pages for changes and receive notifications. Includes a +Playwright/Chrome sidecar for JavaScript-rendered pages. + +## Access +- URL: http://localhost:5000 +- Optional password can be set via Settings in the UI + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_CD="" + prompt_yn "Start Changedetection.io now? (y/n):" "y" START_CD + if [ "$START_CD" = "y" ] || [ "$START_CD" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Changedetection.io started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:5000" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_changedetection diff --git a/services/homebox.sh b/services/homebox.sh new file mode 100644 index 0000000..9eeed7d --- /dev/null +++ b/services/homebox.sh @@ -0,0 +1,277 @@ +#!/bin/bash +# services/homebox.sh — Home inventory and asset management (Homebox). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash homebox.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service homebox utilities "Home inventory and asset management (Homebox)" 7745 + +install_homebox() { + require_docker || return 1 + log_info "Installing Homebox..." + + local HB_DIR="$DOCKER_DIR/homebox" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $HB_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$HB_DIR/data" + ensure_docker_dir_ownership "$HB_DIR" + cd "$HB_DIR" || return 1 + + cat > docker-compose.yml << HB_COMPOSE +name: homebox + +services: + homebox: + image: ghcr.io/sysadminsmedia/homebox:latest + container_name: homebox + hostname: homebox + restart: unless-stopped + environment: + - HBOX_LOG_LEVEL=info + - HBOX_WEB_MAX_UPLOAD_SIZE=10 + volumes: + - ./data:/data + ports: + - "7745:7745" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +HB_COMPOSE + + cat > .env << HB_ENV +CADDY_NET=$SITE_CADDY_NET +HB_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$HB_DIR" + + echo "" + log_success "Homebox configured at $HB_DIR" + + configure_caddy_for_service "Homebox" "homebox:7745" "homebox" + + write_readme "$HB_DIR" << 'MD' +# Homebox + +Home inventory and asset management. Track items, locations, labels, +warranties, and attachments across your household. + +## Access +- URL: http://localhost:7745 +- Register your account on first visit — the first user becomes the admin. + +## Data +- All inventory data and attachments are stored in: ./data/ + +## Configuration +Key environment variables (edit docker-compose.yml to change): +- `HBOX_LOG_LEVEL` — log verbosity (info, debug, warn, error) +- `HBOX_WEB_MAX_UPLOAD_SIZE` — max attachment upload size in MB (default: 10) + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_HB="" + prompt_yn "Start Homebox now? (y/n):" "y" START_HB + if [ "$START_HB" = "y" ] || [ "$START_HB" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Homebox started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:7745" + echo " Register your account on first visit." + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_homebox diff --git a/services/joplin.sh b/services/joplin.sh new file mode 100644 index 0000000..8f0d9c1 --- /dev/null +++ b/services/joplin.sh @@ -0,0 +1,316 @@ +#!/bin/bash +# services/joplin.sh — Self-hosted Joplin sync server for notes. +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash joplin.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service joplin utilities "Self-hosted Joplin sync server for notes" 22300 + +install_joplin() { + require_docker || return 1 + log_info "Installing Joplin Server..." + + local JOPLIN_DIR="$DOCKER_DIR/joplin" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $JOPLIN_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$JOPLIN_DIR" + ensure_docker_dir_ownership "$JOPLIN_DIR" + cd "$JOPLIN_DIR" || return 1 + + local DB_PASS + DB_PASS="$(generate_password 32)" + local BASE_URL="https://joplin.${SITE_DOMAIN}" + + cat > docker-compose.yml << 'JOPLIN_COMPOSE' +name: joplin + +services: + joplin: + image: joplin/server:latest + container_name: joplin + hostname: joplin + restart: unless-stopped + depends_on: + - joplin-db + env_file: .env + ports: + - "22300:22300" + networks: + - caddy_net + + joplin-db: + image: postgres:15-alpine + container_name: joplin-db + hostname: joplin-db + restart: unless-stopped + env_file: .env + volumes: + - ./db-data:/var/lib/postgresql/data + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +JOPLIN_COMPOSE + + cat > .env << JOPLIN_ENV +# Joplin Server configuration +APP_PORT=22300 +# Must match the public URL — update if your domain changes +APP_BASE_URL=${BASE_URL} + +# Database connection (Joplin Server) +DB_CLIENT=pg +POSTGRES_HOST=joplin-db +POSTGRES_DATABASE=joplin +POSTGRES_USER=joplin +POSTGRES_PASSWORD=${DB_PASS} + +# PostgreSQL sidecar +POSTGRES_DB=joplin + +# Caddy network +CADDY_NET=${SITE_CADDY_NET} +JOPLIN_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$JOPLIN_DIR" + + echo "" + log_success "Joplin Server configured at $JOPLIN_DIR" + log_info "APP_BASE_URL set to: $BASE_URL" + log_warning "APP_BASE_URL in .env must match the public URL used by Joplin clients." + + configure_caddy_for_service "Joplin" "joplin:22300" "joplin" + + write_readme "$JOPLIN_DIR" << MD +# Joplin Server + +Self-hosted sync server for the Joplin note-taking app. + +## Access +- URL: http://localhost:22300 +- Default admin: admin@localhost / admin (change immediately after first login!) + +## Important +\`APP_BASE_URL\` in \`.env\` must exactly match the public URL your Joplin +clients connect to (e.g. https://joplin.example.com). If this URL changes, +update .env and restart the stack. + +## Client setup +In the Joplin desktop or mobile app: + Tools → Options → Synchronisation → Synchronisation target: Joplin Server + Enter your server URL, email, and password. + +## Manage +\`\`\`bash +cd $JOPLIN_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose down && docker compose up -d # update +\`\`\` + +## Files +- docker-compose.yml — stack definition +- .env — secrets and config (chmod 600) +- db-data/ — PostgreSQL data volume +MD + + local START_JOPLIN="" + prompt_yn "Start Joplin Server now? (y/n):" "y" START_JOPLIN + if [ "$START_JOPLIN" = "y" ] || [ "$START_JOPLIN" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Joplin Server started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:22300" + echo " Default login: admin@localhost / admin (change immediately!)" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_joplin diff --git a/services/n8n.sh b/services/n8n.sh new file mode 100644 index 0000000..c3cb7d8 --- /dev/null +++ b/services/n8n.sh @@ -0,0 +1,273 @@ +#!/bin/bash +# services/n8n.sh — Workflow automation — connect all your self-hosted services (n8n). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash n8n.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service n8n utilities "Workflow automation — connect all your self-hosted services (n8n)" 5678 + +install_n8n() { + require_docker || return 1 + log_info "Installing n8n..." + + local N8N_DIR="$DOCKER_DIR/n8n" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $N8N_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$N8N_DIR/data" + ensure_docker_dir_ownership "$N8N_DIR" + cd "$N8N_DIR" || return 1 + + cat > docker-compose.yml << 'N8N_COMPOSE' +name: n8n + +services: + n8n: + image: n8nio/n8n:latest + container_name: n8n + hostname: n8n + restart: unless-stopped + env_file: .env + ports: + - "5678:5678" + volumes: + - ./data:/home/node/.n8n + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +N8N_COMPOSE + + cat > .env << N8N_ENV +# n8n configuration +N8N_HOST=n8n.${SITE_DOMAIN} +N8N_PORT=5678 +N8N_PROTOCOL=https +WEBHOOK_URL=https://n8n.${SITE_DOMAIN} +GENERIC_TIMEZONE=${SITE_TZ} +CADDY_NET=${SITE_CADDY_NET} +N8N_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$N8N_DIR" + + echo "" + log_success "n8n configured at $N8N_DIR" + + configure_caddy_for_service "n8n" "n8n:5678" "n8n" + + write_readme "$N8N_DIR" << 'MD' +# n8n + +Workflow automation — connect all your self-hosted services with a visual +node-based editor. An owner account is created on first login. + +## Access +- URL: http://localhost:5678 +- Create an owner account on first visit + +## Manage +```bash +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose down && docker compose up -d +``` +MD + + local START_N8N="" + prompt_yn "Start n8n now? (y/n):" "y" START_N8N + if [ "$START_N8N" = "y" ] || [ "$START_N8N" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "n8n started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:5678" + echo " Create an owner account on first visit." + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_n8n diff --git a/services/stirling-pdf.sh b/services/stirling-pdf.sh new file mode 100644 index 0000000..3c707e4 --- /dev/null +++ b/services/stirling-pdf.sh @@ -0,0 +1,305 @@ +#!/bin/bash +# services/stirling-pdf.sh — PDF toolkit — merge, split, compress, OCR (Stirling PDF). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash stirling-pdf.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + # Determine mode: local Caddy, remote Caddy, or none + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + # Domain prompt — pre-fill from SITE_DOMAIN when available + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Build upstream — remote Caddy uses host IP:port, not container name + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service stirling-pdf utilities "PDF toolkit — merge, split, compress, OCR (Stirling PDF)" 8070 + +install_stirling_pdf() { + require_docker || return 1 + log_info "Installing Stirling PDF..." + + local PDF_DIR="$DOCKER_DIR/stirling-pdf" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $PDF_DIR" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + return 0 + fi + + mkdir -p "$PDF_DIR" + ensure_docker_dir_ownership "$PDF_DIR" + cd "$PDF_DIR" || return 1 + + cat > docker-compose.yml << 'PDF_COMPOSE' +name: stirling-pdf + +services: + stirling-pdf: + image: frooodle/s-pdf:latest + container_name: stirling-pdf + hostname: stirling-pdf + restart: unless-stopped + env_file: .env + ports: + - "8070:8080" + volumes: + - ./training-data:/usr/share/tessdata + - ./extraConfigs:/configs + - ./logs:/logs + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +PDF_COMPOSE + + cat > .env << PDF_ENV +# Stirling PDF configuration + +# Set to true to enable login/user management (requires restart) +DOCKER_ENABLE_SECURITY=false + +# Set to true to install LibreOffice for advanced HTML/book conversion ops +INSTALL_BOOK_AND_ADVANCED_HTML_OPS=false + +# Caddy network +CADDY_NET=${SITE_CADDY_NET} +PDF_ENV + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$PDF_DIR" + + echo "" + log_success "Stirling PDF configured at $PDF_DIR" + log_info "Security/login is disabled by default (DOCKER_ENABLE_SECURITY=false)." + log_info "To enable built-in auth, set DOCKER_ENABLE_SECURITY=true in .env and restart." + + # No built-in auth by default — offer Authelia SSO protection + local EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect Stirling PDF with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia" + fi + + configure_caddy_for_service "Stirling PDF" "stirling-pdf:8080" "pdf" "$EXTRA_BLOCK" + + write_readme "$PDF_DIR" << MD +# Stirling PDF + +Feature-rich PDF toolkit: merge, split, compress, rotate, OCR, convert, and more. + +## Access +- URL: http://localhost:8070 +- No login required by default (security disabled) + +## Enabling built-in auth +Set \`DOCKER_ENABLE_SECURITY=true\` in \`.env\`, then restart: +\`\`\`bash +cd $PDF_DIR +docker compose down && docker compose up -d +\`\`\` + +## OCR / Tesseract +Additional Tesseract language packs can be placed in \`./training-data/\`. +See: https://github.com/Frooodle/Stirling-PDF#ocr + +## Manage +\`\`\`bash +cd $PDF_DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose down && docker compose up -d # update +\`\`\` + +## Files +- docker-compose.yml — stack definition +- .env — configuration flags +- training-data/ — Tesseract OCR language data +- extraConfigs/ — optional extra config files +- logs/ — application logs +MD + + local START_PDF="" + prompt_yn "Start Stirling PDF now? (y/n):" "y" START_PDF + if [ "$START_PDF" = "y" ] || [ "$START_PDF" = "Y" ]; then + docker compose up -d 2>/dev/null \ + && log_success "Stirling PDF started" \ + || log_warning "Failed to start — check: docker compose logs" + fi + + echo " Access at: http://localhost:8070" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_stirling_pdf From 5ee08523e31ba9c4aa3b3a6ee93b1231e4f4406d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 00:27:02 +0000 Subject: [PATCH 24/27] Improve n8n.sh: explicit env vars in compose, chmod 600 .env, better readme --- services/n8n.sh | 36 +++++++++++++++++++++++------------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/services/n8n.sh b/services/n8n.sh index c3cb7d8..a986ea8 100644 --- a/services/n8n.sh +++ b/services/n8n.sh @@ -206,9 +206,16 @@ services: container_name: n8n hostname: n8n restart: unless-stopped - env_file: .env ports: - "5678:5678" + env_file: + - .env + environment: + - N8N_PORT=5678 + - N8N_PROTOCOL=https + - GENERIC_TIMEZONE=${GENERIC_TIMEZONE:-UTC} + - N8N_HOST=${N8N_HOST:-n8n} + - WEBHOOK_URL=${WEBHOOK_URL} volumes: - ./data:/home/node/.n8n networks: @@ -221,16 +228,15 @@ networks: N8N_COMPOSE cat > .env << N8N_ENV -# n8n configuration -N8N_HOST=n8n.${SITE_DOMAIN} -N8N_PORT=5678 -N8N_PROTOCOL=https +# n8n environment — edit before starting if needed +N8N_HOST=n8n WEBHOOK_URL=https://n8n.${SITE_DOMAIN} GENERIC_TIMEZONE=${SITE_TZ} CADDY_NET=${SITE_CADDY_NET} N8N_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$N8N_DIR" + chmod 600 "$N8N_DIR/.env" echo "" log_success "n8n configured at $N8N_DIR" @@ -240,20 +246,24 @@ N8N_ENV write_readme "$N8N_DIR" << 'MD' # n8n -Workflow automation — connect all your self-hosted services with a visual -node-based editor. An owner account is created on first login. +Workflow automation platform — connect all your self-hosted services with +a visual editor. Create webhooks, scheduled jobs, and multi-step automations. ## Access - URL: http://localhost:5678 -- Create an owner account on first visit +- On first run, n8n prompts you to create an owner account. ## Manage ```bash -docker compose up -d -docker compose down -docker compose logs -f -docker compose pull && docker compose down && docker compose up -d +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose down && docker compose up -d # update ``` + +## Environment +Edit `.env` to change `WEBHOOK_URL` or `N8N_HOST` after deployment, +then restart: `docker compose down && docker compose up -d` MD local START_N8N="" @@ -265,7 +275,7 @@ MD fi echo " Access at: http://localhost:5678" - echo " Create an owner account on first visit." + echo " Create your owner account on first visit." echo "" } From 8bdc4ca0250e7934842f094784bd96be8ff754c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 00:30:43 +0000 Subject: [PATCH 25/27] Improve changedetection.sh: explicit env vars in compose, chmod 600 .env, better readme --- services/changedetection.sh | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/services/changedetection.sh b/services/changedetection.sh index 7403eaf..78e553f 100644 --- a/services/changedetection.sh +++ b/services/changedetection.sh @@ -206,15 +206,17 @@ services: container_name: changedetection hostname: changedetection restart: unless-stopped - env_file: .env ports: - "5000:5000" + environment: + - BASE_URL=${BASE_URL} + - PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 volumes: - ./data:/datastore - depends_on: - - playwright-chrome networks: - caddy_net + depends_on: + - playwright-chrome playwright-chrome: image: browserless/chrome:latest @@ -233,13 +235,13 @@ networks: CD_COMPOSE cat > .env << CD_ENV -# Changedetection.io configuration +# Changedetection.io environment — edit before starting if needed BASE_URL=https://changes.${SITE_DOMAIN} -PLAYWRIGHT_DRIVER_URL=ws://playwright-chrome:3000 CADDY_NET=${SITE_CADDY_NET} CD_ENV chown -R "$ACTUAL_USER:$ACTUAL_USER" "$CD_DIR" + chmod 600 "$CD_DIR/.env" echo "" log_success "Changedetection.io configured at $CD_DIR" @@ -249,20 +251,25 @@ CD_ENV write_readme "$CD_DIR" << 'MD' # Changedetection.io -Monitor web pages for changes and receive notifications. Includes a -Playwright/Chrome sidecar for JavaScript-rendered pages. +Monitor web pages for changes and receive notifications via email, Slack, +Discord, ntfy, and many other channels. Includes a Playwright/Chrome sidecar +for JavaScript-heavy pages. ## Access - URL: http://localhost:5000 -- Optional password can be set via Settings in the UI +- Optional password can be set in Settings → General within the UI. ## Manage ```bash -docker compose up -d -docker compose down -docker compose logs -f -docker compose pull && docker compose down && docker compose up -d +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose down && docker compose up -d # update ``` + +## Environment +Edit `.env` to change `BASE_URL` (used for notification links), +then restart: `docker compose down && docker compose up -d` MD local START_CD="" From d47d8dc26f840a0773e1f39c08d3c2318789c1e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 00:58:53 +0000 Subject: [PATCH 26/27] unifi.sh: add remote Caddy snippet support, fix standalone CADDY_REMOTE_HOST global --- services/unifi.sh | 56 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 15 deletions(-) diff --git a/services/unifi.sh b/services/unifi.sh index fe5d45d..cfd9977 100644 --- a/services/unifi.sh +++ b/services/unifi.sh @@ -202,25 +202,36 @@ UNIFI_ENV log_success "UniFi configured at $UNIFI_DIR" - # ── Optional Caddy reverse proxy (HTTPS backend requires special config) ── - if [ -d "$DOCKER_DIR/caddy" ]; then + # ── Optional Caddy reverse proxy (HTTPS backend requires tls_insecure_skip_verify) ── + local _caddy_mode="none" + [ -d "$DOCKER_DIR/caddy" ] && _caddy_mode="local" + [ -n "${CADDY_REMOTE_HOST:-}" ] && [ "$_caddy_mode" != "local" ] && _caddy_mode="remote" + + if [ "$_caddy_mode" != "none" ]; then echo "" echo " UniFi web UI is HTTPS-only (self-signed cert internally)." - echo " Caddy can proxy it, but requires tls_insecure_skip_verify." + echo " Caddy proxies it using tls_insecure_skip_verify." + if [ "$_caddy_mode" = "remote" ]; then + echo " Remote Caddy (${CADDY_REMOTE_HOST}) — a snippet file will be saved." + fi echo "" local CADDY_UNIFI="" prompt_yn "Configure Caddy reverse proxy for UniFi? (y/n):" "n" CADDY_UNIFI if [ "$CADDY_UNIFI" = "y" ] || [ "$CADDY_UNIFI" = "Y" ]; then local UNIFI_DOMAIN="" - prompt_text "UniFi domain (e.g. unifi.example.com):" "unifi.${SITE_DOMAIN:-example.com}" UNIFI_DOMAIN + local _def_domain="unifi.${SITE_DOMAIN:-example.com}" + prompt_text "UniFi domain [${_def_domain}]:" "$_def_domain" UNIFI_DOMAIN if [ -n "$UNIFI_DOMAIN" ]; then - local CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" - cp "$CADDYFILE" "$CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true - cat >> "$CADDYFILE" << CADDY_BLOCK + # UniFi uses HTTPS internally — upstream must use https:// + skip verify + local _upstream="https://unifi-app:8443" + [ "$_caddy_mode" = "remote" ] && _upstream="https://${CADDY_REMOTE_HOST}:8443" + + local _site_block + _site_block="$(cat << CBLOCK # UniFi Network Application -$UNIFI_DOMAIN { - reverse_proxy https://unifi-app:8443 { +${UNIFI_DOMAIN} { + reverse_proxy ${_upstream} { transport http { tls_insecure_skip_verify } @@ -234,15 +245,30 @@ $UNIFI_DOMAIN { } log { - output file /var/log/caddy/$UNIFI_DOMAIN.log + output file /var/log/caddy/${UNIFI_DOMAIN}.log format json } } -CADDY_BLOCK - docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true - docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \ - && log_success "Caddy configured for $UNIFI_DOMAIN" \ - || log_warning "Caddy reload failed — check: docker logs caddy" +CBLOCK +)" + if [ "$_caddy_mode" = "local" ]; then + local CADDYFILE="$DOCKER_DIR/caddy/Caddyfile" + cp "$CADDYFILE" "$CADDYFILE.backup.$(date +%Y%m%d-%H%M%S)" 2>/dev/null || true + printf '%s\n' "$_site_block" >> "$CADDYFILE" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null \ + && log_success "Caddy configured for $UNIFI_DOMAIN" \ + || log_warning "Caddy reload failed — check: docker logs caddy" + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/unifi.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + fi fi fi fi From 12795a27fd222f830716f949fe7275e388455d36 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 01:58:16 +0000 Subject: [PATCH 27/27] Add drum-rhythm-game: nginx-served browser rhythm game with Authelia SSO support --- README.md | 2 +- services/drum-rhythm-game.sh | 298 +++++++++++++++++++++++++++++++++++ 2 files changed, 299 insertions(+), 1 deletion(-) create mode 100644 services/drum-rhythm-game.sh diff --git a/README.md b/README.md index fe76c82..1730656 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ Update them any time with `sudo ./setup.sh configure`. | `utilities` | `actualbudget`, `archivebox`, `changedetection`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `homebox`, `joplin`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `n8n`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `stirling-pdf`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` | | `media` | `arm`, `audiobookshelf`, `calibre-web`, `emby`, `immich`, `jellyfin`, `lyrion` | | `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` | -| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` | +| `gaming` | `drum-rhythm-game`, `js99er`, `minecraft`, `wolf`, `wolf-pair` | | `extras` | `kdeconnect`, `silent-send`, `sync-cc` | | `backup` | `backup` — complete recovery: entire `~/docker//` 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) | diff --git a/services/drum-rhythm-game.sh b/services/drum-rhythm-game.sh new file mode 100644 index 0000000..32023bc --- /dev/null +++ b/services/drum-rhythm-game.sh @@ -0,0 +1,298 @@ +#!/bin/bash +# services/drum-rhythm-game.sh — Browser-based drum rhythm game (outis1one/drum-rhythm-game). +# Part of the modular post-install system (sourced by setup.sh). +# +# Can also be run standalone on any machine: +# sudo bash drum-rhythm-game.sh +# (Docker must already be installed when run standalone) +# +# Serves a single self-contained index.html via nginx. No login — protect +# with Authelia via Caddy if you want access control. +# Source: https://github.com/outis1one/drum-rhythm-game + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + source "$_COMMON" + else + 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 + } + + 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}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + local _display_port="${_upstream##*:}" + + local _mode="none" + [[ -d "$_caddy_dir" ]] && _mode="local" + [[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote" + [[ "$_mode" == "none" ]] && { + log_info "Access $_name directly on port $_display_port." + return 0 + } + + echo "" + local _do_caddy="" + if [[ "$_mode" == "remote" ]]; then + log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})." + log_info "A snippet file will be saved to ~/docker/caddy-snippets/." + fi + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:$_display_port" + return 0 + } + + local _default_domain="" + if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then + _default_domain="${_subdomain}.${SITE_DOMAIN}" + log_info "Default: $_default_domain" + fi + local _domain="" + read -r -p " Domain [${_default_domain:-required}]: " _domain + _domain="${_domain:-$_default_domain}" + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + local _block_upstream="$_upstream" + if [[ "$_mode" == "remote" ]]; then + _block_upstream="${CADDY_REMOTE_HOST}:${_display_port}" + fi + + local _site_block + _site_block="$(cat << CBLOCK + +# $_name +${_domain} { + reverse_proxy ${_block_upstream} + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK +)" + + if [[ "$_mode" == "local" ]]; then + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + printf '%s\n' "$_site_block" >> "$_caddyfile" + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + else + local _snippet_dir="$DOCKER_DIR/caddy-snippets" + local _snippet_file="$_snippet_dir/${_subdomain}.caddy" + mkdir -p "$_snippet_dir" + printf '%s\n' "$_site_block" > "$_snippet_file" + chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true + log_success "Snippet saved: $_snippet_file" + log_info "Copy to Caddy machine:" + log_info " scp $_snippet_file caddy-host:~/caddy-snippets/" + log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)" + fi + } + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}" + + register_service() { :; } + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service drum-rhythm-game gaming "Browser-based drum rhythm game (outis1one/drum-rhythm-game)" 8096 + +install_drum-rhythm-game() { + require_docker || return 1 + + local DRUM_DIR="$DOCKER_DIR/drum-rhythm-game" + local REPO_URL="https://github.com/outis1one/drum-rhythm-game.git" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] drum-rhythm-game would:" + echo " - Clone $REPO_URL to $DRUM_DIR/html" + echo " - Serve index.html via nginx on port 8096" + echo " - Offer Authelia SSO protection via Caddy (no built-in auth)" + return 0 + fi + + mkdir -p "$DRUM_DIR" + ensure_docker_dir_ownership "$DRUM_DIR" + cd "$DRUM_DIR" || return 1 + + # Clone or update the game source + if [ -d "$DRUM_DIR/html/.git" ]; then + log_info "Updating drum-rhythm-game source..." + git -C "$DRUM_DIR/html" pull --ff-only 2>/dev/null \ + && log_success "Updated to latest" \ + || log_warning "Could not pull latest — using existing version" + else + log_info "Cloning drum-rhythm-game..." + git clone --depth 1 "$REPO_URL" "$DRUM_DIR/html" \ + || { log_error "Clone failed — check network and git access"; return 1; } + fi + + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DRUM_DIR/html" + + cat > docker-compose.yml << 'DRUM_COMPOSE' +name: drum-rhythm-game + +services: + drum-rhythm-game: + image: nginx:alpine + container_name: drum-rhythm-game + hostname: drum-rhythm-game + restart: unless-stopped + volumes: + - ./html:/usr/share/nginx/html:ro + ports: + - "8096:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +DRUM_COMPOSE + + cat > .env << DRUM_ENV +CADDY_NET=${SITE_CADDY_NET} +DRUM_ENV + + ensure_docker_dir_ownership "$DRUM_DIR" + log_success "drum-rhythm-game configured at $DRUM_DIR" + + # No built-in auth — offer Authelia SSO protection + local DRUM_EXTRA_BLOCK="" + if [ -d "$DOCKER_DIR/authelia" ]; then + local _use_auth="" + prompt_yn "Protect drum-rhythm-game with Authelia SSO? (y/n):" "y" _use_auth + [[ "$_use_auth" =~ ^[Yy]$ ]] && DRUM_EXTRA_BLOCK=" import authelia" + fi + configure_caddy_for_service "Drum Rhythm Game" "drum-rhythm-game:80" "drums" "$DRUM_EXTRA_BLOCK" + + write_readme "$DRUM_DIR" << 'MD' +# Drum Rhythm Game + +Browser-based drum rhythm game — 124 synthesized orchestra pieces across +18 genres, 120 drum patterns. Supports keyboard and USB drum controllers. +No server required; all audio synthesized in-browser via Web Audio API. + +Source: https://github.com/outis1one/drum-rhythm-game + +## Access +- URL: http://localhost:8096 + +## Manage +```bash +cd ~/docker/drum-rhythm-game +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +``` + +## Update game +```bash +cd ~/docker/drum-rhythm-game +git -C html pull +docker compose restart +``` +MD + + local START_DRUM="" + prompt_yn "Start drum-rhythm-game now? (y/n):" "y" START_DRUM + if [ "$START_DRUM" = "y" ] || [ "$START_DRUM" = "Y" ]; then + docker compose up -d \ + && log_success "Drum Rhythm Game started — http://localhost:8096" \ + || log_warning "Start failed — check: docker compose logs" + fi + + echo "" + echo " URL: http://localhost:8096" + echo " Controls: keyboard or USB drum controller" + echo " Update: git -C $DRUM_DIR/html pull && docker compose -f $DRUM_DIR/docker-compose.yml restart" + echo "" +} + +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_drum-rhythm-game