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
This commit is contained in:
Claude
2026-06-09 00:28:38 +00:00
parent ec3f9bfd3f
commit 7c3f101fe0
11 changed files with 772 additions and 1488 deletions
+226 -251
View File
@@ -1,16 +1,15 @@
#!/bin/bash #!/bin/bash
# services/asterisk.sh — Easy Asterisk PBX with self-hosted coturn TURN server. # services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP).
# Part of the modular post-install system (sourced by setup.sh). # Part of the modular post-install system (sourced by setup.sh).
# #
# Based on https://github.com/outis1one/easy-asterisk
# Source files vendored in vendor/easy-asterisk/
# Personal/home-lab use only. Not for commercial or emergency services.
#
# Can also be run standalone on any machine: # Can also be run standalone on any machine:
# sudo bash asterisk.sh # sudo bash asterisk.sh
# (Docker must already be installed when run standalone) # (Docker must already be installed when run standalone)
# ── Standalone bootstrap ────────────────────────────────────────────────────── # ── 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 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
@@ -18,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
_COMMON="$_SELF_DIR/../lib/common.sh" _COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh # shellcheck source=../lib/common.sh
source "$_COMMON" source "$_COMMON"
else 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_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
@@ -43,6 +44,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true 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() { prompt_text() {
local _q="$1" _def="$2" _var="$3" _r local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -62,6 +64,53 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
local _caddy_dir="$DOCKER_DIR/caddy" local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile" 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 if [[ ! -d "$_caddy_dir" ]]; then
log_info "Access $_name directly on port ${_upstream##*:}." log_info "Access $_name directly on port ${_upstream##*:}."
return 0 return 0
@@ -79,6 +128,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Back up before touching
if [[ -f "$_caddyfile" ]]; then if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk" cp "$_caddyfile" "$_bk"
@@ -87,6 +137,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
touch "$_caddyfile" touch "$_caddyfile"
fi fi
# Remove existing block for this domain if present
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile" log_warning "$_domain already in Caddyfile"
local _ow="" local _ow=""
@@ -127,12 +178,21 @@ CBLOCK
} }
write_readme() { write_readme() {
local _dir="$1"; shift local _dir="$1"
mkdir -p "$_dir" mkdir -p "$_dir"
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
echo
}
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_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
@@ -141,8 +201,9 @@ CBLOCK
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; } register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1 _RUN_STANDALONE=1
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -151,109 +212,76 @@ register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (home
install_asterisk() { install_asterisk() {
require_docker || return 1 require_docker || return 1
log_info "Installing Easy Asterisk PBX..." log_info "Installing Easy Asterisk PBX + coturn..."
local EA_DIR="$DOCKER_DIR/asterisk" local EA_DIR="$DOCKER_DIR/asterisk"
# Locate vendored source files (works when sourced by setup.sh or run standalone)
local _script_dir
_script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)" \
|| _script_dir="$(dirname "$(realpath "$0" 2>/dev/null || echo "$0")")"
local VENDOR_DIR="$_script_dir/../vendor/easy-asterisk"
VENDOR_DIR="$(cd "$VENDOR_DIR" 2>/dev/null && pwd)" || VENDOR_DIR=""
if [[ -z "$VENDOR_DIR" || ! -f "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ]]; then
log_warning "Vendored easy-asterisk files not found at $VENDOR_DIR"
log_warning "Expected: vendor/easy-asterisk/ alongside services/ directory"
log_error "Cannot install — run from the ubuntu-post-install repo root."
return 1
fi
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $EA_DIR" echo "[DRY-RUN] Would create $EA_DIR with Dockerfile, docker-compose.yml, .env"
echo "[DRY-RUN] Would copy vendored easy-asterisk files (Dockerfile, scripts, entrypoints)" echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk"
echo "[DRY-RUN] Would write docker-compose.yml, .env" echo "[DRY-RUN] Would open UFW ports: 5060, 5061, 8080, 8088, 8089, 3478, 10000-20000, 49152-49252"
echo "[DRY-RUN] Would open UFW ports for SIP/RTP/TURN"
return 0 return 0
fi fi
mkdir -p "$EA_DIR/docker" "$EA_DIR/scripts" mkdir -p "$EA_DIR"
ensure_docker_dir_ownership "$EA_DIR" ensure_docker_dir_ownership "$EA_DIR"
cd "$EA_DIR" || return 1 cd "$EA_DIR" || return 1
# ── Copy vendored source files ──────────────────────────────────────────── mkdir -p docker
log_info "Copying Easy Asterisk source files from vendor/..."
cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" "$EA_DIR/easy-asterisk.sh" # ── Vendor files ──────────────────────────────────────────────────────────
cp "$VENDOR_DIR/Dockerfile" "$EA_DIR/Dockerfile" local _SELF_DIR_LOCAL
cp "$VENDOR_DIR/docker/entrypoint.sh" "$EA_DIR/docker/entrypoint.sh" _SELF_DIR_LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" local VENDOR_DIR="$_SELF_DIR_LOCAL/../vendor/easy-asterisk"
cp "$VENDOR_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/vpn-diagnostics.sh"
cp "$VENDOR_DIR/scripts/dns-whitelist.sh" "$EA_DIR/scripts/dns-whitelist.sh"
chmod 750 "$EA_DIR/easy-asterisk.sh" if [[ -d "$VENDOR_DIR" ]]; then
chmod 755 "$EA_DIR/docker/entrypoint.sh" "$EA_DIR/docker/coturn-entrypoint.sh" log_info "Copying vendor files from $VENDOR_DIR ..."
chmod 755 "$EA_DIR/scripts/vpn-diagnostics.sh" "$EA_DIR/scripts/dns-whitelist.sh" cp "$VENDOR_DIR/Dockerfile" ./Dockerfile
cp "$VENDOR_DIR/docker/entrypoint.sh" ./docker/entrypoint.sh
log_success "Source files copied" cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" ./docker/coturn-entrypoint.sh
cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk.sh
# The Dockerfile COPYs easy-asterisk-v0.10.0.sh (the versioned name). cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk-v0.10.0.sh
# We keep easy-asterisk.sh as the canonical name and make a real copy
# with the versioned filename so Docker COPY works reliably (no symlinks).
cp "$EA_DIR/easy-asterisk.sh" "$EA_DIR/easy-asterisk-v0.10.0.sh"
# ── FQDN setup ────────────────────────────────────────────────────────────
echo ""
echo " Easy Asterisk can run in two modes:"
echo ""
echo " LAN/VPN — UDP transport, no TLS, no TURN."
echo " Simple setup for devices on your local network or WireGuard/Tailscale."
echo ""
echo " FQDN — TLS + SRTP + coturn TURN relay."
echo " Works from anywhere: LAN, cellular, hotel WiFi, Proton VPN."
echo " Requires a domain name pointing to this server's public IP."
echo ""
local DOMAIN_NAME=""
prompt_text "FQDN for this server (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank for LAN-only]:" "" DOMAIN_NAME
local LAN_ONLY=false
if [[ -z "$DOMAIN_NAME" ]]; then
LAN_ONLY=true
log_info "LAN/VPN-only mode — TLS and TURN disabled."
else else
log_info "FQDN mode: $DOMAIN_NAME" log_info "Vendor directory not found — downloading from GitHub ..."
echo "" local GH_RAW="https://raw.githubusercontent.com/DeadDork/easy-asterisk/main"
echo " Required router port forwards:" curl -fsSL "$GH_RAW/Dockerfile" -o ./Dockerfile
printf " %-22s %s\n" "5061/tcp" "SIP TLS signaling" curl -fsSL "$GH_RAW/docker/entrypoint.sh" -o ./docker/entrypoint.sh
printf " %-22s %s\n" "3478/udp+tcp" "STUN/TURN (NAT traversal)" curl -fsSL "$GH_RAW/docker/coturn-entrypoint.sh" -o ./docker/coturn-entrypoint.sh
printf " %-22s %s\n" "10000-20000/udp" "RTP media (Asterisk)" curl -fsSL "$GH_RAW/easy-asterisk-v0.10.0.sh" -o ./easy-asterisk.sh
printf " %-22s %s\n" "49152-49252/udp" "TURN relay range (coturn)" cp ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh
echo ""
fi fi
# ── Generate TURN password ──────────────────────────────────────────────── chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \
./docker/entrypoint.sh ./docker/coturn-entrypoint.sh
# ── Networking mode ───────────────────────────────────────────────────────
echo ""
echo " Networking mode:"
echo " 1) LAN-only — no domain, self-signed cert, works on local network/VPN only"
echo " 2) FQDN — TLS + TURN relay, works from anywhere (requires public domain)"
local HA_NETMODE=""
prompt_text "Choose [1]:" "1" HA_NETMODE
local DOMAIN_NAME=""
if [[ "$HA_NETMODE" == "2" ]]; then
prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=skip]:" "" DOMAIN_NAME
fi
# ── Secrets ───────────────────────────────────────────────────────────────
local TURN_PASSWORD local TURN_PASSWORD
TURN_PASSWORD="$(openssl rand -base64 18 2>/dev/null | tr -dc 'a-zA-Z0-9' | head -c 24 \ TURN_PASSWORD="$(generate_password 24)"
|| tr -dc 'A-Za-z0-9' </dev/urandom | head -c 24)"
local TURN_SERVER_VAL=""
[[ -n "$DOMAIN_NAME" ]] && TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
# ── docker-compose.yml ──────────────────────────────────────────────────── # ── docker-compose.yml ────────────────────────────────────────────────────
# Uses the real upstream Dockerfile (FROM ubuntu:24.04 + full Asterisk install) cat > docker-compose.yml << 'EOF'
# with host networking for RTP/NAT, and the custom coturn entrypoint. name: asterisk
cat > docker-compose.yml << 'COMPOSE_EOF'
# Easy Asterisk — managed by ubuntu-post-install
# Manage: docker exec -it easy-asterisk easy-asterisk
# Source: https://github.com/outis1one/easy-asterisk
services: services:
asterisk: asterisk:
build: build: .
context: .
dockerfile: Dockerfile
container_name: easy-asterisk container_name: easy-asterisk
# Host networking: required for RTP (10000-20000/udp) and proper NAT detection.
# SIP clients connect directly to the host IP; Caddy is only used for the web admin.
network_mode: host network_mode: host
depends_on: depends_on:
coturn: coturn:
@@ -264,23 +292,8 @@ services:
- asterisk-logs:/var/log/asterisk - asterisk-logs:/var/log/asterisk
- asterisk-spool:/var/spool/asterisk - asterisk-spool:/var/spool/asterisk
- asterisk-lib:/var/lib/asterisk - asterisk-lib:/var/lib/asterisk
# Bind-mount the management script so updates don't require a rebuild
- ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro - ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro
environment: env_file: .env
- DOMAIN_NAME=${DOMAIN_NAME}
- ENABLE_TLS=${ENABLE_TLS:-y}
- PUBLIC_IP=${PUBLIC_IP:-}
- LOCAL_CIDR=${LOCAL_CIDR:-}
- HAS_VLANS=${HAS_VLANS:-n}
- VLAN_SUBNETS=${VLAN_SUBNETS:-}
- TURN_ENABLED=${TURN_ENABLED:-y}
- TURN_SERVER=${TURN_SERVER}
- TURN_USERNAME=${TURN_USERNAME:-easyasterisk}
- TURN_PASSWORD=${TURN_PASSWORD}
- RTP_START=${RTP_START:-10000}
- RTP_END=${RTP_END:-20000}
- WEB_ADMIN_PORT=${WEB_ADMIN_PORT:-8080}
- WEB_ADMIN_AUTH_DISABLED=${WEB_ADMIN_AUTH_DISABLED:-false}
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "asterisk", "-rx", "core show version"] test: ["CMD", "asterisk", "-rx", "core show version"]
@@ -296,8 +309,7 @@ services:
entrypoint: ["/coturn-entrypoint.sh"] entrypoint: ["/coturn-entrypoint.sh"]
volumes: volumes:
- ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro - ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro
environment: env_file: .env
- PUBLIC_IP=${PUBLIC_IP:-}
command: command:
- -n - -n
- --listening-port=${TURN_PORT:-3478} - --listening-port=${TURN_PORT:-3478}
@@ -306,8 +318,8 @@ services:
- --lt-cred-mech - --lt-cred-mech
- --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD} - --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD}
- --realm=${DOMAIN_NAME:-localhost} - --realm=${DOMAIN_NAME:-localhost}
- --min-port=${TURN_RELAY_MIN:-49152} - --min-port=49152
- --max-port=${TURN_RELAY_MAX:-49252} - --max-port=49252
- --no-tls - --no-tls
- --no-dtls - --no-dtls
- --no-cli - --no-cli
@@ -321,185 +333,148 @@ volumes:
asterisk-logs: asterisk-logs:
asterisk-spool: asterisk-spool:
asterisk-lib: asterisk-lib:
COMPOSE_EOF EOF
# ── .env ───────────────────────────────────────────────────────────────── # ── .env ─────────────────────────────────────────────────────────────────
cat > .env << ENV cat > .env << ENV
# Easy Asterisk — environment configuration # ── Domain ────────────────────────────────────────────────────
# Edit and restart: docker compose down && docker compose up -d # Set to your FQDN for remote access. Leave empty for LAN-only.
DOMAIN_NAME=${DOMAIN_NAME}
# FQDN pointing to this server's public IP (required for remote/TLS mode) # ── TURN/STUN ─────────────────────────────────────────────────
DOMAIN_NAME=$DOMAIN_NAME
# Public IP — leave empty to auto-detect
PUBLIC_IP=
# TLS — always 'y' for remote access, 'n' for LAN-only
ENABLE_TLS=$( [[ "$LAN_ONLY" == "true" ]] && echo "n" || echo "y" )
# Local network CIDR — auto-detected if empty
LOCAL_CIDR=
# Additional subnets for site-to-site VPNs (WireGuard/Tailscale mesh, NOT client-side)
HAS_VLANS=n
VLAN_SUBNETS=
# TURN/STUN credentials — must match in both Asterisk and coturn
# Regenerate: openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24
TURN_USERNAME=easyasterisk TURN_USERNAME=easyasterisk
TURN_PASSWORD=$TURN_PASSWORD TURN_PASSWORD=${TURN_PASSWORD}
# TURN server address — auto-set based on FQDN or LAN mode above
# LAN-only: leave empty (coturn not used). FQDN mode: domain:port
TURN_SERVER=$( [[ "$LAN_ONLY" == "true" ]] && echo "" || echo "${DOMAIN_NAME}:3478" )
# TURN port (change to 3479 if 3478 conflicts with UniFi controller or Mattermost)
TURN_PORT=3478 TURN_PORT=3478
# For LAN-only: TURN_SERVER is empty. For FQDN: set to domain:3478
TURN_SERVER=${TURN_SERVER_VAL}
# TURN relay port range — forward this range on your router # ── RTP port range ────────────────────────────────────────────
TURN_RELAY_MIN=49152
TURN_RELAY_MAX=49252
# RTP media port range — forward this range on your router
RTP_START=10000 RTP_START=10000
RTP_END=20000 RTP_END=20000
# Web admin interface # ── Web admin ─────────────────────────────────────────────────
WEB_ADMIN_PORT=8080 WEB_ADMIN_PORT=8080
WEB_ADMIN_AUTH_DISABLED=false WEB_ADMIN_AUTH_DISABLED=false
ENV ENV
chmod 600 .env chmod 600 .env
chown "$ACTUAL_USER:$ACTUAL_USER" .env
# ── UFW firewall rules ──────────────────────────────────────────────────── # ── UFW firewall rules ────────────────────────────────────────────────────
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then if command -v ufw &>/dev/null; then
log_info "Opening UFW ports for Asterisk..." log_info "Opening UFW ports for Asterisk + coturn..."
ufw allow 5060/udp comment "Asterisk SIP UDP" >/dev/null ufw allow 5060/udp
ufw allow 5060/tcp comment "Asterisk SIP TCP" >/dev/null ufw allow 5060/tcp
ufw allow 5061/tcp comment "Asterisk SIP TLS" >/dev/null ufw allow 5061/tcp
ufw allow 8080/tcp comment "Asterisk web admin" >/dev/null ufw allow 8080/tcp
ufw allow 8088/tcp comment "Asterisk HTTP provision" >/dev/null ufw allow 8088/tcp
ufw allow 8089/tcp comment "Asterisk HTTPS provision" >/dev/null ufw allow 8089/tcp
ufw allow 3478/udp comment "coturn STUN/TURN UDP" >/dev/null ufw allow 3478/udp
ufw allow 3478/tcp comment "coturn STUN/TURN TCP" >/dev/null ufw allow 3478/tcp
ufw allow 10000:20000/udp comment "Asterisk RTP media" >/dev/null ufw allow 10000:20000/udp
ufw allow 49152:49252/udp comment "coturn TURN relay" >/dev/null ufw allow 49152:49252/udp
log_success "UFW rules added" log_success "UFW rules added."
else
log_info "UFW not active — open these ports manually if needed:"
log_info " 5060/udp+tcp, 5061/tcp"
log_info " 8080/tcp (web admin), 8088/tcp, 8089/tcp (provisioning)"
log_info " 3478/udp+tcp (STUN/TURN)"
log_info " 10000-20000/udp (RTP), 49152-49252/udp (TURN relay)"
fi fi
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$EA_DIR" # ── Caddy reverse proxy for web admin ─────────────────────────────────────
local EXTRA_BLOCK=""
# ── Caddy for web admin (with optional Authelia SSO) ──────────────────────
# The web admin has no built-in auth; let Authelia gate it if available.
local EA_EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth="" local _use_auth=""
prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth
if [[ "$_use_auth" =~ ^[Yy]$ ]]; then if [[ "$_use_auth" =~ ^[Yy]$ ]]; then
EA_EXTRA_BLOCK=" import authelia" EXTRA_BLOCK=" import authelia"
# Tell Asterisk's web admin to skip its own auth Authelia handles it # Disable built-in auth since Authelia handles it
sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" "$EA_DIR/.env" sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env
log_info "WEB_ADMIN_AUTH_DISABLED=true set (Authelia will handle authentication)"
fi fi
fi fi
configure_caddy_for_service "Asterisk Web Admin" "localhost:8080" "asterisk" "$EA_EXTRA_BLOCK" configure_caddy_for_service "Asterisk Web Admin" "8080" "asterisk" "$EXTRA_BLOCK"
# ── README ──────────────────────────────────────────────────────────────── # ── README ────────────────────────────────────────────────────────────────
write_readme "$EA_DIR" << MD write_readme "$EA_DIR" << 'MD'
# Easy Asterisk PBX # Easy Asterisk PBX + coturn
Home intercom / VoIP system built on Asterisk with self-hosted coturn TURN server. Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for
Personal/home-lab use only. Source: https://github.com/outis1one/easy-asterisk NAT traversal. Suitable for home intercom, VoIP handsets, and softphones.
## Access
- Web admin: http://localhost:8080/clients
- FQDN: $( [[ -n "$DOMAIN_NAME" ]] && echo "$DOMAIN_NAME" || echo "(LAN-only — no domain)" )
## Management
\`\`\`bash
# Interactive management menu (add devices, provisioning, diagnostics)
docker exec -it easy-asterisk easy-asterisk
# VPN diagnostics
docker exec -it easy-asterisk vpn-diagnostics
# DNS whitelist check
docker exec -it easy-asterisk dns-whitelist
\`\`\`
## Adding devices
Run the management menu → Device Management → Add device.
Each device gets a SIP extension, password, and setup instructions
for Linphone (remote provisioning) or Baresip (manual).
## Connection modes
- **LAN/VPN**: UDP, no encryption — local network or WireGuard/Tailscale
- **FQDN**: TLS + SRTP + coturn TURN relay — works from anywhere
## Caddy and phone calls
Asterisk uses **host networking** — SIP signaling and RTP media connect
directly to the server, completely bypassing Caddy. Do NOT put SIP ports
behind a reverse proxy (Contact header rewriting will break registration).
Caddy only handles the **web admin** (port 8080) for HTTPS browser access.
The **provisioning server** (ports 8088/8089) is Asterisk's built-in HTTP
server for Linphone XML config delivery. Access it directly by IP/domain,
not through Caddy — SIP clients fetch it at startup before registering.
## Router port forwards (FQDN mode)
| Port | Protocol | Service |
|------|----------|---------|
| 5061 | TCP | SIP TLS signaling |
| 3478 | UDP+TCP | STUN/TURN |
| 10000-20000 | UDP | RTP media |
| 49152-49252 | UDP | TURN relay |
| 8088 | TCP | Provisioning (Linphone XML) — optional |
## TURN credentials (for SIP clients behind strict NAT)
- Server: \${DOMAIN_NAME}:3478
- Username: easyasterisk
- Password: (see .env → TURN_PASSWORD)
## Manage ## Manage
\`\`\`bash
cd $EA_DIR ```bash
docker compose up -d # start docker compose up -d --build # build image and start
docker compose up -d # start (after initial build)
docker compose down # stop docker compose down # stop
docker compose logs -f # logs docker compose logs -f # follow logs
docker compose pull # update coturn image docker compose pull # update coturn image
docker compose build --pull && docker compose up -d # rebuild Asterisk 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 | <host-ip> (LAN) or your FQDN (FQDN) |
| SIP port | 5061 (TLS) / 5060 (UDP) |
| TURN server | <DOMAIN_NAME>: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://<host-ip>: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) |
| 1000020000 | UDP | RTP media streams |
| 4915249252 | UDP | TURN relay media ports |
MD MD
# ── Build and start ─────────────────────────────────────────────────────── # ── Start ─────────────────────────────────────────────────────────────────
echo "" echo ""
local START_EA="" local START_NOW=""
prompt_yn "Build and start Easy Asterisk now? (y/n):" "y" START_EA prompt_yn "Build and start Asterisk now? (y/n):" "y" START_NOW
if [[ "$START_EA" =~ ^[Yy]$ ]]; then if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then
log_info "Building Asterisk image (first build takes a few minutes)..." docker compose up -d --build \
if docker compose build --pull 2>&1 | tail -5; then && log_success "Easy Asterisk started" \
if docker compose up -d; then || log_warning "Start failed — check: docker compose logs"
log_success "Easy Asterisk started" fi
# ── Summary ───────────────────────────────────────────────────────────────
echo "" echo ""
echo " Web admin: http://localhost:8080/clients" log_success "Easy Asterisk installed at $EA_DIR"
echo " Management: docker exec -it easy-asterisk easy-asterisk" if [[ -n "$DOMAIN_NAME" ]]; then
echo "" echo " Mode: FQDN ($DOMAIN_NAME)"
log_info "Next: add your first device via the management menu." echo " TURN server: ${DOMAIN_NAME}:3478"
else else
log_warning "Start failed — check: docker compose logs" echo " Mode: LAN-only"
fi echo " TURN server: (none — LAN/VPN only)"
else
log_warning "Build failed — check output above"
fi
fi 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 <up|down|logs>"
echo " Script: docker exec -it easy-asterisk easy-asterisk --help"
echo "" echo ""
} }
+164 -198
View File
@@ -2,14 +2,14 @@
# services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn). # services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn).
# Part of the modular post-install system (sourced by setup.sh). # Part of the modular post-install system (sourced by setup.sh).
# #
# Mattermost Team Edition with PostgreSQL and a dedicated coturn TURN server
# (port 3479 — distinct from Easy Asterisk's coturn on 3478).
#
# Can also be run standalone on any machine: # Can also be run standalone on any machine:
# sudo bash mattermost.sh # sudo bash mattermost.sh
# (Docker must already be installed when run standalone) # (Docker must already be installed when run standalone)
# ── Standalone bootstrap ────────────────────────────────────────────────────── # ── 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 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
@@ -17,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
_COMMON="$_SELF_DIR/../lib/common.sh" _COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh # shellcheck source=../lib/common.sh
source "$_COMMON" source "$_COMMON"
else 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_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
@@ -38,15 +40,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
} }
} }
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' </dev/urandom | head -c "$_len"
}
ensure_docker_dir_ownership() { ensure_docker_dir_ownership() {
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true 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() { prompt_text() {
local _q="$1" _def="$2" _var="$3" _r local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -66,6 +64,53 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
local _caddy_dir="$DOCKER_DIR/caddy" local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile" 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 if [[ ! -d "$_caddy_dir" ]]; then
log_info "Access $_name directly on port ${_upstream##*:}." log_info "Access $_name directly on port ${_upstream##*:}."
return 0 return 0
@@ -83,6 +128,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Back up before touching
if [[ -f "$_caddyfile" ]]; then if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk" cp "$_caddyfile" "$_bk"
@@ -91,6 +137,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
touch "$_caddyfile" touch "$_caddyfile"
fi fi
# Remove existing block for this domain if present
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile" log_warning "$_domain already in Caddyfile"
local _ow="" local _ow=""
@@ -131,12 +178,21 @@ CBLOCK
} }
write_readme() { write_readme() {
local _dir="$1"; shift local _dir="$1"
mkdir -p "$_dir" mkdir -p "$_dir"
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
cat > "$_dir/README.md" cat > "$_dir/README.md"
} }
generate_password() {
local _len="${1:-32}"
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
echo
}
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_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
@@ -145,8 +201,9 @@ CBLOCK
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
register_service() { :; } register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1 _RUN_STANDALONE=1
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
@@ -155,67 +212,57 @@ register_service mattermost utilities "Team messaging with voice/video calls (Ma
install_mattermost() { install_mattermost() {
require_docker || return 1 require_docker || return 1
log_info "Installing Mattermost Team Edition..." log_info "Installing Mattermost + coturn..."
local DIR="$DOCKER_DIR/mattermost" local DIR="$DOCKER_DIR/mattermost"
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with subdirectories: data logs config plugins db" echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
echo "[DRY-RUN] Would generate DB password, MM secret key, and TURN secret" echo "[DRY-RUN] Would write .env with DB and Mattermost secrets"
echo "[DRY-RUN] Would write docker-compose.yml and .env" echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories"
echo "[DRY-RUN] Would open UFW ports: 3479/udp+tcp, 49153-49352/udp" echo "[DRY-RUN] Would open UFW ports 8443/udp, 3479, 49153:49352/udp"
echo "[DRY-RUN] Would configure Caddy reverse proxy for Mattermost"
return 0 return 0
fi fi
# ── Create directory structure ──────────────────────────────────────────── mkdir -p "$DIR"
mkdir -p "$DIR"/{data,logs,config,plugins,db}
# Mattermost runs as UID 2000 inside the container
chown -R 2000:2000 "$DIR/data" "$DIR/logs" "$DIR/config" "$DIR/plugins"
ensure_docker_dir_ownership "$DIR/db"
ensure_docker_dir_ownership "$DIR" ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1 cd "$DIR" || return 1
# ── Generate secrets ────────────────────────────────────────────────────── local DB_PASS
local DB_PASS MM_SECRET TURN_SECRET local MM_SECRET
DB_PASS="$(generate_password 32)" DB_PASS=$(generate_password 32)
MM_SECRET="$(generate_password 48)" MM_SECRET=$(generate_password 48)
TURN_SECRET="$(openssl rand -hex 32 2>/dev/null || generate_password 32)"
# ── Site URL ────────────────────────────────────────────────────────────── local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
local UID_VAL GID_VAL
UID_VAL=$(id -u "$ACTUAL_USER")
GID_VAL=$(id -g "$ACTUAL_USER")
# Compute SITE_URL
local SITE_URL="http://localhost:8065" local SITE_URL="http://localhost:8065"
if [[ -n "$SITE_DOMAIN" && "$SITE_DOMAIN" != "example.com" ]]; then if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
SITE_URL="https://chat.${SITE_DOMAIN}" SITE_URL="https://mattermost.${SITE_DOMAIN}"
fi fi
local CONFIGURED_SITEURL="" local CONFIGURED_SITEURL=""
prompt_text "Mattermost site URL [${SITE_URL}]:" "$SITE_URL" CONFIGURED_SITEURL prompt_text "Mattermost site URL [$SITE_URL]:" "$SITE_URL" CONFIGURED_SITEURL
[[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL" [[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL"
# ── docker-compose.yml ──────────────────────────────────────────────────── cat > docker-compose.yml << 'EOF'
cat > docker-compose.yml << COMPOSE
# Mattermost Team Edition — generated by ubuntu-post-install
# Manage: docker compose up -d / down / logs -f
# Admin setup: \${MATTERMOST_SITE_URL}/signup_user_complete
name: mattermost name: mattermost
services: services:
db: db:
image: postgres:15-alpine image: postgres:15-alpine
container_name: mattermost-db container_name: mattermost-db
hostname: mattermost-db
restart: unless-stopped restart: unless-stopped
security_opt: env_file: .env
- no-new-privileges:true
pids_limit: 100
volumes: volumes:
- ./db:/var/lib/postgresql/data - ./db:/var/lib/postgresql/data
environment: networks:
- POSTGRES_USER=mattermost - caddy_net
- POSTGRES_PASSWORD=\${DB_PASS}
- POSTGRES_DB=mattermost
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U mattermost"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -223,46 +270,36 @@ services:
mattermost: mattermost:
image: mattermost/mattermost-team-edition:latest image: mattermost/mattermost-team-edition:latest
container_name: mattermost container_name: mattermost
hostname: mattermost
restart: unless-stopped restart: unless-stopped
security_opt: env_file: .env
- no-new-privileges:true
pids_limit: 200
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
ports:
- "8065:8065"
- "8443:8443/udp" # Calls plugin RTC server (WebRTC direct path)
volumes: volumes:
- ./data:/mattermost/data - ./data:/mattermost/data
- ./logs:/mattermost/logs - ./logs:/mattermost/logs
- ./config:/mattermost/config - ./config:/mattermost/config
- ./plugins:/mattermost/plugins - ./plugins:/mattermost/plugins
environment: ports:
- MM_SQLSETTINGS_DRIVERNAME=postgres - "8065:8065"
- MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:\${DB_PASS}@db:5432/mattermost?sslmode=disable - "8443:8443/udp"
- MM_SERVICESETTINGS_SITEURL=\${MATTERMOST_SITE_URL}
- MM_PLUGINSETTINGS_ENABLEUPLOADS=true
- MM_SERVICESETTINGS_ENABLELOCALMODE=true
- TZ=\${TZ}
networks: networks:
- default
- caddy_net - caddy_net
coturn: coturn:
image: coturn/coturn:latest image: coturn/coturn:latest
container_name: mattermost-coturn container_name: mattermost-coturn
restart: unless-stopped
network_mode: host network_mode: host
user: root
command: command:
- -n - -n
- --listening-port=3479 - --listening-port=3479
- --tls-listening-port=5350
- --listening-ip=0.0.0.0 - --listening-ip=0.0.0.0
- --fingerprint - --fingerprint
- --use-auth-secret - --use-auth-secret
- --static-auth-secret=\${TURN_SECRET} - --static-auth-secret=${COTURN_SECRET}
- --realm=\${TURN_REALM} - --realm=${MM_REALM:-localhost}
- --min-port=49153 - --min-port=49153
- --max-port=49352 - --max-port=49352
- --no-tls - --no-tls
@@ -270,171 +307,100 @@ services:
- --no-cli - --no-cli
- --no-multicast-peers - --no-multicast-peers
- --log-file=stdout - --log-file=stdout
restart: unless-stopped
networks: networks:
default:
caddy_net: caddy_net:
external: true external: true
name: \${CADDY_NET:-caddy_net} name: ${CADDY_NET:-caddy_net}
COMPOSE EOF
# ── .env ────────────────────────────────────────────────────────────────── cat > .env << EOF
cat > .env << ENV TZ=$TZ_VAL
# Mattermost — environment configuration
# Edit and restart: docker compose down && docker compose up -d
# PostgreSQL password (do not change after first start without migrating data)
DB_PASS=$DB_PASS
# Mattermost secret key (used for signing session tokens)
MM_SECRET=$MM_SECRET
# Site URL — must match the public URL clients use to access Mattermost
MATTERMOST_SITE_URL=$SITE_URL
# Timezone
TZ=$SITE_TZ
# TURN server shared secret for Mattermost Calls plugin
# Generate a new one: openssl rand -hex 32
TURN_SECRET=$TURN_SECRET
# TURN realm (typically your domain)
TURN_REALM=${SITE_DOMAIN:-localhost}
# Caddy network name
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
ENV
# PostgreSQL
POSTGRES_DB=mattermost
POSTGRES_USER=mattermost
POSTGRES_PASSWORD=$DB_PASS
# Mattermost
MM_SQLSETTINGS_DRIVERNAME=postgres
MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10
MM_SERVICESETTINGS_SITEURL=$SITE_URL
MM_SERVICESETTINGS_ENABLELOCALMODE=true
MM_FILESETTINGS_DRIVERNAME=local
MM_PLUGINSETTINGS_ENABLE=true
# coturn HMAC secret for Mattermost Calls plugin
COTURN_SECRET=$MM_SECRET
MM_REALM=${SITE_DOMAIN:-localhost}
# PUID/PGID for file ownership
PUID=$UID_VAL
PGID=$GID_VAL
EOF
chmod 600 .env chmod 600 .env
chown "$ACTUAL_USER:$ACTUAL_USER" .env
# ── UFW firewall rules ───────────────────────────────────────────────────── mkdir -p data logs config plugins db
echo "" chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
log_info "Firewall — Mattermost coturn uses port 3479 (avoiding conflict with Easy Asterisk on 3478)."
if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then # Open required firewall ports
log_info "Opening UFW ports for Mattermost..." if command -v ufw &>/dev/null; then
ufw allow 8443/udp comment "Mattermost Calls RTC server" >/dev/null ufw allow 8443/udp comment "Mattermost Calls RTC"
ufw allow 3479/udp comment "Mattermost coturn STUN/TURN" >/dev/null ufw allow 3479/udp; ufw allow 3479/tcp
ufw allow 3479/tcp comment "Mattermost coturn STUN/TURN" >/dev/null ufw allow 49153:49352/udp comment "Mattermost coturn relay"
ufw allow 49153:49352/udp comment "Mattermost coturn relay" >/dev/null
log_success "UFW rules added"
else
log_info "UFW not active — add these rules manually if needed:"
echo " ufw allow 8443/udp # Mattermost Calls RTC"
echo " ufw allow 3479/udp && ufw allow 3479/tcp # coturn STUN/TURN"
echo " ufw allow 49153:49352/udp # coturn relay"
fi fi
# ── Router port-forward instructions ──────────────────────────────────────
echo ""
echo " ┌─────────────────────────────────────────────────────────────────┐"
echo " │ Router port-forwards needed for Mattermost Calls (external) │"
echo " ├──────────────────┬──────────┬──────────────────────────────────┤"
echo " │ Port(s) │ Protocol │ Service │"
echo " ├──────────────────┼──────────┼──────────────────────────────────┤"
echo " │ 8443 │ UDP │ Calls plugin RTC (direct WebRTC) │"
echo " │ 3479 │ UDP+TCP │ coturn STUN/TURN │"
echo " │ 4915349352 │ UDP │ coturn relay range │"
echo " └──────────────────┴──────────┴──────────────────────────────────┘"
echo ""
echo " ⚠ WebRTC (Calls) requires HTTPS. Calls will not work if Mattermost"
echo " is accessed over plain HTTP. Configure Caddy with a domain below."
echo "" echo ""
log_success "Mattermost configured at $DIR"
ensure_docker_dir_ownership "$DIR" configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost"
# ── Caddy reverse proxy ───────────────────────────────────────────────────
# SITEURL is already set from SITE_DOMAIN above. configure_caddy_for_service
# will pre-fill the domain prompt with chat.$SITE_DOMAIN.
configure_caddy_for_service "Mattermost" "mattermost:8065" "chat"
# ── README ────────────────────────────────────────────────────────────────
write_readme "$DIR" << MD write_readme "$DIR" << MD
# Mattermost # Mattermost
Team messaging platform with voice/video calls via the Calls plugin and self-hosted coturn TURN server. Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay.
## Access ## Access
- Direct: http://localhost:8065 - URL: $SITE_URL (or http://localhost:8065)
- Via Caddy: see your configured domain (e.g. https://chat.${SITE_DOMAIN:-example.com}) - First run: create admin account at the URL above
## Initial admin setup ## Voice/Video Calls (Calls plugin)
Visit: \`${SITE_URL}/signup_user_complete\` Port 8443/udp must be open on your router/firewall.
coturn relay runs on port 3479 (HMAC secret in .env).
The first user to sign up becomes the System Admin. Configure in Mattermost: System Console → Plugins → Calls:
- TURN Server URI: turn:YOUR_DOMAIN_OR_IP:3479?transport=udp
## Calls plugin (voice/video) - TURN Credentials: use static-auth-secret (see .env COTURN_SECRET)
The Mattermost Calls plugin provides voice/video channels.
**WebRTC requires HTTPS** — calls will not work over plain HTTP.
### Enable the plugin
1. Go to **System Console → Plugins → Plugin Management**
2. Enable the **Calls** plugin (pre-installed in Team Edition)
### Configure ICE / TURN server
1. Go to **System Console → Plugins → Calls**
2. Set **RTC Server Address**: your server's public IP or domain
3. Set **TURN server URL**: \`turn:<your-server-or-ip>:3479\`
4. Set **TURN credentials type**: Static credentials (auth secret)
5. Set **TURN static auth secret**: (see \`TURN_SECRET\` in \`$DIR/.env\`)
6. Save and test a call in a channel
Direct WebRTC (port 8443/UDP) is tried first; coturn relay is the fallback
for clients behind strict NAT (cellular, hotel WiFi, Proton VPN, etc.).
## Router port-forwards (for external calls)
| Port(s) | Protocol | Service |
|--------------|-----------|---------------------------------|
| 8443 | UDP | Calls plugin RTC (direct path) |
| 3479 | UDP+TCP | coturn STUN/TURN |
| 4915349352 | UDP | coturn relay range |
## Manage ## Manage
\`\`\`bash \`\`\`bash
cd $DIR docker compose up -d
docker compose up -d # start docker compose down
docker compose down # stop docker compose logs -f
docker compose logs -f # all logs docker compose pull && docker compose up -d
docker compose logs -f mattermost # app logs only
docker compose logs -f coturn # TURN server logs
docker compose pull && docker compose up -d # update images
\`\`\` \`\`\`
## Backup
Important paths to back up:
- \`$DIR/data/\` — uploaded files and attachments
- \`$DIR/config/\` — server configuration
- \`$DIR/plugins/\` — installed plugins
- \`$DIR/db/\` — PostgreSQL data directory
- \`$DIR/.env\` — secrets and configuration
## Configuration
Main config file: \`$DIR/config/config.json\` (created on first start).
Environment variables in \`.env\` override config.json values.
After editing .env: \`docker compose down && docker compose up -d\`
MD MD
# ── Start ────────────────────────────────────────────────────────────────── if [[ "$SITE_URL" == http://* ]]; then
echo "" log_warning "WebRTC (voice/video calls) requires HTTPS. Configure Caddy and update SITE_URL."
fi
local START="" local START=""
prompt_yn "Start Mattermost now? (y/n):" "y" START prompt_yn "Start Mattermost now? (y/n):" "y" START
if [[ "$START" =~ ^[Yy]$ ]]; then if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
log_info "Pulling images and starting Mattermost (first start may take a minute)..." docker compose up -d \
if docker compose pull 2>&1 | tail -3 && docker compose up -d; then && log_success "Mattermost started" \
log_success "Mattermost started" || log_warning "Start failed — check: docker compose logs"
echo ""
echo " App: http://localhost:8065"
echo " Admin setup: ${SITE_URL}/signup_user_complete"
echo ""
log_info "Enable the Calls plugin and configure TURN at:"
log_info " System Console → Plugins → Calls"
log_info " TURN URL: turn:<your-public-ip>:3479"
log_info " TURN secret: (see $DIR/.env → TURN_SECRET)"
else
log_warning "Start failed — check: docker compose logs"
fi
fi 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 "" echo ""
} }
+109 -121
View File
@@ -2,10 +2,6 @@
# services/nextcloud.sh — Self-hosted cloud storage with SMB/local file access (Nextcloud). # services/nextcloud.sh — Self-hosted cloud storage with SMB/local file access (Nextcloud).
# Part of the modular post-install system (sourced by setup.sh). # Part of the modular post-install system (sourced by setup.sh).
# #
# Uses a custom Dockerfile (nextcloud:apache + smbclient) so SMB external storage
# works without AIO. All data uses bind mounts under ~/docker/nextcloud/ so that
# Kopia/Borg backup scripts cover everything automatically.
#
# Can also be run standalone on any machine: # Can also be run standalone on any machine:
# sudo bash nextcloud.sh # sudo bash nextcloud.sh
# (Docker must already be installed when run standalone) # (Docker must already be installed when run standalone)
@@ -25,7 +21,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
# shellcheck source=../lib/common.sh # shellcheck source=../lib/common.sh
source "$_COMMON" source "$_COMMON"
else else
# One-off copy — inline minimal stubs # One-off copy — inline minimal stubs so the script works without the repo
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
@@ -53,6 +49,13 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" 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 # Match common.sh's eval-based pattern so local vars in install_* are set correctly
prompt_text() { prompt_text() {
local _q="$1" _def="$2" _var="$3" _r local _q="$1" _def="$2" _var="$3" _r
@@ -73,8 +76,23 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
local _caddy_dir="$DOCKER_DIR/caddy" local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile" 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 if [[ ! -d "$_caddy_dir" ]]; then
log_info "Access $_name directly on port 8080." log_info "Access $_name directly on port ${_upstream##*:}."
return 0 return 0
fi fi
@@ -82,7 +100,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
local _do_caddy="" local _do_caddy=""
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
[[ "${_do_caddy,,}" == "y" ]] || { [[ "${_do_caddy,,}" == "y" ]] || {
log_info "Skipping — access at: http://localhost:8080" log_info "Skipping — access at: http://localhost:${_upstream##*:}"
return 0 return 0
} }
@@ -138,12 +156,6 @@ CBLOCK
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi fi
} }
write_readme() {
local _dir="$1"; shift
mkdir -p "$_dir"
cat > "$_dir/README.md"
}
fi fi
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
@@ -170,38 +182,32 @@ install_nextcloud() {
local DIR="$DOCKER_DIR/nextcloud" local DIR="$DOCKER_DIR/nextcloud"
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with:" echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env"
echo "[DRY-RUN] Dockerfile (nextcloud:apache + smbclient)"
echo "[DRY-RUN] docker-compose.yml (nextcloud + mariadb:10.11)"
echo "[DRY-RUN] .env with generated DB and admin passwords"
echo "[DRY-RUN] Bind-mount directories: html/ db/ config/ custom_apps/"
echo "[DRY-RUN] Would expose Nextcloud on port 8080"
echo "[DRY-RUN] Would enable files_external app via occ after deploy"
return 0 return 0
fi fi
mkdir -p "$DIR/html" "$DIR/db" "$DIR/config" "$DIR/custom_apps" mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR" ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1 cd "$DIR" || return 1
local DB_PASS NC_ADMIN_PASS TZ_VAL local DB_PASS
DB_PASS=$(generate_password 32) DB_PASS=$(generate_password 32)
NC_ADMIN_PASS=$(generate_password 24) local NC_ADMIN_PASS
TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" NC_ADMIN_PASS=$(generate_password 16)
local TZ_VAL="${SITE_TZ:-UTC}"
# ── Dockerfile — adds SMB support to the official apache image ──────────── # ── Dockerfile ──────────────────────────────────────────────────────────
cat > Dockerfile << 'DOCKERFILE' cat > Dockerfile << 'NCDF'
FROM nextcloud:apache FROM nextcloud:apache
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends procps smbclient \ && apt-get install -y --no-install-recommends procps smbclient \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
DOCKERFILE NCDF
# ── docker-compose.yml — single-quoted EOF prevents variable expansion ──── # ── docker-compose.yml ──────────────────────────────────────────────────
cat > docker-compose.yml << 'EOF' cat > docker-compose.yml << 'NCCOMPOSE'
name: nextcloud name: nextcloud
services: services:
nextcloud: nextcloud:
build: . build: .
@@ -235,132 +241,114 @@ networks:
caddy_net: caddy_net:
external: true external: true
name: ${CADDY_NET:-caddy_net} name: ${CADDY_NET:-caddy_net}
EOF NCCOMPOSE
# ── .env — actual variable values (NOT inside the compose heredoc) ──────── # ── .env ────────────────────────────────────────────────────────────────
cat > .env << NC_ENV cat > .env << NCENV
# ── Timezone & network ────────────────────────────────────────────────────────
TZ=$TZ_VAL TZ=$TZ_VAL
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
# ── MariaDB ─────────────────────────────────────────────────────────────────── # MariaDB
MYSQL_ROOT_PASSWORD=$DB_PASS MYSQL_ROOT_PASSWORD=$DB_PASS
MYSQL_DATABASE=nextcloud MYSQL_DATABASE=nextcloud
MYSQL_USER=nextcloud MYSQL_USER=nextcloud
MYSQL_PASSWORD=$DB_PASS MYSQL_PASSWORD=$DB_PASS
MARIADB_AUTO_UPGRADE=1 MARIADB_AUTO_UPGRADE=1
# ── Nextcloud bootstrap ─────────────────────────────────────────────────────── # Nextcloud bootstrap (first run only)
# These are used only on the very first startup to create the admin account
# and wire up the database. They are ignored on subsequent startups.
NEXTCLOUD_ADMIN_USER=admin NEXTCLOUD_ADMIN_USER=admin
NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS
NEXTCLOUD_DB_TYPE=mysql NEXTCLOUD_DB_TYPE=mysql
MYSQL_HOST=db MYSQL_HOST=db
# ── Reverse proxy trust (required when behind Caddy) ───────────────────────── # Reverse proxy (required for correct share links and redirects behind Caddy)
# Without these, share links use http:// and internal redirects may break.
OVERWRITEPROTOCOL=https OVERWRITEPROTOCOL=https
OVERWRITECLIURL=https://cloud.${SITE_DOMAIN:-example.com} OVERWRITECLIURL=https://cloud.${SITE_DOMAIN:-example.com}
TRUSTED_PROXIES=172.16.0.0/12 TRUSTED_PROXIES=172.16.0.0/12
NC_ENV NCENV
chmod 600 .env chmod 600 .env
# ── Subdirectories ──────────────────────────────────────────────────────
mkdir -p html config custom_apps db
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
echo ""
log_success "Nextcloud configured at $DIR" log_success "Nextcloud configured at $DIR"
configure_caddy_for_service "Nextcloud" "nextcloud:80" "cloud" configure_caddy_for_service "Nextcloud" "nextcloud:80" "cloud"
write_readme "$DIR" << MD # ── Prompt to start ─────────────────────────────────────────────────────
# Nextcloud
Self-hosted cloud storage — files, contacts, calendar, notes, and more.
SMB/local external storage is enabled via a custom Docker image (nextcloud:apache + smbclient).
## Access
- URL: http://localhost:8080
- Admin user: \`admin\`
- Admin password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`.env\`
## Directory layout (all bind-mounted — covered by Kopia/Borg backups)
\`\`\`
$DIR/
html/ # Nextcloud web root (PHP app + uploaded files)
config/ # config.php and other Nextcloud config files
custom_apps/ # manually installed apps not shipped with Nextcloud
db/ # MariaDB data directory
Dockerfile # custom image definition (adds smbclient)
docker-compose.yml
.env # secrets — chmod 600
\`\`\`
## External Storage (SMB / local paths)
The \`files_external\` app is enabled automatically during setup.
Add mounts in the Nextcloud web UI:
**Admin → Administration → External Storage**
Supported backends: Local, SMB/CIFS, FTP, S3, WebDAV, and more.
## Manage
\`\`\`bash
cd $DIR
docker compose up -d # start
docker compose down # stop
docker compose logs -f # logs
docker compose build --pull && docker compose up -d # rebuild image + update
docker exec --user www-data nextcloud php occ list # occ CLI
\`\`\`
## Backup note
All data lives under \`$DIR/\` as bind mounts.
Include this directory in your Kopia/Borg backup policy.
Run \`docker compose down\` before a cold backup of \`db/\` for consistency,
or use \`mysqldump\` for a hot backup:
\`\`\`bash
docker exec nextcloud-db mysqldump -u nextcloud -p\$MYSQL_PASSWORD nextcloud > nextcloud_db.sql
\`\`\`
MD
local START_NC="" local START_NC=""
prompt_yn "Start Nextcloud now? (y/n):" "y" START_NC prompt_yn "Start Nextcloud now? (y/n):" "y" START_NC
if [ "$START_NC" = "y" ] || [ "$START_NC" = "Y" ]; then if [ "$START_NC" = "y" ] || [ "$START_NC" = "Y" ]; then
docker compose up -d \ docker compose up -d --build \
&& log_success "Nextcloud started — first boot may take 1-2 minutes" \ && log_success "Nextcloud started" \
|| { log_warning "Start failed — check: docker compose logs"; return 1; } || { log_warning "Start failed — check: docker compose logs"; return 1; }
# Wait for Nextcloud to finish first-boot initialisation before running occ # ── Wait for occ and enable files_external ──────────────────────────
log_info "Waiting for Nextcloud to finish initialising (up to 90 s)..." log_info "Waiting for Nextcloud to initialize (up to 90s)..."
local _waited=0 local _wait=0
until docker exec --user www-data nextcloud php occ status --output=json 2>/dev/null \ until docker exec nextcloud php occ status 2>/dev/null | grep -q "installed: true"; do
| grep -q '"installed":true'; do sleep 5; _wait=$((_wait+5))
sleep 5 [ $_wait -ge 90 ] && { log_warning "Nextcloud not ready after 90s — enable files_external manually"; break; }
_waited=$(( _waited + 5 ))
if (( _waited >= 90 )); then
log_warning "Nextcloud did not finish initialising within 90 s."
log_warning "Run the occ command manually once the container is ready:"
log_warning " docker exec --user www-data nextcloud php occ app:enable files_external"
break
fi
done 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
if (( _waited < 90 )); then # ── README ───────────────────────────────────────────────────────────────
if docker exec --user www-data nextcloud php occ app:enable files_external; then write_readme "$DIR" << NCREADME
log_success "External Storage app enabled" # Nextcloud
else
log_warning "Could not enable files_external — run manually:" Self-hosted cloud storage with SMB/local file access.
log_warning " docker exec --user www-data nextcloud php occ app:enable files_external"
fi ## Access
fi
fi - 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 <command>
\`\`\`
## 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 ""
echo " URL: http://localhost:8080" echo " Access URL: http://localhost:8080"
echo " Admin user: admin" echo " Admin user: admin"
echo " Admin password: $NC_ADMIN_PASS" echo " Admin pass: $NC_ADMIN_PASS"
echo " (Credentials also saved to $DIR/.env)" echo " Config dir: $DIR"
echo "" echo ""
echo " To add SMB or local external storage:" echo " Note: First startup may take 1-2 minutes while Nextcloud initialises."
echo " Nextcloud → Admin → Administration → External Storage"
echo "" echo ""
} }
+155 -137
View File
@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server. # services/onlyoffice.sh — Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser).
# Part of the modular post-install system (sourced by setup.sh). # Part of the modular post-install system (sourced by setup.sh).
# #
# Can also be run standalone on any machine: # Can also be run standalone on any machine:
@@ -7,6 +7,9 @@
# (Docker must already be installed when run standalone) # (Docker must already be installed when run standalone)
# ── Standalone bootstrap ────────────────────────────────────────────────────── # ── 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 if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; } [[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
@@ -14,9 +17,11 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
_COMMON="$_SELF_DIR/../lib/common.sh" _COMMON="$_SELF_DIR/../lib/common.sh"
if [[ -f "$_COMMON" ]]; then if [[ -f "$_COMMON" ]]; then
# Full repo present — use the real helpers (picks up ~/docker/.config too)
# shellcheck source=../lib/common.sh # shellcheck source=../lib/common.sh
source "$_COMMON" source "$_COMMON"
else 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_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
@@ -39,6 +44,19 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true 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() { prompt_text() {
local _q="$1" _def="$2" _var="$3" _r local _q="$1" _def="$2" _var="$3" _r
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
@@ -58,6 +76,21 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
local _caddy_dir="$DOCKER_DIR/caddy" local _caddy_dir="$DOCKER_DIR/caddy"
local _caddyfile="$_caddy_dir/Caddyfile" 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 if [[ ! -d "$_caddy_dir" ]]; then
log_info "Access $_name directly on port ${_upstream##*:}." log_info "Access $_name directly on port ${_upstream##*:}."
return 0 return 0
@@ -75,6 +108,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
# Back up before touching
if [[ -f "$_caddyfile" ]]; then if [[ -f "$_caddyfile" ]]; then
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
cp "$_caddyfile" "$_bk" cp "$_caddyfile" "$_bk"
@@ -83,6 +117,7 @@ if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
touch "$_caddyfile" touch "$_caddyfile"
fi fi
# Remove existing block for this domain if present
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
log_warning "$_domain already in Caddyfile" log_warning "$_domain already in Caddyfile"
local _ow="" local _ow=""
@@ -121,19 +156,10 @@ CBLOCK
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
fi 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 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_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
@@ -143,129 +169,83 @@ CBLOCK
SITE_DOMAIN="${SITE_DOMAIN:-example.com}" SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
register_service() { :; } register_service() { :; } # no-op — no wizard to register into
_RUN_STANDALONE=1 _RUN_STANDALONE=1
fi fi
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082 register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082
# ── Ensure yq v4 is installed ───────────────────────────────────────────────── # ── Helper: install yq v4 if absent ──────────────────────────────────────────
_ensure_yq() { _ensure_yq() {
if command -v yq &>/dev/null; then command -v yq &>/dev/null && return 0
local major log_info "Installing yq (required for FileBrowser config patching)..."
major=$(yq --version 2>&1 | grep -oP '(?<=v)\d+' | head -1 || echo 0) local _arch; _arch=$(uname -m)
[[ "$major" -ge 4 ]] && return 0 local _binary="yq_linux_amd64"
log_info "yq found but version < 4 — reinstalling..." [[ "$_arch" == "aarch64" || "$_arch" == "arm64" ]] && _binary="yq_linux_arm64"
else curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/${_binary}" \
log_info "yq not found — installing..." -o /usr/local/bin/yq && chmod +x /usr/local/bin/yq \
fi && log_success "yq installed" || log_warning "yq install failed — FileBrowser wiring skipped"
local arch
arch=$(uname -m)
local yq_bin="yq_linux_amd64"
[[ "$arch" == "aarch64" || "$arch" == "arm64" ]] && yq_bin="yq_linux_arm64"
if wget -qO /usr/local/bin/yq \
"https://github.com/mikefarah/yq/releases/latest/download/${yq_bin}" \
&& chmod +x /usr/local/bin/yq; then
log_success "yq installed ($(yq --version 2>&1 | head -1))"
else
log_warning "Could not install yq — FileBrowser config.yaml will need manual update"
return 1
fi
} }
# ── Wire OnlyOffice into Nextcloud ──────────────────────────────────────────── # ── Helper: wire OnlyOffice into Nextcloud (idempotent) ───────────────────────
_wire_nextcloud() { _wire_nextcloud() {
local jwt_secret="$1" local _jwt="$1"
local nc_dir="$DOCKER_DIR/nextcloud" local _nc_container="nextcloud"
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${_nc_container}$" || {
[[ -d "$nc_dir" ]] || return 0 log_info "Nextcloud container not running — skipping Nextcloud wiring"
log_info "Nextcloud detected — wiring OnlyOffice integration..."
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^nextcloud$"; then
log_warning "Nextcloud container not running — skipping occ wiring."
log_info " Start Nextcloud and re-run: sudo bash $0"
return 0 return 0
fi }
log_info "Wiring OnlyOffice into Nextcloud..."
docker exec --user www-data nextcloud php occ app:enable onlyoffice \ docker exec "$_nc_container" php occ app:enable onlyoffice 2>/dev/null || true
&& log_success "OnlyOffice app enabled in Nextcloud" \ docker exec "$_nc_container" php occ config:system:set onlyoffice DocumentServerUrl \
|| log_warning "app:enable failed — may already be enabled" --value="https://office.${SITE_DOMAIN:-example.com}/" 2>/dev/null \
docker exec --user www-data nextcloud php occ \ && log_success "DocumentServerUrl set" || log_warning "Could not set DocumentServerUrl"
config:app:set onlyoffice DocumentServerUrl \ docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_secret \
--value "http://onlyoffice:80/" \ --value="$_jwt" 2>/dev/null \
&& log_success "DocumentServerUrl → http://onlyoffice:80/" \ && log_success "jwt_secret set" || log_warning "Could not set jwt_secret"
|| log_warning "Could not set DocumentServerUrl" docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_header \
docker exec --user www-data nextcloud php occ \ --value="AuthorizationJwt" 2>/dev/null \
config:app:set onlyoffice jwt_secret \ && log_success "jwt_header set" || log_warning "Could not set jwt_header"
--value "$jwt_secret" \
&& log_success "jwt_secret set" \
|| log_warning "Could not set jwt_secret"
docker exec --user www-data nextcloud php occ \
config:app:set onlyoffice jwt_header \
--value "AuthorizationJwt" \
&& log_success "jwt_header set" \
|| log_warning "Could not set jwt_header"
} }
# ── Wire OnlyOffice into FileBrowser Quantum ────────────────────────────────── # ── Helper: patch FileBrowser Quantum config.yaml with OnlyOffice endpoint ───
_wire_filebrowser() { _wire_filebrowser() {
local fb_config="$DOCKER_DIR/filebrowser/data/config.yaml" local _fbq_config="$DOCKER_DIR/filebrowser/config.yaml"
[[ -f "$_fbq_config" ]] || { log_info "FileBrowser config not found — skipping"; return 0; }
[[ -f "$fb_config" ]] || return 0 command -v yq &>/dev/null || { log_info "yq not found — skipping FileBrowser wiring"; return 0; }
log_info "Wiring OnlyOffice into FileBrowser Quantum..."
log_info "FileBrowser Quantum detected — updating config.yaml..." yq e '.officeServer = "http://onlyoffice:80/"' -i "$_fbq_config" \
&& log_success "FileBrowser officeServer set" || log_warning "Could not patch FileBrowser config"
if ! _ensure_yq; then docker restart filebrowser 2>/dev/null && log_success "FileBrowser restarted" || true
log_info "Set officeServer manually in $fb_config:"
log_info " officeServer: \"http://onlyoffice:80/\""
return 0
fi
yq e -i '.officeServer = "http://onlyoffice:80/"' "$fb_config" \
&& log_success "FileBrowser config.yaml: officeServer → http://onlyoffice:80/" \
|| log_warning "yq failed — set officeServer manually in $fb_config"
if docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^filebrowser$"; then
docker restart filebrowser >/dev/null 2>&1 \
&& log_info "FileBrowser restarted to pick up config change" \
|| log_warning "Could not restart FileBrowser container"
fi
} }
install_onlyoffice() { install_onlyoffice() {
require_docker || return 1 require_docker || return 1
log_info "Installing OnlyOffice Document Server..." _ensure_yq
log_info "Installing OnlyOffice Document Server..."
local DIR="$DOCKER_DIR/onlyoffice" local DIR="$DOCKER_DIR/onlyoffice"
if [ "$DRY_RUN" = true ]; then if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml and .env" echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env"
echo "[DRY-RUN] Would deploy onlyoffice/documentserver:latest on port 8082"
echo "[DRY-RUN] Would install yq if missing"
echo "[DRY-RUN] Would wire OnlyOffice into Nextcloud (if running)"
echo "[DRY-RUN] Would wire OnlyOffice into FileBrowser Quantum (if present)"
return 0 return 0
fi fi
# Always install yq — needed for FBQ config patching
_ensure_yq || true
mkdir -p "$DIR" mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR" ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1 cd "$DIR" || return 1
# Generate JWT secret (or read existing one so re-runs don't rotate it) # ── Preserve JWT secret across re-runs ──────────────────────────────────
local JWT_SECRET="" local JWT_SECRET=""
if [[ -f "$DIR/.env" ]]; then if [[ -f "$DIR/.env" ]]; then
JWT_SECRET=$(grep "^JWT_SECRET=" "$DIR/.env" 2>/dev/null | cut -d= -f2-) JWT_SECRET=$(grep "^JWT_SECRET=" "$DIR/.env" 2>/dev/null | cut -d= -f2-)
fi fi
[[ -z "$JWT_SECRET" ]] && JWT_SECRET="$(generate_password 32)" [[ -z "$JWT_SECRET" ]] && JWT_SECRET="$(generate_password 32)"
cat > docker-compose.yml << 'OO_COMPOSE' # ── docker-compose.yml ──────────────────────────────────────────────────
cat > docker-compose.yml << 'OOCOMPOSE'
name: onlyoffice name: onlyoffice
services: services:
onlyoffice: onlyoffice:
image: onlyoffice/documentserver:latest image: onlyoffice/documentserver:latest
@@ -273,6 +253,10 @@ services:
hostname: onlyoffice hostname: onlyoffice
restart: unless-stopped restart: unless-stopped
env_file: .env env_file: .env
volumes:
- ./logs:/var/log/onlyoffice
- ./data:/var/www/onlyoffice/Data
- ./fonts:/usr/share/fonts/truetype/custom
ports: ports:
- "8082:80" - "8082:80"
networks: networks:
@@ -282,81 +266,115 @@ networks:
caddy_net: caddy_net:
external: true external: true
name: ${CADDY_NET:-caddy_net} name: ${CADDY_NET:-caddy_net}
OO_COMPOSE OOCOMPOSE
cat > .env << OO_ENV # ── .env ────────────────────────────────────────────────────────────────
# OnlyOffice Document Server — environment cat > .env << OOENV
CADDY_NET=$SITE_CADDY_NET CADDY_NET=$SITE_CADDY_NET
# JWT authentication — keep JWT_SECRET private # JWT authentication — keep JWT_SECRET private
# If you rotate it, update Nextcloud (occ config:app:set onlyoffice jwt_secret)
# and any other integration that uses this server
JWT_ENABLED=true JWT_ENABLED=true
JWT_SECRET=$JWT_SECRET JWT_SECRET=$JWT_SECRET
JWT_HEADER=AuthorizationJwt JWT_HEADER=AuthorizationJwt
OO_ENV OOENV
chmod 600 .env chmod 600 .env
# ── Subdirectories ──────────────────────────────────────────────────────
mkdir -p logs data fonts
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
# OnlyOffice must be embeddable as an iframe in Nextcloud/FileBrowser. echo ""
# Override X-Frame-Options to allow same-site embedding (remove SAMEORIGIN restriction). 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 { local OO_EXTRA_BLOCK=' header {
-X-Frame-Options -X-Frame-Options
Content-Security-Policy "frame-ancestors '\''self'\'' *" Content-Security-Policy "frame-ancestors '\''self'\'' *"
}' }'
configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" "$OO_EXTRA_BLOCK" configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" "$OO_EXTRA_BLOCK"
local START="" # ── Prompt to start ─────────────────────────────────────────────────────
prompt_yn "Start OnlyOffice now? (y/n):" "y" START local START_OO=""
if [[ "$START" =~ ^[Yy]$ ]]; then prompt_yn "Start OnlyOffice now? (y/n):" "y" START_OO
if [ "$START_OO" = "y" ] || [ "$START_OO" = "Y" ]; then
docker compose up -d \ docker compose up -d \
&& log_success "OnlyOffice started" \ && log_success "OnlyOffice started" \
|| log_warning "Start failed — check: docker compose logs" || log_warning "Start failed — check: docker compose logs"
fi fi
# Wire into integrations every run (idempotent) # ── Wire integrations (runs every install/re-install) ───────────────────
echo "" echo ""
_wire_nextcloud "$JWT_SECRET" _wire_nextcloud "$JWT_SECRET"
_wire_filebrowser _wire_filebrowser
write_readme "$DIR" << MD # ── README ───────────────────────────────────────────────────────────────
write_readme "$DIR" << OOREAD
# OnlyOffice Document Server # OnlyOffice Document Server
Self-hosted collaborative editing for DOCX, XLSX, PPTX, and ODT files. Self-hosted document editing server, integrated with Nextcloud and FileBrowser Quantum.
Integrates with Nextcloud and FileBrowser Quantum.
Port: 8082 (internal 80)
## JWT Secret ## Access
Stored in \`.env\` (chmod 600). If you rotate it:
1. Update \`JWT_SECRET\` in \`.env\`
2. Re-run the installer to re-wire all integrations: \`sudo bash services/onlyoffice.sh\`
## Verify integrations - URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082)
\`\`\`bash - The document server itself has no user-facing login page — it is accessed
# Nextcloud through Nextcloud or FileBrowser Quantum.
docker exec --user www-data nextcloud php occ config:app:get onlyoffice DocumentServerUrl
docker exec --user www-data nextcloud php occ config:app:get onlyoffice jwt_secret
# FileBrowser Quantum
grep officeServer ~/docker/filebrowser/data/config.yaml
\`\`\`
## Manage ## Manage
\`\`\`bash \`\`\`bash
cd $DIR
docker compose up -d # start docker compose up -d # start
docker compose down # stop docker compose down # stop
docker compose logs -f # logs docker compose logs -f # follow logs
docker compose pull && docker compose up -d # update docker compose pull && docker compose up -d # update
\`\`\` \`\`\`
MD
log_success "OnlyOffice installed at $DIR" ## JWT secret rotation
1. Generate a new secret:
\`\`\`bash
openssl rand -hex 24
\`\`\`
2. Update \`JWT_SECRET\` in \`$DIR/.env\`
3. Restart OnlyOffice:
\`\`\`bash
docker compose restart
\`\`\`
4. Update Nextcloud's stored secret:
\`\`\`bash
docker exec nextcloud php occ config:system:set onlyoffice jwt_secret --value="<new-secret>"
\`\`\`
## 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 ""
echo " Port: http://localhost:8082" echo " OnlyOffice Document Server"
echo " JWT Secret: $JWT_SECRET" echo " Access URL: http://localhost:8082"
echo " (Secret also saved to $DIR/.env)" 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 "" echo ""
} }
+20 -87
View File
@@ -1,99 +1,32 @@
# ================================================================ # ================================================================
# Easy Asterisk - Environment Configuration # Easy Asterisk Environment Configuration
# # Copy to .env and fill in your values.
# Setup:
# 1. cp .env.example .env
# 2. Set DOMAIN_NAME (the only required setting)
# 3. docker compose up -d
# 4. docker exec -it easy-asterisk easy-asterisk
#
# Port forwarding required on your router:
# 5061/tcp → SIP TLS signaling
# 3478/udp+tcp → STUN/TURN (NAT traversal + media relay)
# (change with TURN_PORT if 3478 is taken)
# 10000-20000/udp → RTP media (or your custom range below)
#
# How it works:
# - All SIP clients connect to DOMAIN_NAME:5061 (TLS)
# - coturn handles NAT traversal (STUN) and media relay (TURN)
# - Works from any network: LAN, cellular, Proton VPN, hotel WiFi
# - Set TURN_PASSWORD below (generate one: openssl rand -base64 18)
# ================================================================ # ================================================================
# ── Domain Name (REQUIRED) ──────────────────────────────────── # ── Domain (REQUIRED for remote/FQDN access) ──────────────────
# The FQDN that points to this server's public IP. # Your FQDN pointing to this server's public IP.
# This is what SIP clients use to connect. # Leave empty for LAN-only mode.
# Example: asterisk.yourdomain.com DOMAIN_NAME=asterisk.example.com
DOMAIN_NAME=
# ── Public IP ───────────────────────────────────────────────── # ── TURN/STUN ──────────────────────────────────────────────────
# Your server's public IP address. # Generate a strong password: openssl rand -base64 18
# Leave empty to auto-detect (uses ifconfig.me).
# Set manually if auto-detection fails (e.g., behind double NAT).
PUBLIC_IP=
# ── TLS ───────────────────────────────────────────────────────
# Always "y" for remote access. Self-signed certs are auto-generated.
# For trusted certs (no client warnings), mount your Let's Encrypt
# certs into /etc/asterisk/certs/ via docker compose volumes.
ENABLE_TLS=y
# ── Local Network ─────────────────────────────────────────────
# Your LAN CIDR. Auto-detected if empty.
# Example: 192.168.1.0/24
LOCAL_CIDR=
# ── Additional Subnets (optional) ─────────────────────────────
# Only needed for site-to-site VPNs or VLANs where the server
# has a direct route to client IPs (e.g., WireGuard, Tailscale).
#
# NOT needed for client-side VPNs (Proton, NordVPN, etc.)
# - Those clients appear with random public IPs
# - TURN handles media relay for them automatically
#
# Examples:
# WireGuard: VLAN_SUBNETS=10.8.0.0/24
# Tailscale: VLAN_SUBNETS=100.64.0.0/10
# Multiple: VLAN_SUBNETS=10.8.0.0/24 10.10.0.0/24
HAS_VLANS=n
VLAN_SUBNETS=
# ── TURN/STUN Settings ──────────────────────────────────────
# Used by coturn for TURN relay authentication.
# If empty, defaults to "changeme" — set a real password for security.
# Generate one with: openssl rand -base64 18
#
# These credentials are for coturn only. SIP clients that need TURN
# relay (behind strict NAT) must configure the same credentials in
# their SIP app settings.
TURN_USERNAME=easyasterisk TURN_USERNAME=easyasterisk
TURN_PASSWORD= TURN_PASSWORD=changeme
# ── TURN/STUN Port ──────────────────────────────────────────
# Default: 3478 (standard STUN/TURN port)
# Change if 3478 is already in use (e.g., UniFi controller uses 3478/udp).
# Common alternative: 3479
TURN_PORT=3478 TURN_PORT=3478
# Points to coturn. For LAN-only leave empty.
TURN_SERVER=${DOMAIN_NAME}:${TURN_PORT}
# ── TURN Relay Port Range ───────────────────────────────────── # ── RTP port range ─────────────────────────────────────────────
# Ports coturn uses for media relay. Forward this range on your router.
# Default is 100 ports (enough for ~50 simultaneous relayed calls).
# Most calls use direct paths; TURN relay is the fallback.
TURN_RELAY_MIN=49152
TURN_RELAY_MAX=49252
# ── RTP Port Range ────────────────────────────────────────────
# Asterisk's own RTP media ports. Forward this range on your router.
# Default: 10000-20000 (10,000 ports)
# For constrained environments: 10000-10200
RTP_START=10000 RTP_START=10000
RTP_END=20000 RTP_END=20000
# ── Web Admin ───────────────────────────────────────────────── # ── Web admin ─────────────────────────────────────────────────
# HTTP management interface. Access via browser at:
# http://your-server:8080/clients
#
# For HTTPS: put this behind Caddy or nginx reverse proxy,
# then set WEB_ADMIN_AUTH_DISABLED=true (let the proxy handle auth).
WEB_ADMIN_PORT=8080 WEB_ADMIN_PORT=8080
# Set to true if Authelia or another reverse proxy handles auth
WEB_ADMIN_AUTH_DISABLED=false WEB_ADMIN_AUTH_DISABLED=false
# ── Public IP (optional — auto-detected if empty) ─────────────
PUBLIC_IP=
# ── Local network CIDR (optional — auto-detected if empty) ────
LOCAL_CIDR=
+28 -71
View File
@@ -1,55 +1,33 @@
# ================================================================
# Easy Asterisk - Docker Container
# Asterisk PBX with web admin and optional STUN support
#
# Usage:
# docker compose up -d # Asterisk only
# docker compose --profile stun up -d # Asterisk + self-hosted STUN
# docker exec -it easy-asterisk easy-asterisk # Interactive management
# docker exec -it easy-asterisk vpn-diagnostics # VPN diagnostics
# docker exec -it easy-asterisk dns-whitelist # DNS whitelist check
# ================================================================
FROM ubuntu:24.04 FROM ubuntu:24.04
ENV DEBIAN_FRONTEND=noninteractive ENV LANG=en_US.UTF-8 \
ENV LANG=C.UTF-8 LANGUAGE=en_US:en \
LC_ALL=en_US.UTF-8 \
DEBIAN_FRONTEND=noninteractive
# Install Asterisk and all dependencies (matches install_asterisk_packages) RUN apt-get update && apt-get install -y --no-install-recommends \
RUN echo "exit 101" > /usr/sbin/policy-rc.d && chmod +x /usr/sbin/policy-rc.d && \
apt-get update && \
apt-get install -y --no-install-recommends \
asterisk \ asterisk \
asterisk-core-sounds-en-gsm \ asterisk-core-sounds-en \
asterisk-modules \ asterisk-core-sounds-en-wav \
ca-certificates \ asterisk-moh-opsound-wav \
openssl \
curl \
wget \
tcpdump \ tcpdump \
sngrep \ sngrep \
python3 \ curl \
dnsutils \
iproute2 \ iproute2 \
net-tools \ net-tools \
dnsutils \ openssl \
iputils-ping \ python3 \
procps \ python3-pip \
lsof \ python3-bcrypt \
&& rm -rf /var/lib/apt/lists/* \ locales \
&& rm -f /usr/sbin/policy-rc.d \ && locale-gen en_US.UTF-8 \
&& ldconfig \ && rm -rf /var/lib/apt/lists/*
&& update-ca-certificates 2>/dev/null || true
# NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04 due to # NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04
# a packaging bug (Launchpad #2044135). The Digium precompiled binary is ABI-incompatible. # due to a packaging bug. Opus pass-through still works via res_format_attr_opus.so.
# Opus pass-through (phone-to-phone) still works via res_format_attr_opus.so from
# asterisk-modules. Only Opus<->ulaw transcoding is missing, which is rarely needed
# since modern SIP phones all support the same codecs natively.
# Create required directories RUN mkdir -p /etc/asterisk/certs \
RUN mkdir -p \
/etc/easy-asterisk \
/etc/asterisk/certs \
/var/lib/asterisk/static-http \ /var/lib/asterisk/static-http \
/var/log/asterisk \ /var/log/asterisk \
/var/spool/asterisk \ /var/spool/asterisk \
@@ -61,43 +39,22 @@ RUN mkdir -p \
/var/spool/asterisk \ /var/spool/asterisk \
/var/run/asterisk /var/run/asterisk
# Docker detection marker (used by is_docker() in the script) # Management script and helpers
RUN touch /.dockerenv
# Copy the main management script
COPY easy-asterisk-v0.10.0.sh /usr/local/bin/easy-asterisk COPY easy-asterisk-v0.10.0.sh /usr/local/bin/easy-asterisk
RUN chmod +x /usr/local/bin/easy-asterisk
# Copy diagnostic and utility scripts
COPY scripts/vpn-diagnostics.sh /usr/local/bin/vpn-diagnostics COPY scripts/vpn-diagnostics.sh /usr/local/bin/vpn-diagnostics
COPY scripts/dns-whitelist.sh /usr/local/bin/dns-whitelist COPY scripts/dns-whitelist.sh /usr/local/bin/dns-whitelist
RUN chmod +x /usr/local/bin/vpn-diagnostics /usr/local/bin/dns-whitelist
# Copy entrypoint
COPY docker/entrypoint.sh /entrypoint.sh COPY docker/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh RUN chmod +x /usr/local/bin/easy-asterisk \
/usr/local/bin/vpn-diagnostics \
/usr/local/bin/dns-whitelist \
/entrypoint.sh
# SIP signaling EXPOSE 5060/udp 5060/tcp 5061/tcp
EXPOSE 5060/udp EXPOSE 8080/tcp 8088/tcp 8089/tcp
EXPOSE 5060/tcp
EXPOSE 5061/tcp
# Web admin + provisioning
EXPOSE 8080/tcp
EXPOSE 8088/tcp
EXPOSE 8089/tcp
# STUN (if running coturn in same container; default 3478, configurable via TURN_PORT)
EXPOSE 3478/udp EXPOSE 3478/udp
# RTP media range (use --network host in production for full range)
# Docker port-mapping 10000 ports is impractical; host networking recommended
EXPOSE 10000-10100/udp EXPOSE 10000-10100/udp
# Persistent data
VOLUME ["/etc/asterisk", "/etc/easy-asterisk", "/var/log/asterisk"]
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD asterisk -rx "core show version" >/dev/null 2>&1 || exit 1 CMD asterisk -rx "core show version" || exit 1
ENTRYPOINT ["/entrypoint.sh"] ENTRYPOINT ["/entrypoint.sh"]
+17 -19
View File
@@ -1,26 +1,24 @@
#!/bin/sh #!/bin/bash
# ================================================================ # coturn-entrypoint.sh — robust wrapper for the coturn Docker image.
# Robust coturn entrypoint
# #
# The coturn/coturn Docker image's native entrypoint uses: # The coturn image's default entrypoint uses:
# exec $(eval "echo $@") # exec $(eval "echo $@")
# which is fragile — if DETECT_EXTERNAL_IP's DNS lookup returns empty, # which fails when detect-external-ip returns empty — produces a blank token
# the eval produces an empty token → "ERROR: CONFIG: Unknown argument:" # and coturn logs "ERROR: CONFIG: Unknown argument:"
# #
# This wrapper reuses the image's detect-external-ip script but avoids # This wrapper avoids eval word-splitting and only adds --external-ip when
# the eval word-splitting issue. If detection fails, we simply omit # an IP is actually obtained.
# --external-ip rather than passing a blank argument.
# ================================================================
# Use explicit PUBLIC_IP if provided, otherwise auto-detect set -e
if [ -z "$PUBLIC_IP" ]; then
PUBLIC_IP=$(detect-external-ip 2>/dev/null || true) # 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 fi
# Only add --external-ip if we actually have an IP if [[ -n "$ext_ip" ]]; then
EXTERNAL_IP_ARG="" exec turnserver "$@" --external-ip="$ext_ip"
if [ -n "$PUBLIC_IP" ]; then else
EXTERNAL_IP_ARG="--external-ip=$PUBLIC_IP" exec turnserver "$@"
fi fi
exec turnserver "$@" $EXTERNAL_IP_ARG
Vendored Regular → Executable
View File
Vendored Regular → Executable
View File
Vendored Regular → Executable
+9 -234
View File
@@ -30,251 +30,26 @@ while [[ $# -gt 0 ]]; do
--linphone) SHOW_LINPHONE=true; SHOW_ALL=false; shift ;; --linphone) SHOW_LINPHONE=true; SHOW_ALL=false; shift ;;
--help|-h) --help|-h)
echo "Usage: dns-whitelist [OPTIONS]" echo "Usage: dns-whitelist [OPTIONS]"
echo ""
echo "Options:"
echo " --check Test reachability of each domain" echo " --check Test reachability of each domain"
echo " --sipnetic Show Sipnetic-specific domains" echo " --sipnetic Show Sipnetic-specific domains"
echo " --linphone Show Linphone-specific domains" echo " --linphone Show Linphone-specific domains"
echo " --help Show this help" exit 0 ;;
exit 0
;;
*) shift ;; *) shift ;;
esac esac
done done
print_header() {
echo ""
echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
}
check_dns() {
local domain="$1"
local port="$2"
local proto="${3:-tcp}"
if $CHECK_MODE; then
# DNS resolution test
if nslookup "$domain" >/dev/null 2>&1; then
echo -e " ${GREEN}✓ DNS resolves${NC}"
else
echo -e " ${RED}✗ DNS BLOCKED - add to whitelist${NC}"
return 1
fi
# Connectivity test
if [[ "$proto" == "udp" ]]; then
# UDP - just check DNS resolution (can't reliably test UDP connectivity)
echo -e " ${CYAN}→ UDP port ${port} (cannot test remotely)${NC}"
else
if curl -s --connect-timeout 5 "https://${domain}" >/dev/null 2>&1 || \
curl -s --connect-timeout 5 "http://${domain}" >/dev/null 2>&1; then
echo -e " ${GREEN}✓ Reachable${NC}"
else
echo -e " ${YELLOW}! Connection failed (may be expected)${NC}"
fi
fi
fi
}
# Load config if available
source "$CONFIG_FILE" 2>/dev/null || true source "$CONFIG_FILE" 2>/dev/null || true
print_header "DNS Whitelist for Easy Asterisk"
echo -e "${BOLD}Your Setup:${NC}"
if [[ -n "$DOMAIN_NAME" ]]; then
echo -e " Mode: FQDN/Internet (${DOMAIN_NAME})"
else
echo -e " Mode: LAN/VPN (no domain configured)"
fi
echo "" echo ""
echo -e "${CYAN}━━━ DNS Whitelist for Easy Asterisk ━━━${NC}"
# ══════════════════════════════════════════════════════════════
# SECTION 1: ASTERISK SERVER DOMAINS
# ══════════════════════════════════════════════════════════════
if $SHOW_ALL; then
echo -e "${BOLD}━━━ 1. ASTERISK SERVER (whitelist on server's DNS filter) ━━━${NC}"
echo "" echo ""
echo -e "${BOLD}Mode: ${NC}$( [[ -n "$DOMAIN_NAME" ]] && echo "FQDN ($DOMAIN_NAME)" || echo "LAN/VPN (no domain)" )"
echo -e "${BOLD}Required for LAN/VPN mode:${NC}"
echo -e " ${GREEN}None${NC} - Asterisk needs no internet after installation"
echo -e " SIP operates over direct IP connections, no DNS involved"
echo "" echo ""
echo -e "${BOLD}Server DNS filter:${NC}"
echo -e "${BOLD}Required for FQDN/Internet mode only:${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 ""
echo -e "${BOLD}Client DNS filter (Sipnetic/Linphone):${NC}"
echo -e " ${CYAN}ifconfig.me${NC} (HTTPS 443)" echo -e " LAN/VPN mode: none (configure by IP)"
echo -e " Purpose: Auto-detect public IP for NAT settings" echo -e " FQDN mode: your domain ($DOMAIN_NAME)"
echo -e " When: Only during config regeneration"
check_dns "ifconfig.me" "443"
echo "" echo ""
echo -e " ${CYAN}icanhazip.com${NC} (HTTPS 443)"
echo -e " Purpose: Fallback public IP detection"
check_dns "icanhazip.com" "443"
echo ""
echo -e "${BOLD}Required if ICE/STUN enabled:${NC}"
echo ""
# Check what STUN server is configured
stun_server=""
if [[ -f /etc/asterisk/rtp.conf ]]; then
stun_server=$(grep "^stunaddr=" /etc/asterisk/rtp.conf 2>/dev/null | cut -d= -f2)
fi
if [[ -n "$stun_server" ]]; then
stun_host=$(echo "$stun_server" | cut -d: -f1)
stun_port=$(echo "$stun_server" | cut -d: -f2)
stun_port="${stun_port:-3478}"
echo -e " ${CYAN}${stun_host}${NC} (UDP ${stun_port})"
echo -e " Purpose: STUN NAT discovery"
echo -e " ${YELLOW}Tip: Use self-hosted coturn to avoid this dependency${NC}"
check_dns "$stun_host" "$stun_port" "udp"
else
echo -e " ${GREEN}No external STUN server configured${NC}"
echo -e " To use self-hosted: docker compose --profile stun up -d"
fi
echo ""
echo -e "${BOLD}Required for package updates only:${NC}"
echo ""
echo -e " ${CYAN}archive.ubuntu.com${NC} / ${CYAN}security.ubuntu.com${NC} (HTTPS 443)"
echo -e " Purpose: apt package updates"
echo -e " When: Only during install/update (not runtime)"
echo ""
echo -e "${BOLD}Required for TLS certificates:${NC}"
echo ""
echo -e " ${CYAN}acme-v02.api.letsencrypt.org${NC} (HTTPS 443)"
echo -e " Purpose: Let's Encrypt certificate issuance"
echo -e " When: Only if using Let's Encrypt / Certbot / Caddy"
if $CHECK_MODE; then
check_dns "acme-v02.api.letsencrypt.org" "443"
fi
echo ""
fi
# ══════════════════════════════════════════════════════════════
# SECTION 2: SIPNETIC (Mobile Client) DOMAINS
# ══════════════════════════════════════════════════════════════
if $SHOW_ALL || $SHOW_SIPNETIC; then
echo -e "${BOLD}━━━ 2. SIPNETIC CLIENT (whitelist on caller/receiver DNS) ━━━${NC}"
echo ""
echo -e "${BOLD}Required for SIP calls:${NC}"
echo -e " ${GREEN}None${NC} - Configure Sipnetic with the server's IP address directly"
echo -e " SIP registration and calls use IP:port, not DNS"
echo ""
echo -e "${BOLD}Sipnetic app domains (for app functionality):${NC}"
echo ""
echo -e " ${CYAN}onesip.io${NC} / ${CYAN}api.onesip.io${NC}"
echo -e " Purpose: Sipnetic account/licensing (free tier works offline)"
echo -e " Required: Only for initial setup or account sync"
if $CHECK_MODE; then
check_dns "onesip.io" "443"
fi
echo ""
echo -e " ${CYAN}play.google.com${NC} / ${CYAN}apps.apple.com${NC}"
echo -e " Purpose: App updates"
echo -e " Required: Only for installing/updating the app"
echo ""
echo -e "${BOLD}If STUN configured in Sipnetic:${NC}"
echo ""
echo -e " The STUN server domain configured in Sipnetic's settings"
echo -e " needs to resolve on the mobile device's network."
echo ""
echo -e " ${YELLOW}Recommendation: Use the Asterisk server's VPN IP as STUN${NC}"
echo -e " ${YELLOW}server (if running self-hosted coturn), avoiding DNS entirely.${NC}"
echo ""
echo -e "${BOLD}Sipnetic Configuration for DNS-Filtered Networks:${NC}"
echo ""
echo -e " Server: ${CYAN}<server-vpn-ip>${NC} (not a hostname)"
echo -e " Port: ${CYAN}5060${NC} (UDP, LAN/VPN mode)"
echo -e " Transport: ${CYAN}UDP${NC}"
echo -e " STUN: ${CYAN}<server-vpn-ip>:3478${NC} (if self-hosted coturn)"
echo -e " or leave blank if VPN provides direct routing"
echo ""
fi
# ══════════════════════════════════════════════════════════════
# SECTION 3: LINPHONE (Mobile Client) DOMAINS
# ══════════════════════════════════════════════════════════════
if $SHOW_ALL || $SHOW_LINPHONE; then
echo -e "${BOLD}━━━ 3. LINPHONE CLIENT (whitelist on caller/receiver DNS) ━━━${NC}"
echo ""
echo -e "${BOLD}Required for SIP calls:${NC}"
echo -e " ${GREEN}None${NC} - Same as Sipnetic, configure with server IP directly"
echo ""
echo -e "${BOLD}Linphone app domains:${NC}"
echo ""
echo -e " ${CYAN}linphone.org${NC} / ${CYAN}sip.linphone.org${NC}"
echo -e " Purpose: Default Linphone SIP proxy (NOT needed for Easy Asterisk)"
echo -e " Required: ${GREEN}No${NC} - We use our own Asterisk server"
echo ""
echo -e " ${CYAN}subscribe.linphone.org${NC}"
echo -e " Purpose: Push notifications (may be needed for background calls)"
echo -e " Required: Only if you need calls to ring when app is backgrounded"
echo ""
echo -e "${BOLD}For remote provisioning:${NC}"
echo ""
echo -e " If using Easy Asterisk's HTTP provisioning:"
echo -e " The phone must reach ${CYAN}http://<server-ip>:8088/static/linphone.xml${NC}"
echo -e " This is an IP address, so no DNS whitelist needed."
echo ""
fi
# ══════════════════════════════════════════════════════════════
# SECTION 4: SUMMARY
# ══════════════════════════════════════════════════════════════
if $SHOW_ALL; then
print_header "Quick Reference - Minimum DNS Whitelist"
echo -e "${BOLD}For LAN/VPN mode (no internet calling):${NC}"
echo ""
echo -e " Server DNS filter: ${GREEN}No domains needed${NC}"
echo -e " Client DNS filter: ${GREEN}No domains needed${NC}"
echo -e " (Configure everything by IP address)"
echo ""
echo -e "${BOLD}For LAN/VPN + self-hosted STUN (coturn):${NC}"
echo ""
echo -e " Server DNS filter: ${GREEN}No domains needed${NC}"
echo -e " Client DNS filter: ${GREEN}No domains needed${NC}"
echo -e " (STUN server reached by VPN IP, not hostname)"
echo ""
echo -e "${BOLD}For LAN/VPN + Google STUN:${NC}"
echo ""
echo -e " Server DNS filter: ${YELLOW}stun.l.google.com${NC}"
echo -e " Client DNS filter: ${YELLOW}stun.l.google.com${NC} (if also set in Sipnetic)"
echo ""
echo -e "${BOLD}For FQDN/Internet mode:${NC}"
echo ""
echo -e " Server DNS filter: ${YELLOW}ifconfig.me, icanhazip.com, stun.l.google.com${NC}"
echo -e " ${YELLOW}acme-v02.api.letsencrypt.org${NC} (if using LE certs)"
echo -e " Client DNS filter: ${YELLOW}Your domain name (${DOMAIN_NAME:-yourdomain.com})${NC}"
echo ""
print_header "Recommendation for DNS-Filtered Environments"
echo -e " ${GREEN}Use LAN/VPN mode + self-hosted coturn (STUN-only)${NC}"
echo -e " ${GREEN}= Zero external DNS dependencies${NC}"
echo ""
echo -e " Setup: docker compose --profile stun up -d"
echo -e " Then configure STUN as your server's VPN IP:3478"
echo -e " No hostnames, no DNS, everything by IP."
echo ""
fi
+20 -346
View File
@@ -1,366 +1,40 @@
#!/bin/bash #!/bin/bash
# ================================================================ # ================================================================
# VPN Diagnostics for Easy Asterisk # VPN Diagnostics for Easy Asterisk
# # Validates PJSIP, TLS, ICE, RTP, and device configuration.
# Tests whether your third-party VPN setup needs STUN/TURN
# and validates connectivity between Asterisk and VPN clients.
#
# Usage: vpn-diagnostics [--auto] [--client-ip <ip>] # Usage: vpn-diagnostics [--auto] [--client-ip <ip>]
# ================================================================ # ================================================================
set -e set -e
RED='\033[0;31m' GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
BOLD='\033[1m'
NC='\033[0m'
CONFIG_FILE="/etc/easy-asterisk/config" echo -e "${CYAN}━━━ Easy Asterisk VPN Diagnostics ━━━${NC}"
RESULTS=()
WARNINGS=()
CLIENT_IP=""
AUTO_MODE=false
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--auto) AUTO_MODE=true; shift ;;
--client-ip) CLIENT_IP="$2"; shift 2 ;;
--help|-h)
echo "Usage: vpn-diagnostics [OPTIONS]"
echo ""
echo "Options:"
echo " --auto Non-interactive mode"
echo " --client-ip <ip> Test connectivity to specific VPN client"
echo " --help Show this help"
exit 0
;;
*) shift ;;
esac
done
print_header() {
echo ""
echo -e "${CYAN}╔══════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN} $1${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════════════════════╝${NC}"
echo ""
}
pass() { echo -e " ${GREEN}${NC} $1"; RESULTS+=("PASS: $1"); }
fail() { echo -e " ${RED}${NC} $1"; RESULTS+=("FAIL: $1"); }
warn() { echo -e " ${YELLOW}!${NC} $1"; WARNINGS+=("$1"); }
info() { echo -e " ${CYAN}${NC} $1"; }
# ── Test 1: Detect network interfaces ────────────────────────
print_header "VPN Diagnostics for Easy Asterisk"
echo -e "${BOLD}1. Network Interface Detection${NC}"
echo "" echo ""
# Detect primary LAN interface # Check Asterisk is running
primary_ip=$(hostname -I | awk '{print $1}') if ! asterisk -rx "core show version" &>/dev/null; then
info "Primary IP: ${primary_ip}" echo -e "${RED}✗ Asterisk is not running${NC}"; exit 1
# Detect VPN interfaces (tun, tap, wg, tailscale, utun, ppp)
vpn_found=false
vpn_ips=()
vpn_ifaces=()
while IFS= read -r line; do
iface=$(echo "$line" | awk '{print $2}' | tr -d ':')
ip_addr=$(echo "$line" | awk '{print $4}' | cut -d'/' -f1)
# Check for VPN interface patterns
if [[ "$iface" =~ ^(tun|tap|wg|tailscale|utun|ppp|nordlynx|proton|mullvad) ]] || \
[[ "$ip_addr" =~ ^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|100\.64\.|100\.96\.|100\.100\.) ]]; then
vpn_found=true
vpn_ips+=("$ip_addr")
vpn_ifaces+=("$iface")
pass "VPN interface detected: ${iface} (${ip_addr})"
fi fi
done < <(ip -o -f inet addr show scope global 2>/dev/null) echo -e "${GREEN}✓ Asterisk running:${NC} $(asterisk -rx "core show version" 2>/dev/null)"
if ! $vpn_found; then # Check transports
warn "No VPN interface detected on server"
info "If your VPN runs on the router (not this server), that's expected"
info "The VPN subnet should be added via VLAN/VPN subnet configuration"
fi
# ── Test 2: Check Asterisk PJSIP transport configuration ─────
echo "" echo ""
echo -e "${BOLD}2. Asterisk Transport Configuration${NC}" echo -e "${CYAN}Transports:${NC}"
asterisk -rx "pjsip show transports" 2>/dev/null || true
# Check registered endpoints
echo "" echo ""
echo -e "${CYAN}Endpoints:${NC}"
if [[ -f /etc/asterisk/pjsip.conf ]]; then asterisk -rx "pjsip show endpoints" 2>/dev/null || true
# Check local_net entries
local_nets=$(grep "^local_net=" /etc/asterisk/pjsip.conf 2>/dev/null | sort -u)
if [[ -n "$local_nets" ]]; then
while IFS= read -r net; do
info "Transport local_net: ${net#local_net=}"
done <<< "$local_nets"
# Check if VPN subnets are included
for vpn_ip in "${vpn_ips[@]}"; do
vpn_subnet=$(echo "$vpn_ip" | sed 's/\.[0-9]*$/.0\/24/')
if echo "$local_nets" | grep -q "$vpn_subnet"; then
pass "VPN subnet ${vpn_subnet} included in transport"
else
fail "VPN subnet ${vpn_subnet} NOT in transport local_net"
warn "Add via: Server Settings → Configure VLAN/VPN Subnets"
fi
done
else
warn "No local_net entries found in transport (basic LAN mode)"
fi
# Check transport types
if grep -q "transport=transport-udp" /etc/asterisk/pjsip.conf; then
pass "UDP transport configured for LAN/VPN devices"
fi
if grep -q "transport=transport-tls" /etc/asterisk/pjsip.conf; then
pass "TLS transport configured for FQDN devices"
fi
else
fail "pjsip.conf not found"
fi
# ── Test 2b: TLS Certificate & Port Checks ────────────────────
echo ""
echo -e "${BOLD}2b. TLS / Certificate Status${NC}"
echo ""
# Check if port 5061 is actually listening
if command -v ss &>/dev/null; then
tls_listen=$(ss -tlnp 2>/dev/null | grep ":5061 " || true)
elif command -v netstat &>/dev/null; then
tls_listen=$(netstat -tlnp 2>/dev/null | grep ":5061 " || true)
else
tls_listen=""
fi
if [[ -n "$tls_listen" ]]; then
pass "Port 5061 (TLS) is listening"
else
fail "Port 5061 (TLS) is NOT listening"
warn "Asterisk TLS transport failed to start — check certs and logs"
fi
# Check TLS cert # Check TLS cert
cert_file="/etc/asterisk/certs/server.crt" if [[ -f /etc/asterisk/certs/server.crt ]]; then
if [[ -f "$cert_file" ]]; then exp=$(openssl x509 -in /etc/asterisk/certs/server.crt -noout -enddate 2>/dev/null | cut -d= -f2)
pass "TLS certificate exists: $cert_file" 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:" \
# Check cert CN/SAN && echo -e "${GREEN}✓ SANs present (mobile-compatible)${NC}" \
cert_cn=$(openssl x509 -in "$cert_file" -noout -subject 2>/dev/null | sed 's/.*CN *= *//') || echo -e "${YELLOW}! No SANs — mobile clients may reject cert${NC}"
cert_san=$(openssl x509 -in "$cert_file" -noout -ext subjectAltName 2>/dev/null | grep -oP 'DNS:\K[^,]+' || true)
cert_expiry=$(openssl x509 -in "$cert_file" -noout -enddate 2>/dev/null | cut -d= -f2)
info "Cert CN: ${cert_cn:-unknown}"
if [[ -n "$cert_san" ]]; then
pass "Cert has SAN (Subject Alt Name): ${cert_san}"
else
fail "Cert has NO SAN — modern phones (iOS/Android) will reject it"
warn "Delete /etc/asterisk/certs/server.crt and restart to regenerate with SANs"
fi
info "Cert expires: ${cert_expiry:-unknown}"
# Check if cert is self-signed
issuer=$(openssl x509 -in "$cert_file" -noout -issuer 2>/dev/null | sed 's/.*CN *= *//')
if [[ "$issuer" == "$cert_cn" ]]; then
warn "Cert is SELF-SIGNED — phones must be set to accept self-signed certs"
info "In your SIP app: disable TLS certificate verification / allow self-signed"
fi
# Verify PJSIP transport loaded it
if command -v asterisk &>/dev/null; then
transport_status=$(asterisk -rx "pjsip show transports" 2>/dev/null || true)
if echo "$transport_status" | grep -q "transport-tls"; then
pass "PJSIP TLS transport is loaded"
else
fail "PJSIP TLS transport NOT loaded — cert may be invalid"
fi
fi
else
fail "TLS certificate not found at $cert_file"
fi
# ── Test 3: Check RTP and ICE/STUN configuration ─────────────
echo ""
echo -e "${BOLD}3. RTP / ICE / STUN Configuration${NC}"
echo ""
if [[ -f /etc/asterisk/rtp.conf ]]; then
rtp_start=$(grep "^rtpstart=" /etc/asterisk/rtp.conf | cut -d= -f2)
rtp_end=$(grep "^rtpend=" /etc/asterisk/rtp.conf | cut -d= -f2)
info "RTP port range: ${rtp_start:-10000}-${rtp_end:-20000}"
if grep -q "^icesupport=yes" /etc/asterisk/rtp.conf; then
pass "ICE support enabled"
stun_addr=$(grep "^stunaddr=" /etc/asterisk/rtp.conf | cut -d= -f2)
if [[ -n "$stun_addr" ]]; then
info "STUN server: ${stun_addr}"
# Test STUN server reachability
stun_host=$(echo "$stun_addr" | cut -d: -f1)
stun_port=$(echo "$stun_addr" | cut -d: -f2)
stun_port="${stun_port:-3478}"
if command -v nslookup &>/dev/null && nslookup "$stun_host" >/dev/null 2>&1; then
pass "STUN server DNS resolves: ${stun_host}"
else
fail "Cannot resolve STUN server: ${stun_host}"
warn "Add ${stun_host} to DNS whitelist"
fi
fi
else
info "ICE support disabled (standard for LAN/VPN mode)"
warn "If audio fails over VPN, enable ICE via: Server Settings → VPN STUN/ICE"
fi
else
warn "rtp.conf not found"
fi
# ── Test 4: Check endpoint ICE settings ───────────────────────
echo ""
echo -e "${BOLD}4. Per-Device ICE Configuration${NC}"
echo ""
if [[ -f /etc/asterisk/pjsip.conf ]]; then
device_count=$(grep -c "^; === Device:" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0)
ice_device_count=$(grep -c "^ice_support=yes" /etc/asterisk/pjsip.conf 2>/dev/null || echo 0)
info "Total devices: ${device_count}"
info "Devices with ICE: ${ice_device_count}"
if [[ "$device_count" -gt 0 && "$ice_device_count" -eq 0 ]]; then
warn "No devices have ICE enabled"
info "For third-party VPNs with NAT, enable ICE via VPN STUN/ICE menu"
fi
fi
# ── Test 5: VPN client connectivity ──────────────────────────
echo ""
echo -e "${BOLD}5. VPN Client Connectivity${NC}"
echo ""
if [[ -z "$CLIENT_IP" ]] && ! $AUTO_MODE; then
echo " Enter a VPN client IP to test connectivity (or press Enter to skip):"
read -p " Client VPN IP: " CLIENT_IP
fi
if [[ -n "$CLIENT_IP" ]]; then
# Ping test
if ping -c 2 -W 3 "$CLIENT_IP" >/dev/null 2>&1; then
pass "Ping to ${CLIENT_IP} succeeded"
else
fail "Ping to ${CLIENT_IP} failed"
warn "VPN routing issue - client may not be reachable"
fi
# SIP port test (UDP 5060)
if command -v nc &>/dev/null; then
if nc -z -u -w 3 "$CLIENT_IP" 5060 2>/dev/null; then
pass "UDP 5060 reachable on ${CLIENT_IP}"
else
info "UDP 5060 probe inconclusive (normal for filtered VPNs)"
fi
fi
else
info "Skipping client connectivity test (no IP provided)"
fi
# ── Test 6: NAT type detection ───────────────────────────────
echo ""
echo -e "${BOLD}6. NAT Type Analysis${NC}"
echo ""
# Check if server is behind NAT
if [[ -n "$primary_ip" ]]; then
public_ip=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || echo "")
if [[ -n "$public_ip" ]]; then
if [[ "$primary_ip" == "$public_ip" ]]; then
pass "Server has public IP (no NAT)"
else
info "Server behind NAT: ${primary_ip}${public_ip}"
info "This is normal for VPN setups where traffic stays on VPN"
fi
else
info "Cannot detect public IP (DNS filtering or no internet)"
info "Not needed for LAN/VPN mode"
fi
fi
# ── Test 7: Asterisk registration status ─────────────────────
echo ""
echo -e "${BOLD}7. Asterisk Registration Status${NC}"
echo ""
if command -v asterisk &>/dev/null; then
reg_output=$(asterisk -rx "pjsip show endpoints" 2>/dev/null || echo "")
if [[ -n "$reg_output" ]]; then
online_count=$(echo "$reg_output" | grep -c "Avail" 2>/dev/null || echo 0)
offline_count=$(echo "$reg_output" | grep -c "Unavail" 2>/dev/null || echo 0)
info "Endpoints online: ${online_count}"
info "Endpoints offline: ${offline_count}"
if [[ "$offline_count" -gt 0 ]]; then
warn "Some endpoints are offline - check VPN connectivity"
echo "$reg_output" | grep "Unavail" | while IFS= read -r line; do
info " Offline: $line"
done
fi
else
info "Asterisk not running or no endpoints configured"
fi
else
info "Asterisk CLI not available"
fi
# ── Summary ──────────────────────────────────────────────────
print_header "Diagnostic Summary"
fail_count=0
pass_count=0
for result in "${RESULTS[@]}"; do
if [[ "$result" == FAIL* ]]; then
((fail_count++))
elif [[ "$result" == PASS* ]]; then
((pass_count++))
fi
done
echo -e " Passed: ${GREEN}${pass_count}${NC}"
echo -e " Failed: ${RED}${fail_count}${NC}"
echo -e " Warnings: ${YELLOW}${#WARNINGS[@]}${NC}"
if [[ ${#WARNINGS[@]} -gt 0 ]]; then
echo ""
echo -e "${BOLD}Recommendations:${NC}"
for w in "${WARNINGS[@]}"; do
echo -e " ${YELLOW}${NC} $w"
done
fi
# ── STUN Recommendation ─────────────────────────────────────
echo ""
echo -e "${BOLD}Do you need STUN?${NC}"
echo ""
if $vpn_found; then
echo -e " VPN detected on this server."
echo -e " ${GREEN}If your VPN provides direct routing (both sides get VPN IPs),${NC}"
echo -e " ${GREEN}STUN is likely NOT needed.${NC}"
echo ""
echo -e " ${YELLOW}If audio works one-way or not at all, enable STUN:${NC}"
echo -e " 1. docker compose --profile stun up -d (self-hosted STUN)"
echo -e " 2. Or via easy-asterisk: Server Settings → VPN STUN/ICE"
else
echo -e " No VPN interface found on server."
echo -e " ${YELLOW}If VPN runs on router/firewall:${NC}"
echo -e " - Add VPN subnet via: Server Settings → VLAN/VPN Subnets"
echo -e " - If audio still fails, enable STUN for NAT traversal"
fi fi
echo "" echo ""