From bb00806e6c1ffa10bc617b2e5aa61c82be974f7b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:08:47 +0000 Subject: [PATCH 01/10] 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 f1631d8cb71e197976bc0af59bb10c0e6014b9c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:15:19 +0000 Subject: [PATCH 02/10] =?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 3e027657942a8589cb5d29d87b9d73963385f1aa Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:19:20 +0000 Subject: [PATCH 03/10] 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 170d4c0d2c06d0bfc077722f2998b87b6f0a48e5 Mon Sep 17 00:00:00 2001 From: Outis Date: Mon, 8 Jun 2026 12:24:23 -0400 Subject: [PATCH 04/10] 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 676fa77f6e2d832788afeeed313a269ac5385145 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:25:34 +0000 Subject: [PATCH 05/10] 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 cdadbda08dae8240948bc14287a475f951b16206 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:30:00 +0000 Subject: [PATCH 06/10] 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 c4d3973ff6122663495a2a39ae8e5b0c8e62a445 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:45:57 +0000 Subject: [PATCH 07/10] 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 6498ee1f7bac77e8cf0c4dcf76ce0644a9a0e0c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 16:50:39 +0000 Subject: [PATCH 08/10] 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 d4e06914e70124b404c9e147b5291f9f3f9613ee Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 21:10:37 +0000 Subject: [PATCH 09/10] =?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 | 144 ++++++++++++++++++++++----------- 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 | 161 +++++++++++++++++++++++++++++++++++++ setup.sh | 11 ++- 44 files changed, 2482 insertions(+), 1177 deletions(-) diff --git a/lib/common.sh b/lib/common.sh index 45d1f40..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,43 +310,35 @@ 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:" - 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 - 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 { @@ -346,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 7087d15..92a08f9 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 5377027..1f90845 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 ab78ba2..8614b76 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 05a6a27..ef025f4 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 a855b56..0cc4ea1 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 8ab453f..3c38038 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 7aa8fad..e160134 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 22ba149..9915801 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 e1acf02..818fe38 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 0573129..868675c 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 @@ -927,6 +1046,48 @@ PYEOF echo " Set up automatic backups with the backup module:" echo " sudo ./setup.sh backup" echo "" + # ── Caddy reverse proxy (Wolf web UI — no built-in auth, offer Authelia) ──── + 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 + +Cloud gaming via Moonlight. Stream any Moonlight-compatible game or app from +this server to any device on your network. + +## Manage +\`\`\`bash +cd $WOLF_DIR +./manage.sh start # start Wolf +./manage.sh stop # stop Wolf +./manage.sh logs # stream logs +./manage.sh pin # show pairing PIN +./manage.sh update # pull latest and restart +\`\`\` + +## Pairing +1. Open Moonlight on your device, select this server +2. Run \`./manage.sh pin\` to get the pairing PIN +3. Enter the PIN in Moonlight + +## Game storage: \`$GAME_STORAGE_DIR\` +- \`roms/\` → /ROMs (EmulationStation) +- \`steam/\` → Steam data +- \`saves/\` → RetroArch saves +MD + + local START_WOLF="" + prompt_yn "Start Wolf now? (y/n):" "y" START_WOLF + if [ "$START_WOLF" = "y" ] || [ "$START_WOLF" = "Y" ]; then + docker compose up -d && log_success "Wolf started" || log_warning "Failed to start — check: docker compose logs" + fi + log_success "Done. Pair Moonlight and play." } diff --git a/setup.sh b/setup.sh index 27b8a7b..dae66da 100755 --- a/setup.sh +++ b/setup.sh @@ -121,10 +121,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 13dec9a8572fd651c280e4f9aa4f93d212e31b54 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 8 Jun 2026 23:42:37 +0000 Subject: [PATCH 10/10] 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 --- README.md | 4 +- services/asterisk.sh | 482 ++ services/mattermost.sh | 408 + services/nextcloud.sh | 356 + services/onlyoffice.sh | 382 + vendor/easy-asterisk/.env.example | 32 + vendor/easy-asterisk/Dockerfile | 60 + .../easy-asterisk/docker/coturn-entrypoint.sh | 24 + vendor/easy-asterisk/docker/entrypoint.sh | 449 ++ vendor/easy-asterisk/easy-asterisk-v0.10.0.sh | 6929 +++++++++++++++++ vendor/easy-asterisk/scripts/dns-whitelist.sh | 55 + .../easy-asterisk/scripts/vpn-diagnostics.sh | 40 + 12 files changed, 9219 insertions(+), 2 deletions(-) create mode 100644 services/asterisk.sh create mode 100644 services/mattermost.sh create mode 100644 services/nextcloud.sh create mode 100644 services/onlyoffice.sh 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 100755 vendor/easy-asterisk/docker/entrypoint.sh create mode 100755 vendor/easy-asterisk/easy-asterisk-v0.10.0.sh create mode 100755 vendor/easy-asterisk/scripts/dns-whitelist.sh create mode 100755 vendor/easy-asterisk/scripts/vpn-diagnostics.sh diff --git a/README.md b/README.md index 89b22c7..4bff1c3 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`, `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..74498f4 --- /dev/null +++ b/services/asterisk.sh @@ -0,0 +1,482 @@ +#!/bin/bash +# services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP). +# Part of the modular post-install system (sourced by setup.sh). +# +# 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; } + + _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" + + # 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 + 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" + 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}" + 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() { :; } # no-op — no wizard to register into + _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 + coturn..." + + local EA_DIR="$DOCKER_DIR/asterisk" + + if [ "$DRY_RUN" = true ]; then + 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" + ensure_docker_dir_ownership "$EA_DIR" + cd "$EA_DIR" || return 1 + + mkdir -p docker + + # ── Vendor files ────────────────────────────────────────────────────────── + local _SELF_DIR_LOCAL + _SELF_DIR_LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + local VENDOR_DIR="$_SELF_DIR_LOCAL/../vendor/easy-asterisk" + + 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 "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 + + 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="$(generate_password 24)" + + local TURN_SERVER_VAL="" + [[ -n "$DOMAIN_NAME" ]] && TURN_SERVER_VAL="${DOMAIN_NAME}:3478" + + # ── docker-compose.yml ──────────────────────────────────────────────────── + cat > docker-compose.yml << 'EOF' +name: asterisk + +services: + asterisk: + build: . + container_name: easy-asterisk + 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 + env_file: .env + restart: unless-stopped + healthcheck: + test: ["CMD", "asterisk", "-rx", "core show version"] + interval: 30s + timeout: 5s + retries: 3 + + 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 + env_file: .env + command: + - -n + - --listening-port=${TURN_PORT:-3478} + - --listening-ip=0.0.0.0 + - --fingerprint + - --lt-cred-mech + - --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD} + - --realm=${DOMAIN_NAME:-localhost} + - --min-port=49152 + - --max-port=49252 + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + restart: unless-stopped + +volumes: + asterisk-config: + easy-asterisk-config: + asterisk-logs: + asterisk-spool: + asterisk-lib: +EOF + + # ── .env ────────────────────────────────────────────────────────────────── + cat > .env << ENV +# ── Domain ──────────────────────────────────────────────────── +# Set to your FQDN for remote access. Leave empty for LAN-only. +DOMAIN_NAME=${DOMAIN_NAME} + +# ── TURN/STUN ───────────────────────────────────────────────── +TURN_USERNAME=easyasterisk +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} + +# ── RTP port range ──────────────────────────────────────────── +RTP_START=10000 +RTP_END=20000 + +# ── Web admin ───────────────────────────────────────────────── +WEB_ADMIN_PORT=8080 +WEB_ADMIN_AUTH_DISABLED=false +ENV + chmod 600 .env + + # ── UFW firewall rules ──────────────────────────────────────────────────── + 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 + + # ── 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 + 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" "8080" "asterisk" "$EXTRA_BLOCK" + + # ── README ──────────────────────────────────────────────────────────────── + write_readme "$EA_DIR" << 'MD' +# Easy Asterisk PBX + coturn + +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 +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 + + # ── Start ───────────────────────────────────────────────────────────────── + echo "" + 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 "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_asterisk diff --git a/services/mattermost.sh b/services/mattermost.sh new file mode 100644 index 0000000..50e744e --- /dev/null +++ b/services/mattermost.sh @@ -0,0 +1,408 @@ +#!/bin/bash +# services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn). +# Part of the modular post-install system (sourced by setup.sh). +# +# 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; } + + _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" + + # 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 + 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" + 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}" + 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() { :; } # no-op — no wizard to register into + _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 + coturn..." + + local DIR="$DOCKER_DIR/mattermost" + + if [ "$DRY_RUN" = true ]; then + 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 + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local DB_PASS + local MM_SECRET + DB_PASS=$(generate_password 32) + MM_SECRET=$(generate_password 48) + + 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://mattermost.${SITE_DOMAIN}" + fi + local CONFIGURED_SITEURL="" + prompt_text "Mattermost site URL [$SITE_URL]:" "$SITE_URL" CONFIGURED_SITEURL + [[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL" + + cat > docker-compose.yml << 'EOF' +name: mattermost + +services: + db: + image: postgres:15-alpine + container_name: mattermost-db + hostname: mattermost-db + restart: unless-stopped + env_file: .env + volumes: + - ./db:/var/lib/postgresql/data + networks: + - caddy_net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + mattermost: + image: mattermost/mattermost-team-edition:latest + container_name: mattermost + hostname: mattermost + restart: unless-stopped + env_file: .env + depends_on: + db: + condition: service_healthy + volumes: + - ./data:/mattermost/data + - ./logs:/mattermost/logs + - ./config:/mattermost/config + - ./plugins:/mattermost/plugins + ports: + - "8065:8065" + - "8443:8443/udp" + networks: + - caddy_net + + coturn: + image: coturn/coturn:latest + container_name: mattermost-coturn + network_mode: host + user: root + command: + - -n + - --listening-port=3479 + - --listening-ip=0.0.0.0 + - --fingerprint + - --use-auth-secret + - --static-auth-secret=${COTURN_SECRET} + - --realm=${MM_REALM:-localhost} + - --min-port=49153 + - --max-port=49352 + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + restart: unless-stopped + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +EOF + + cat > .env << EOF +TZ=$TZ_VAL +CADDY_NET=$SITE_CADDY_NET + +# 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 + + 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 + + echo "" + log_success "Mattermost configured at $DIR" + + configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost" + + write_readme "$DIR" << MD +# Mattermost + +Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay. + +## Access +- URL: $SITE_URL (or http://localhost:8065) +- First run: create admin account at the URL above + +## 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). + +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 +docker compose up -d +docker compose down +docker compose logs -f +docker compose pull && docker compose up -d +\`\`\` +MD + + 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" = "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 "" +} + +# 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..050bf2e --- /dev/null +++ b/services/nextcloud.sh @@ -0,0 +1,356 @@ +#!/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). +# +# 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 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" + } + + 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; } + 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" + + # 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 + 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 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 Dockerfile, docker-compose.yml, .env" + return 0 + fi + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local DB_PASS + DB_PASS=$(generate_password 32) + local NC_ADMIN_PASS + NC_ADMIN_PASS=$(generate_password 16) + local TZ_VAL="${SITE_TZ:-UTC}" + + # ── 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/* +NCDF + + # ── docker-compose.yml ────────────────────────────────────────────────── + cat > docker-compose.yml << 'NCCOMPOSE' +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} +NCCOMPOSE + + # ── .env ──────────────────────────────────────────────────────────────── + cat > .env << NCENV +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 (first run only) +NEXTCLOUD_ADMIN_USER=admin +NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS +NEXTCLOUD_DB_TYPE=mysql +MYSQL_HOST=db + +# 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 +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" + + # ── 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 --build \ + && log_success "Nextcloud started" \ + || { log_warning "Start failed — check: docker compose logs"; return 1; } + + # ── 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 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 " Access URL: http://localhost:8080" + echo " Admin user: admin" + echo " Admin pass: $NC_ADMIN_PASS" + echo " Config dir: $DIR" + echo "" + echo " Note: First startup may take 1-2 minutes while Nextcloud initialises." + 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..7abba63 --- /dev/null +++ b/services/onlyoffice.sh @@ -0,0 +1,382 @@ +#!/bin/bash +# 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: +# sudo bash onlyoffice.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 + } + + 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; } + 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" + + # 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 + 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 onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082 + +# ── Helper: install yq v4 if absent ────────────────────────────────────────── +_ensure_yq() { + 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" +} + +# ── Helper: wire OnlyOffice into Nextcloud (idempotent) ─────────────────────── +_wire_nextcloud() { + 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 + } + 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" +} + +# ── Helper: patch FileBrowser Quantum config.yaml with OnlyOffice endpoint ─── +_wire_filebrowser() { + 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 + _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, .env" + return 0 + fi + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + # ── 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)" + + # ── docker-compose.yml ────────────────────────────────────────────────── + cat > docker-compose.yml << 'OOCOMPOSE' +name: onlyoffice +services: + onlyoffice: + image: onlyoffice/documentserver:latest + container_name: onlyoffice + 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: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +OOCOMPOSE + + # ── .env ──────────────────────────────────────────────────────────────── + cat > .env << OOENV +CADDY_NET=$SITE_CADDY_NET +# JWT authentication — keep JWT_SECRET private +JWT_ENABLED=true +JWT_SECRET=$JWT_SECRET +JWT_HEADER=AuthorizationJwt +OOENV + chmod 600 .env + + # ── Subdirectories ────────────────────────────────────────────────────── + mkdir -p logs data fonts + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + + 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" + + # ── 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 integrations (runs every install/re-install) ─────────────────── + echo "" + _wire_nextcloud "$JWT_SECRET" + _wire_filebrowser + + # ── README ─────────────────────────────────────────────────────────────── + write_readme "$DIR" << OOREAD +# OnlyOffice Document Server + +Self-hosted document editing server, integrated with Nextcloud and FileBrowser Quantum. + +## Access + +- 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 +docker compose up -d # start +docker compose down # stop +docker compose logs -f # follow logs +docker compose pull && docker compose up -d # update +\`\`\` + +## 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 " 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 "" +} + +# 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..a1e125b --- /dev/null +++ b/vendor/easy-asterisk/.env.example @@ -0,0 +1,32 @@ +# ================================================================ +# Easy Asterisk — Environment Configuration +# Copy to .env and fill in your values. +# ================================================================ + +# ── 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 + +# ── TURN/STUN ────────────────────────────────────────────────── +# Generate a strong password: openssl rand -base64 18 +TURN_USERNAME=easyasterisk +TURN_PASSWORD=changeme +TURN_PORT=3478 +# Points to coturn. For LAN-only leave empty. +TURN_SERVER=${DOMAIN_NAME}:${TURN_PORT} + +# ── RTP port range ───────────────────────────────────────────── +RTP_START=10000 +RTP_END=20000 + +# ── 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 new file mode 100644 index 0000000..0ce4bc0 --- /dev/null +++ b/vendor/easy-asterisk/Dockerfile @@ -0,0 +1,60 @@ +FROM ubuntu:24.04 + +ENV LANG=en_US.UTF-8 \ + LANGUAGE=en_US:en \ + LC_ALL=en_US.UTF-8 \ + DEBIAN_FRONTEND=noninteractive + +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. Opus pass-through still works via res_format_attr_opus.so. + +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 + +# Management script and helpers +COPY easy-asterisk-v0.10.0.sh /usr/local/bin/easy-asterisk +COPY scripts/vpn-diagnostics.sh /usr/local/bin/vpn-diagnostics +COPY scripts/dns-whitelist.sh /usr/local/bin/dns-whitelist +COPY docker/entrypoint.sh /entrypoint.sh +RUN chmod +x /usr/local/bin/easy-asterisk \ + /usr/local/bin/vpn-diagnostics \ + /usr/local/bin/dns-whitelist \ + /entrypoint.sh + +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" || 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..7431a81 --- /dev/null +++ b/vendor/easy-asterisk/docker/coturn-entrypoint.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# coturn-entrypoint.sh — robust wrapper for the coturn Docker image. +# +# The coturn image's default entrypoint uses: +# exec $(eval "echo $@") +# which fails when detect-external-ip returns empty — produces a blank token +# and coturn logs "ERROR: CONFIG: Unknown argument:" +# +# This wrapper avoids eval word-splitting and only adds --external-ip when +# an IP is actually obtained. + +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 + +if [[ -n "$ext_ip" ]]; then + exec turnserver "$@" --external-ip="$ext_ip" +else + exec turnserver "$@" +fi diff --git a/vendor/easy-asterisk/docker/entrypoint.sh b/vendor/easy-asterisk/docker/entrypoint.sh new file mode 100755 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 100755 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 100755 index 0000000..3500017 --- /dev/null +++ b/vendor/easy-asterisk/scripts/dns-whitelist.sh @@ -0,0 +1,55 @@ +#!/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 " --check Test reachability of each domain" + echo " --sipnetic Show Sipnetic-specific domains" + echo " --linphone Show Linphone-specific domains" + exit 0 ;; + *) shift ;; + esac +done + +source "$CONFIG_FILE" 2>/dev/null || true + +echo "" +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 new file mode 100755 index 0000000..9694f66 --- /dev/null +++ b/vendor/easy-asterisk/scripts/vpn-diagnostics.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# ================================================================ +# VPN Diagnostics for Easy Asterisk +# Validates PJSIP, TLS, ICE, RTP, and device configuration. +# Usage: vpn-diagnostics [--auto] [--client-ip ] +# ================================================================ + +set -e + +GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m' + +echo -e "${CYAN}━━━ Easy Asterisk VPN Diagnostics ━━━${NC}" +echo "" + +# 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)" + +# Check transports +echo "" +echo -e "${CYAN}Transports:${NC}" +asterisk -rx "pjsip show transports" 2>/dev/null || true + +# Check registered endpoints +echo "" +echo -e "${CYAN}Endpoints:${NC}" +asterisk -rx "pjsip show endpoints" 2>/dev/null || true + +# Check TLS cert +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 ""