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:
@@ -66,8 +66,8 @@ Update them any time with `sudo ./setup.sh configure`.
|
|||||||
| Group | Services |
|
| Group | Services |
|
||||||
|-------|---------|
|
|-------|---------|
|
||||||
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) |
|
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) |
|
||||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` |
|
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` |
|
||||||
| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `rustdesk`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
|
| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
|
||||||
| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||||
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
|
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# services/asterisk.sh — Easy Asterisk PBX + coturn TURN server (home intercom/VoIP).
|
||||||
|
# Part of the modular post-install system (sourced by setup.sh).
|
||||||
|
#
|
||||||
|
# Can also be run standalone on any machine:
|
||||||
|
# sudo bash asterisk.sh
|
||||||
|
# (Docker must already be installed when run standalone)
|
||||||
|
|
||||||
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||||
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||||
|
# Sets up helpers and globals, then defers execution until after the function
|
||||||
|
# definition at the bottom of this file.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||||
|
|
||||||
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||||
|
|
||||||
|
if [[ -f "$_COMMON" ]]; then
|
||||||
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$_COMMON"
|
||||||
|
else
|
||||||
|
# One-off copy — inline minimal stubs so the script works without the repo
|
||||||
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||||
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||||
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||||
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
require_docker() {
|
||||||
|
command -v docker &>/dev/null || {
|
||||||
|
log_error "Docker not found. Install it first:"
|
||||||
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
docker compose version &>/dev/null || {
|
||||||
|
log_error "Docker Compose plugin missing:"
|
||||||
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_dir_ownership() {
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||||
|
prompt_text() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_yn() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_caddy_for_service() {
|
||||||
|
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||||
|
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||||
|
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||||
|
|
||||||
|
# Remote Caddy support: if CADDY_REMOTE_HOST is set, operate on the
|
||||||
|
# remote machine via SSH instead of the local filesystem.
|
||||||
|
if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then
|
||||||
|
echo ""
|
||||||
|
local _do_caddy=""
|
||||||
|
read -r -p " Configure Caddy reverse proxy for $_name on $CADDY_REMOTE_HOST? [y/N]: " _do_caddy
|
||||||
|
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||||
|
log_info "Skipping — access at: http://$(hostname -I | awk '{print $1}'):${_upstream##*:}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local _domain=""
|
||||||
|
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
|
||||||
|
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||||
|
|
||||||
|
local _block
|
||||||
|
_block="$(cat << CBLOCK
|
||||||
|
|
||||||
|
# $_name
|
||||||
|
$_domain {
|
||||||
|
reverse_proxy $_upstream
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/${_domain}.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
${_extra}
|
||||||
|
}
|
||||||
|
CBLOCK
|
||||||
|
)"
|
||||||
|
echo "$_block" | ssh "$CADDY_REMOTE_HOST" "cat >> $_caddyfile"
|
||||||
|
ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true"
|
||||||
|
if ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null"; then
|
||||||
|
log_success "$_name accessible at: https://$_domain"
|
||||||
|
else
|
||||||
|
log_warning "Reload failed — check: ssh $CADDY_REMOTE_HOST docker logs caddy"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$_caddy_dir" ]]; then
|
||||||
|
log_info "Access $_name directly on port ${_upstream##*:}."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
local _do_caddy=""
|
||||||
|
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||||
|
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||||
|
log_info "Skipping — access at: http://localhost:${_upstream##*:}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local _domain=""
|
||||||
|
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
|
||||||
|
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||||
|
|
||||||
|
# Back up before touching
|
||||||
|
if [[ -f "$_caddyfile" ]]; then
|
||||||
|
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$_caddyfile" "$_bk"
|
||||||
|
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||||
|
else
|
||||||
|
touch "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing block for this domain if present
|
||||||
|
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||||
|
log_warning "$_domain already in Caddyfile"
|
||||||
|
local _ow=""
|
||||||
|
read -r -p " Overwrite? [y/N]: " _ow
|
||||||
|
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||||
|
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> "$_caddyfile" << CBLOCK
|
||||||
|
|
||||||
|
# $_name
|
||||||
|
$_domain {
|
||||||
|
reverse_proxy $_upstream
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/${_domain}.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
${_extra}
|
||||||
|
}
|
||||||
|
CBLOCK
|
||||||
|
|
||||||
|
log_success "Added $_domain to Caddyfile"
|
||||||
|
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||||
|
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||||
|
log_success "$_name accessible at: https://$_domain"
|
||||||
|
else
|
||||||
|
log_warning "Reload failed — check: docker logs caddy"
|
||||||
|
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_readme() {
|
||||||
|
local _dir="$1"
|
||||||
|
mkdir -p "$_dir"
|
||||||
|
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||||
|
cat > "$_dir/README.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_password() {
|
||||||
|
local _len="${1:-32}"
|
||||||
|
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||||
|
# ($HOME under sudo is /root, not the real user's home)
|
||||||
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||||
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||||
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||||
|
DRY_RUN="${DRY_RUN:-false}"
|
||||||
|
UNATTENDED="${UNATTENDED:-false}"
|
||||||
|
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||||
|
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||||
|
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
|
||||||
|
|
||||||
|
register_service() { :; } # no-op — no wizard to register into
|
||||||
|
_RUN_STANDALONE=1
|
||||||
|
fi
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
register_service asterisk homelab "Easy Asterisk PBX + coturn TURN server (home intercom/VoIP)" 5061
|
||||||
|
|
||||||
|
install_asterisk() {
|
||||||
|
require_docker || return 1
|
||||||
|
log_info "Installing Easy Asterisk PBX + coturn..."
|
||||||
|
|
||||||
|
local EA_DIR="$DOCKER_DIR/asterisk"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would create $EA_DIR with Dockerfile, docker-compose.yml, .env"
|
||||||
|
echo "[DRY-RUN] Would copy/download vendor files from easy-asterisk"
|
||||||
|
echo "[DRY-RUN] Would open UFW ports: 5060, 5061, 8080, 8088, 8089, 3478, 10000-20000, 49152-49252"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$EA_DIR"
|
||||||
|
ensure_docker_dir_ownership "$EA_DIR"
|
||||||
|
cd "$EA_DIR" || return 1
|
||||||
|
|
||||||
|
mkdir -p docker
|
||||||
|
|
||||||
|
# ── Vendor files ──────────────────────────────────────────────────────────
|
||||||
|
local _SELF_DIR_LOCAL
|
||||||
|
_SELF_DIR_LOCAL="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
local VENDOR_DIR="$_SELF_DIR_LOCAL/../vendor/easy-asterisk"
|
||||||
|
|
||||||
|
if [[ -d "$VENDOR_DIR" ]]; then
|
||||||
|
log_info "Copying vendor files from $VENDOR_DIR ..."
|
||||||
|
cp "$VENDOR_DIR/Dockerfile" ./Dockerfile
|
||||||
|
cp "$VENDOR_DIR/docker/entrypoint.sh" ./docker/entrypoint.sh
|
||||||
|
cp "$VENDOR_DIR/docker/coturn-entrypoint.sh" ./docker/coturn-entrypoint.sh
|
||||||
|
cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk.sh
|
||||||
|
cp "$VENDOR_DIR/easy-asterisk-v0.10.0.sh" ./easy-asterisk-v0.10.0.sh
|
||||||
|
else
|
||||||
|
log_info "Vendor directory not found — downloading from GitHub ..."
|
||||||
|
local GH_RAW="https://raw.githubusercontent.com/DeadDork/easy-asterisk/main"
|
||||||
|
curl -fsSL "$GH_RAW/Dockerfile" -o ./Dockerfile
|
||||||
|
curl -fsSL "$GH_RAW/docker/entrypoint.sh" -o ./docker/entrypoint.sh
|
||||||
|
curl -fsSL "$GH_RAW/docker/coturn-entrypoint.sh" -o ./docker/coturn-entrypoint.sh
|
||||||
|
curl -fsSL "$GH_RAW/easy-asterisk-v0.10.0.sh" -o ./easy-asterisk.sh
|
||||||
|
cp ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh
|
||||||
|
fi
|
||||||
|
|
||||||
|
chmod 755 ./easy-asterisk.sh ./easy-asterisk-v0.10.0.sh \
|
||||||
|
./docker/entrypoint.sh ./docker/coturn-entrypoint.sh
|
||||||
|
|
||||||
|
# ── Networking mode ───────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
echo " Networking mode:"
|
||||||
|
echo " 1) LAN-only — no domain, self-signed cert, works on local network/VPN only"
|
||||||
|
echo " 2) FQDN — TLS + TURN relay, works from anywhere (requires public domain)"
|
||||||
|
local HA_NETMODE=""
|
||||||
|
prompt_text "Choose [1]:" "1" HA_NETMODE
|
||||||
|
|
||||||
|
local DOMAIN_NAME=""
|
||||||
|
if [[ "$HA_NETMODE" == "2" ]]; then
|
||||||
|
prompt_text "FQDN (e.g. asterisk.${SITE_DOMAIN:-example.com}) [blank=skip]:" "" DOMAIN_NAME
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Secrets ───────────────────────────────────────────────────────────────
|
||||||
|
local TURN_PASSWORD
|
||||||
|
TURN_PASSWORD="$(generate_password 24)"
|
||||||
|
|
||||||
|
local TURN_SERVER_VAL=""
|
||||||
|
[[ -n "$DOMAIN_NAME" ]] && TURN_SERVER_VAL="${DOMAIN_NAME}:3478"
|
||||||
|
|
||||||
|
# ── docker-compose.yml ────────────────────────────────────────────────────
|
||||||
|
cat > docker-compose.yml << 'EOF'
|
||||||
|
name: asterisk
|
||||||
|
|
||||||
|
services:
|
||||||
|
asterisk:
|
||||||
|
build: .
|
||||||
|
container_name: easy-asterisk
|
||||||
|
network_mode: host
|
||||||
|
depends_on:
|
||||||
|
coturn:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- asterisk-config:/etc/asterisk
|
||||||
|
- easy-asterisk-config:/etc/easy-asterisk
|
||||||
|
- asterisk-logs:/var/log/asterisk
|
||||||
|
- asterisk-spool:/var/spool/asterisk
|
||||||
|
- asterisk-lib:/var/lib/asterisk
|
||||||
|
- ./easy-asterisk.sh:/usr/local/bin/easy-asterisk:ro
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "asterisk", "-rx", "core show version"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
coturn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: easy-asterisk-coturn
|
||||||
|
network_mode: host
|
||||||
|
user: root
|
||||||
|
entrypoint: ["/coturn-entrypoint.sh"]
|
||||||
|
volumes:
|
||||||
|
- ./docker/coturn-entrypoint.sh:/coturn-entrypoint.sh:ro
|
||||||
|
env_file: .env
|
||||||
|
command:
|
||||||
|
- -n
|
||||||
|
- --listening-port=${TURN_PORT:-3478}
|
||||||
|
- --listening-ip=0.0.0.0
|
||||||
|
- --fingerprint
|
||||||
|
- --lt-cred-mech
|
||||||
|
- --user=${TURN_USERNAME:-easyasterisk}:${TURN_PASSWORD}
|
||||||
|
- --realm=${DOMAIN_NAME:-localhost}
|
||||||
|
- --min-port=49152
|
||||||
|
- --max-port=49252
|
||||||
|
- --no-tls
|
||||||
|
- --no-dtls
|
||||||
|
- --no-cli
|
||||||
|
- --no-multicast-peers
|
||||||
|
- --log-file=stdout
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
asterisk-config:
|
||||||
|
easy-asterisk-config:
|
||||||
|
asterisk-logs:
|
||||||
|
asterisk-spool:
|
||||||
|
asterisk-lib:
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── .env ──────────────────────────────────────────────────────────────────
|
||||||
|
cat > .env << ENV
|
||||||
|
# ── Domain ────────────────────────────────────────────────────
|
||||||
|
# Set to your FQDN for remote access. Leave empty for LAN-only.
|
||||||
|
DOMAIN_NAME=${DOMAIN_NAME}
|
||||||
|
|
||||||
|
# ── TURN/STUN ─────────────────────────────────────────────────
|
||||||
|
TURN_USERNAME=easyasterisk
|
||||||
|
TURN_PASSWORD=${TURN_PASSWORD}
|
||||||
|
TURN_PORT=3478
|
||||||
|
# For LAN-only: TURN_SERVER is empty. For FQDN: set to domain:3478
|
||||||
|
TURN_SERVER=${TURN_SERVER_VAL}
|
||||||
|
|
||||||
|
# ── RTP port range ────────────────────────────────────────────
|
||||||
|
RTP_START=10000
|
||||||
|
RTP_END=20000
|
||||||
|
|
||||||
|
# ── Web admin ─────────────────────────────────────────────────
|
||||||
|
WEB_ADMIN_PORT=8080
|
||||||
|
WEB_ADMIN_AUTH_DISABLED=false
|
||||||
|
ENV
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
# ── UFW firewall rules ────────────────────────────────────────────────────
|
||||||
|
if command -v ufw &>/dev/null; then
|
||||||
|
log_info "Opening UFW ports for Asterisk + coturn..."
|
||||||
|
ufw allow 5060/udp
|
||||||
|
ufw allow 5060/tcp
|
||||||
|
ufw allow 5061/tcp
|
||||||
|
ufw allow 8080/tcp
|
||||||
|
ufw allow 8088/tcp
|
||||||
|
ufw allow 8089/tcp
|
||||||
|
ufw allow 3478/udp
|
||||||
|
ufw allow 3478/tcp
|
||||||
|
ufw allow 10000:20000/udp
|
||||||
|
ufw allow 49152:49252/udp
|
||||||
|
log_success "UFW rules added."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Caddy reverse proxy for web admin ─────────────────────────────────────
|
||||||
|
local EXTRA_BLOCK=""
|
||||||
|
if [ -d "$DOCKER_DIR/authelia" ]; then
|
||||||
|
local _use_auth=""
|
||||||
|
prompt_yn "Protect Asterisk web admin with Authelia SSO? (y/n):" "y" _use_auth
|
||||||
|
if [[ "$_use_auth" =~ ^[Yy]$ ]]; then
|
||||||
|
EXTRA_BLOCK=" import authelia"
|
||||||
|
# Disable built-in auth since Authelia handles it
|
||||||
|
sed -i "s/^WEB_ADMIN_AUTH_DISABLED=.*/WEB_ADMIN_AUTH_DISABLED=true/" .env
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
configure_caddy_for_service "Asterisk Web Admin" "8080" "asterisk" "$EXTRA_BLOCK"
|
||||||
|
|
||||||
|
# ── README ────────────────────────────────────────────────────────────────
|
||||||
|
write_readme "$EA_DIR" << 'MD'
|
||||||
|
# Easy Asterisk PBX + coturn
|
||||||
|
|
||||||
|
Self-hosted SIP PBX using Easy Asterisk with a coturn TURN/STUN server for
|
||||||
|
NAT traversal. Suitable for home intercom, VoIP handsets, and softphones.
|
||||||
|
|
||||||
|
## Manage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build # build image and start
|
||||||
|
docker compose up -d # start (after initial build)
|
||||||
|
docker compose down # stop
|
||||||
|
docker compose logs -f # follow logs
|
||||||
|
docker compose pull # update coturn image
|
||||||
|
docker compose up -d --build # rebuild asterisk image
|
||||||
|
```
|
||||||
|
|
||||||
|
## Management script
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker exec -it easy-asterisk easy-asterisk --help
|
||||||
|
```
|
||||||
|
|
||||||
|
## SIP client setup
|
||||||
|
|
||||||
|
| Setting | Value |
|
||||||
|
|-----------------|--------------------------------------|
|
||||||
|
| SIP server | <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) |
|
||||||
|
| 10000–20000 | UDP | RTP media streams |
|
||||||
|
| 49152–49252 | UDP | TURN relay media ports |
|
||||||
|
MD
|
||||||
|
|
||||||
|
# ── Start ─────────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
local START_NOW=""
|
||||||
|
prompt_yn "Build and start Asterisk now? (y/n):" "y" START_NOW
|
||||||
|
if [ "$START_NOW" = "y" ] || [ "$START_NOW" = "Y" ]; then
|
||||||
|
docker compose up -d --build \
|
||||||
|
&& log_success "Easy Asterisk started" \
|
||||||
|
|| log_warning "Start failed — check: docker compose logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Summary ───────────────────────────────────────────────────────────────
|
||||||
|
echo ""
|
||||||
|
log_success "Easy Asterisk installed at $EA_DIR"
|
||||||
|
if [[ -n "$DOMAIN_NAME" ]]; then
|
||||||
|
echo " Mode: FQDN ($DOMAIN_NAME)"
|
||||||
|
echo " TURN server: ${DOMAIN_NAME}:3478"
|
||||||
|
else
|
||||||
|
echo " Mode: LAN-only"
|
||||||
|
echo " TURN server: (none — LAN/VPN only)"
|
||||||
|
fi
|
||||||
|
echo " SIP port: 5061 (TLS) / 5060 (UDP)"
|
||||||
|
echo " Web admin: http://$(hostname -I 2>/dev/null | awk '{print $1}' || echo localhost):8080"
|
||||||
|
echo " Manage: docker compose -f $EA_DIR/docker-compose.yml <up|down|logs>"
|
||||||
|
echo " Script: docker exec -it easy-asterisk easy-asterisk --help"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run immediately when executed directly (deferred until after function definition)
|
||||||
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_asterisk
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn).
|
||||||
|
# Part of the modular post-install system (sourced by setup.sh).
|
||||||
|
#
|
||||||
|
# Can also be run standalone on any machine:
|
||||||
|
# sudo bash mattermost.sh
|
||||||
|
# (Docker must already be installed when run standalone)
|
||||||
|
|
||||||
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||||
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||||
|
# Sets up helpers and globals, then defers execution until after the function
|
||||||
|
# definition at the bottom of this file.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||||
|
|
||||||
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||||
|
|
||||||
|
if [[ -f "$_COMMON" ]]; then
|
||||||
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$_COMMON"
|
||||||
|
else
|
||||||
|
# One-off copy — inline minimal stubs so the script works without the repo
|
||||||
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||||
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||||
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||||
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
require_docker() {
|
||||||
|
command -v docker &>/dev/null || {
|
||||||
|
log_error "Docker not found. Install it first:"
|
||||||
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
docker compose version &>/dev/null || {
|
||||||
|
log_error "Docker Compose plugin missing:"
|
||||||
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_dir_ownership() {
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||||
|
prompt_text() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_yn() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_caddy_for_service() {
|
||||||
|
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||||
|
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||||
|
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||||
|
|
||||||
|
# Remote Caddy support: if CADDY_REMOTE_HOST is set, operate on the
|
||||||
|
# remote machine via SSH instead of the local filesystem.
|
||||||
|
if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then
|
||||||
|
echo ""
|
||||||
|
local _do_caddy=""
|
||||||
|
read -r -p " Configure Caddy reverse proxy for $_name on $CADDY_REMOTE_HOST? [y/N]: " _do_caddy
|
||||||
|
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||||
|
log_info "Skipping — access at: http://$(hostname -I | awk '{print $1}'):${_upstream##*:}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local _domain=""
|
||||||
|
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
|
||||||
|
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||||
|
|
||||||
|
local _block
|
||||||
|
_block="$(cat << CBLOCK
|
||||||
|
|
||||||
|
# $_name
|
||||||
|
$_domain {
|
||||||
|
reverse_proxy $_upstream
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/${_domain}.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
${_extra}
|
||||||
|
}
|
||||||
|
CBLOCK
|
||||||
|
)"
|
||||||
|
echo "$_block" | ssh "$CADDY_REMOTE_HOST" "cat >> $_caddyfile"
|
||||||
|
ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true"
|
||||||
|
if ssh "$CADDY_REMOTE_HOST" "docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null"; then
|
||||||
|
log_success "$_name accessible at: https://$_domain"
|
||||||
|
else
|
||||||
|
log_warning "Reload failed — check: ssh $CADDY_REMOTE_HOST docker logs caddy"
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$_caddy_dir" ]]; then
|
||||||
|
log_info "Access $_name directly on port ${_upstream##*:}."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
local _do_caddy=""
|
||||||
|
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||||
|
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||||
|
log_info "Skipping — access at: http://localhost:${_upstream##*:}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local _domain=""
|
||||||
|
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
|
||||||
|
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||||
|
|
||||||
|
# Back up before touching
|
||||||
|
if [[ -f "$_caddyfile" ]]; then
|
||||||
|
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$_caddyfile" "$_bk"
|
||||||
|
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||||
|
else
|
||||||
|
touch "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing block for this domain if present
|
||||||
|
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||||
|
log_warning "$_domain already in Caddyfile"
|
||||||
|
local _ow=""
|
||||||
|
read -r -p " Overwrite? [y/N]: " _ow
|
||||||
|
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||||
|
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> "$_caddyfile" << CBLOCK
|
||||||
|
|
||||||
|
# $_name
|
||||||
|
$_domain {
|
||||||
|
reverse_proxy $_upstream
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/${_domain}.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
${_extra}
|
||||||
|
}
|
||||||
|
CBLOCK
|
||||||
|
|
||||||
|
log_success "Added $_domain to Caddyfile"
|
||||||
|
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||||
|
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||||
|
log_success "$_name accessible at: https://$_domain"
|
||||||
|
else
|
||||||
|
log_warning "Reload failed — check: docker logs caddy"
|
||||||
|
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
write_readme() {
|
||||||
|
local _dir="$1"
|
||||||
|
mkdir -p "$_dir"
|
||||||
|
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||||
|
cat > "$_dir/README.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_password() {
|
||||||
|
local _len="${1:-32}"
|
||||||
|
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||||
|
echo
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||||
|
# ($HOME under sudo is /root, not the real user's home)
|
||||||
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||||
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||||
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||||
|
DRY_RUN="${DRY_RUN:-false}"
|
||||||
|
UNATTENDED="${UNATTENDED:-false}"
|
||||||
|
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||||
|
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||||
|
CADDY_REMOTE_HOST="${CADDY_REMOTE_HOST:-}"
|
||||||
|
|
||||||
|
register_service() { :; } # no-op — no wizard to register into
|
||||||
|
_RUN_STANDALONE=1
|
||||||
|
fi
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
register_service mattermost utilities "Team messaging with voice/video calls (Mattermost + coturn)" 8065
|
||||||
|
|
||||||
|
install_mattermost() {
|
||||||
|
require_docker || return 1
|
||||||
|
log_info "Installing Mattermost + coturn..."
|
||||||
|
|
||||||
|
local DIR="$DOCKER_DIR/mattermost"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
|
||||||
|
echo "[DRY-RUN] Would write .env with DB and Mattermost secrets"
|
||||||
|
echo "[DRY-RUN] Would create data/ logs/ config/ plugins/ db/ subdirectories"
|
||||||
|
echo "[DRY-RUN] Would open UFW ports 8443/udp, 3479, 49153:49352/udp"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
ensure_docker_dir_ownership "$DIR"
|
||||||
|
cd "$DIR" || return 1
|
||||||
|
|
||||||
|
local DB_PASS
|
||||||
|
local MM_SECRET
|
||||||
|
DB_PASS=$(generate_password 32)
|
||||||
|
MM_SECRET=$(generate_password 48)
|
||||||
|
|
||||||
|
local TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
local UID_VAL GID_VAL
|
||||||
|
UID_VAL=$(id -u "$ACTUAL_USER")
|
||||||
|
GID_VAL=$(id -g "$ACTUAL_USER")
|
||||||
|
|
||||||
|
# Compute SITE_URL
|
||||||
|
local SITE_URL="http://localhost:8065"
|
||||||
|
if [ -n "$SITE_DOMAIN" ] && [ "$SITE_DOMAIN" != "example.com" ]; then
|
||||||
|
SITE_URL="https://mattermost.${SITE_DOMAIN}"
|
||||||
|
fi
|
||||||
|
local CONFIGURED_SITEURL=""
|
||||||
|
prompt_text "Mattermost site URL [$SITE_URL]:" "$SITE_URL" CONFIGURED_SITEURL
|
||||||
|
[[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL"
|
||||||
|
|
||||||
|
cat > docker-compose.yml << 'EOF'
|
||||||
|
name: mattermost
|
||||||
|
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
container_name: mattermost-db
|
||||||
|
hostname: mattermost-db
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./db:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- caddy_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
mattermost:
|
||||||
|
image: mattermost/mattermost-team-edition:latest
|
||||||
|
container_name: mattermost
|
||||||
|
hostname: mattermost
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
volumes:
|
||||||
|
- ./data:/mattermost/data
|
||||||
|
- ./logs:/mattermost/logs
|
||||||
|
- ./config:/mattermost/config
|
||||||
|
- ./plugins:/mattermost/plugins
|
||||||
|
ports:
|
||||||
|
- "8065:8065"
|
||||||
|
- "8443:8443/udp"
|
||||||
|
networks:
|
||||||
|
- caddy_net
|
||||||
|
|
||||||
|
coturn:
|
||||||
|
image: coturn/coturn:latest
|
||||||
|
container_name: mattermost-coturn
|
||||||
|
network_mode: host
|
||||||
|
user: root
|
||||||
|
command:
|
||||||
|
- -n
|
||||||
|
- --listening-port=3479
|
||||||
|
- --listening-ip=0.0.0.0
|
||||||
|
- --fingerprint
|
||||||
|
- --use-auth-secret
|
||||||
|
- --static-auth-secret=${COTURN_SECRET}
|
||||||
|
- --realm=${MM_REALM:-localhost}
|
||||||
|
- --min-port=49153
|
||||||
|
- --max-port=49352
|
||||||
|
- --no-tls
|
||||||
|
- --no-dtls
|
||||||
|
- --no-cli
|
||||||
|
- --no-multicast-peers
|
||||||
|
- --log-file=stdout
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy_net:
|
||||||
|
external: true
|
||||||
|
name: ${CADDY_NET:-caddy_net}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat > .env << EOF
|
||||||
|
TZ=$TZ_VAL
|
||||||
|
CADDY_NET=$SITE_CADDY_NET
|
||||||
|
|
||||||
|
# PostgreSQL
|
||||||
|
POSTGRES_DB=mattermost
|
||||||
|
POSTGRES_USER=mattermost
|
||||||
|
POSTGRES_PASSWORD=$DB_PASS
|
||||||
|
|
||||||
|
# Mattermost
|
||||||
|
MM_SQLSETTINGS_DRIVERNAME=postgres
|
||||||
|
MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:${DB_PASS}@mattermost-db:5432/mattermost?sslmode=disable&connect_timeout=10
|
||||||
|
MM_SERVICESETTINGS_SITEURL=$SITE_URL
|
||||||
|
MM_SERVICESETTINGS_ENABLELOCALMODE=true
|
||||||
|
MM_FILESETTINGS_DRIVERNAME=local
|
||||||
|
MM_PLUGINSETTINGS_ENABLE=true
|
||||||
|
|
||||||
|
# coturn HMAC secret for Mattermost Calls plugin
|
||||||
|
COTURN_SECRET=$MM_SECRET
|
||||||
|
MM_REALM=${SITE_DOMAIN:-localhost}
|
||||||
|
|
||||||
|
# PUID/PGID for file ownership
|
||||||
|
PUID=$UID_VAL
|
||||||
|
PGID=$GID_VAL
|
||||||
|
EOF
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
mkdir -p data logs config plugins db
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
||||||
|
|
||||||
|
# Open required firewall ports
|
||||||
|
if command -v ufw &>/dev/null; then
|
||||||
|
ufw allow 8443/udp comment "Mattermost Calls RTC"
|
||||||
|
ufw allow 3479/udp; ufw allow 3479/tcp
|
||||||
|
ufw allow 49153:49352/udp comment "Mattermost coturn relay"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_success "Mattermost configured at $DIR"
|
||||||
|
|
||||||
|
configure_caddy_for_service "Mattermost" "mattermost:8065" "mattermost"
|
||||||
|
|
||||||
|
write_readme "$DIR" << MD
|
||||||
|
# Mattermost
|
||||||
|
|
||||||
|
Team messaging with voice/video calls. PostgreSQL backend + coturn TURN relay.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
- URL: $SITE_URL (or http://localhost:8065)
|
||||||
|
- First run: create admin account at the URL above
|
||||||
|
|
||||||
|
## Voice/Video Calls (Calls plugin)
|
||||||
|
Port 8443/udp must be open on your router/firewall.
|
||||||
|
coturn relay runs on port 3479 (HMAC secret in .env).
|
||||||
|
|
||||||
|
Configure in Mattermost: System Console → Plugins → Calls:
|
||||||
|
- TURN Server URI: turn:YOUR_DOMAIN_OR_IP:3479?transport=udp
|
||||||
|
- TURN Credentials: use static-auth-secret (see .env COTURN_SECRET)
|
||||||
|
|
||||||
|
## Manage
|
||||||
|
\`\`\`bash
|
||||||
|
docker compose up -d
|
||||||
|
docker compose down
|
||||||
|
docker compose logs -f
|
||||||
|
docker compose pull && docker compose up -d
|
||||||
|
\`\`\`
|
||||||
|
MD
|
||||||
|
|
||||||
|
if [[ "$SITE_URL" == http://* ]]; then
|
||||||
|
log_warning "WebRTC (voice/video calls) requires HTTPS. Configure Caddy and update SITE_URL."
|
||||||
|
fi
|
||||||
|
|
||||||
|
local START=""
|
||||||
|
prompt_yn "Start Mattermost now? (y/n):" "y" START
|
||||||
|
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
|
||||||
|
docker compose up -d \
|
||||||
|
&& log_success "Mattermost started" \
|
||||||
|
|| log_warning "Start failed — check: docker compose logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo " Access at: $SITE_URL"
|
||||||
|
echo " First run: open the URL above and create your admin account."
|
||||||
|
echo " Calls plugin: System Console → Plugins → Calls to configure coturn."
|
||||||
|
echo " TURN URI: turn:${SITE_DOMAIN:-YOUR_IP}:3479?transport=udp"
|
||||||
|
echo " Auth secret: see COTURN_SECRET in $DIR/.env"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run immediately when executed directly (deferred until after function definition)
|
||||||
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_mattermost
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# services/nextcloud.sh — Self-hosted cloud storage with SMB/local file access (Nextcloud).
|
||||||
|
# Part of the modular post-install system (sourced by setup.sh).
|
||||||
|
#
|
||||||
|
# Can also be run standalone on any machine:
|
||||||
|
# sudo bash nextcloud.sh
|
||||||
|
# (Docker must already be installed when run standalone)
|
||||||
|
|
||||||
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||||
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||||
|
# Sets up helpers and globals, then defers execution until after the function
|
||||||
|
# definition at the bottom of this file.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||||
|
|
||||||
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||||
|
|
||||||
|
if [[ -f "$_COMMON" ]]; then
|
||||||
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$_COMMON"
|
||||||
|
else
|
||||||
|
# One-off copy — inline minimal stubs so the script works without the repo
|
||||||
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||||
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||||
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||||
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
require_docker() {
|
||||||
|
command -v docker &>/dev/null || {
|
||||||
|
log_error "Docker not found. Install it first:"
|
||||||
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
docker compose version &>/dev/null || {
|
||||||
|
log_error "Docker Compose plugin missing:"
|
||||||
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_dir_ownership() {
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_password() {
|
||||||
|
local _len="${1:-32}"
|
||||||
|
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_readme() {
|
||||||
|
local _dir="$1"; shift
|
||||||
|
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||||
|
mkdir -p "$_dir"
|
||||||
|
cat > "$_dir/README.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||||
|
prompt_text() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_yn() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_caddy_for_service() {
|
||||||
|
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||||
|
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||||
|
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||||
|
|
||||||
|
# Support remote Caddy host via CADDY_REMOTE_HOST
|
||||||
|
if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then
|
||||||
|
log_info "Remote Caddy detected at $CADDY_REMOTE_HOST — printing block to add manually."
|
||||||
|
echo ""
|
||||||
|
echo " Add the following to your Caddyfile on $CADDY_REMOTE_HOST:"
|
||||||
|
echo " ──────────────────────────────────────────────────────────"
|
||||||
|
echo " # $_name"
|
||||||
|
echo " ${_subdomain}.${SITE_DOMAIN:-example.com} {"
|
||||||
|
echo " reverse_proxy $_upstream"
|
||||||
|
[[ -n "$_extra" ]] && echo "$_extra"
|
||||||
|
echo " }"
|
||||||
|
echo " ──────────────────────────────────────────────────────────"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$_caddy_dir" ]]; then
|
||||||
|
log_info "Access $_name directly on port ${_upstream##*:}."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
local _do_caddy=""
|
||||||
|
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||||
|
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||||
|
log_info "Skipping — access at: http://localhost:${_upstream##*:}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local _domain=""
|
||||||
|
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
|
||||||
|
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||||
|
|
||||||
|
# Back up before touching
|
||||||
|
if [[ -f "$_caddyfile" ]]; then
|
||||||
|
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$_caddyfile" "$_bk"
|
||||||
|
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||||
|
else
|
||||||
|
touch "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing block for this domain if present
|
||||||
|
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||||
|
log_warning "$_domain already in Caddyfile"
|
||||||
|
local _ow=""
|
||||||
|
read -r -p " Overwrite? [y/N]: " _ow
|
||||||
|
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||||
|
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> "$_caddyfile" << CBLOCK
|
||||||
|
|
||||||
|
# $_name
|
||||||
|
$_domain {
|
||||||
|
reverse_proxy $_upstream
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/${_domain}.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
${_extra}
|
||||||
|
}
|
||||||
|
CBLOCK
|
||||||
|
|
||||||
|
log_success "Added $_domain to Caddyfile"
|
||||||
|
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||||
|
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||||
|
log_success "$_name accessible at: https://$_domain"
|
||||||
|
else
|
||||||
|
log_warning "Reload failed — check: docker logs caddy"
|
||||||
|
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||||
|
# ($HOME under sudo is /root, not the real user's home)
|
||||||
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||||
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||||
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||||
|
DRY_RUN="${DRY_RUN:-false}"
|
||||||
|
UNATTENDED="${UNATTENDED:-false}"
|
||||||
|
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||||
|
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||||
|
|
||||||
|
register_service() { :; } # no-op — no wizard to register into
|
||||||
|
_RUN_STANDALONE=1
|
||||||
|
fi
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
register_service nextcloud utilities "Self-hosted cloud storage with SMB/local file access (Nextcloud)" 8080
|
||||||
|
|
||||||
|
install_nextcloud() {
|
||||||
|
require_docker || return 1
|
||||||
|
log_info "Installing Nextcloud..."
|
||||||
|
local DIR="$DOCKER_DIR/nextcloud"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would create $DIR with Dockerfile, docker-compose.yml, .env"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
ensure_docker_dir_ownership "$DIR"
|
||||||
|
cd "$DIR" || return 1
|
||||||
|
|
||||||
|
local DB_PASS
|
||||||
|
DB_PASS=$(generate_password 32)
|
||||||
|
local NC_ADMIN_PASS
|
||||||
|
NC_ADMIN_PASS=$(generate_password 16)
|
||||||
|
local TZ_VAL="${SITE_TZ:-UTC}"
|
||||||
|
|
||||||
|
# ── Dockerfile ──────────────────────────────────────────────────────────
|
||||||
|
cat > Dockerfile << 'NCDF'
|
||||||
|
FROM nextcloud:apache
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends procps smbclient \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
NCDF
|
||||||
|
|
||||||
|
# ── docker-compose.yml ──────────────────────────────────────────────────
|
||||||
|
cat > docker-compose.yml << 'NCCOMPOSE'
|
||||||
|
name: nextcloud
|
||||||
|
services:
|
||||||
|
nextcloud:
|
||||||
|
build: .
|
||||||
|
container_name: nextcloud
|
||||||
|
hostname: nextcloud
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
volumes:
|
||||||
|
- ./html:/var/www/html
|
||||||
|
- ./config:/var/www/html/config
|
||||||
|
- ./custom_apps:/var/www/html/custom_apps
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
networks:
|
||||||
|
- caddy_net
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:10.11
|
||||||
|
container_name: nextcloud-db
|
||||||
|
hostname: nextcloud-db
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./db:/var/lib/mysql
|
||||||
|
networks:
|
||||||
|
- caddy_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy_net:
|
||||||
|
external: true
|
||||||
|
name: ${CADDY_NET:-caddy_net}
|
||||||
|
NCCOMPOSE
|
||||||
|
|
||||||
|
# ── .env ────────────────────────────────────────────────────────────────
|
||||||
|
cat > .env << NCENV
|
||||||
|
TZ=$TZ_VAL
|
||||||
|
CADDY_NET=$SITE_CADDY_NET
|
||||||
|
|
||||||
|
# MariaDB
|
||||||
|
MYSQL_ROOT_PASSWORD=$DB_PASS
|
||||||
|
MYSQL_DATABASE=nextcloud
|
||||||
|
MYSQL_USER=nextcloud
|
||||||
|
MYSQL_PASSWORD=$DB_PASS
|
||||||
|
MARIADB_AUTO_UPGRADE=1
|
||||||
|
|
||||||
|
# Nextcloud bootstrap (first run only)
|
||||||
|
NEXTCLOUD_ADMIN_USER=admin
|
||||||
|
NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS
|
||||||
|
NEXTCLOUD_DB_TYPE=mysql
|
||||||
|
MYSQL_HOST=db
|
||||||
|
|
||||||
|
# Reverse proxy (required for correct share links and redirects behind Caddy)
|
||||||
|
OVERWRITEPROTOCOL=https
|
||||||
|
OVERWRITECLIURL=https://cloud.${SITE_DOMAIN:-example.com}
|
||||||
|
TRUSTED_PROXIES=172.16.0.0/12
|
||||||
|
NCENV
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
# ── Subdirectories ──────────────────────────────────────────────────────
|
||||||
|
mkdir -p html config custom_apps db
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_success "Nextcloud configured at $DIR"
|
||||||
|
|
||||||
|
configure_caddy_for_service "Nextcloud" "nextcloud:80" "cloud"
|
||||||
|
|
||||||
|
# ── Prompt to start ─────────────────────────────────────────────────────
|
||||||
|
local START_NC=""
|
||||||
|
prompt_yn "Start Nextcloud now? (y/n):" "y" START_NC
|
||||||
|
if [ "$START_NC" = "y" ] || [ "$START_NC" = "Y" ]; then
|
||||||
|
docker compose up -d --build \
|
||||||
|
&& log_success "Nextcloud started" \
|
||||||
|
|| { log_warning "Start failed — check: docker compose logs"; return 1; }
|
||||||
|
|
||||||
|
# ── Wait for occ and enable files_external ──────────────────────────
|
||||||
|
log_info "Waiting for Nextcloud to initialize (up to 90s)..."
|
||||||
|
local _wait=0
|
||||||
|
until docker exec nextcloud php occ status 2>/dev/null | grep -q "installed: true"; do
|
||||||
|
sleep 5; _wait=$((_wait+5))
|
||||||
|
[ $_wait -ge 90 ] && { log_warning "Nextcloud not ready after 90s — enable files_external manually"; break; }
|
||||||
|
done
|
||||||
|
if docker exec nextcloud php occ app:enable files_external 2>/dev/null; then
|
||||||
|
log_success "files_external app enabled (SMB/local external storage)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── README ───────────────────────────────────────────────────────────────
|
||||||
|
write_readme "$DIR" << NCREADME
|
||||||
|
# Nextcloud
|
||||||
|
|
||||||
|
Self-hosted cloud storage with SMB/local file access.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
- URL: https://cloud.${SITE_DOMAIN:-example.com} (or http://localhost:8080)
|
||||||
|
- Admin: admin
|
||||||
|
- Password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`$DIR/.env\`
|
||||||
|
|
||||||
|
## Manage
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
docker compose up -d --build # start / rebuild
|
||||||
|
docker compose down # stop
|
||||||
|
docker compose logs -f # follow logs
|
||||||
|
docker compose pull && docker compose up -d --build # update
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## Run occ commands
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
docker exec -u www-data nextcloud php occ <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 " Access URL: http://localhost:8080"
|
||||||
|
echo " Admin user: admin"
|
||||||
|
echo " Admin pass: $NC_ADMIN_PASS"
|
||||||
|
echo " Config dir: $DIR"
|
||||||
|
echo ""
|
||||||
|
echo " Note: First startup may take 1-2 minutes while Nextcloud initialises."
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run immediately when executed directly (deferred until after function definition)
|
||||||
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_nextcloud
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser).
|
||||||
|
# Part of the modular post-install system (sourced by setup.sh).
|
||||||
|
#
|
||||||
|
# Can also be run standalone on any machine:
|
||||||
|
# sudo bash onlyoffice.sh
|
||||||
|
# (Docker must already be installed when run standalone)
|
||||||
|
|
||||||
|
# ── Standalone bootstrap ──────────────────────────────────────────────────────
|
||||||
|
# Detected when the script is executed directly rather than sourced by setup.sh.
|
||||||
|
# Sets up helpers and globals, then defers execution until after the function
|
||||||
|
# definition at the bottom of this file.
|
||||||
|
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||||
|
[[ "$(id -u)" == "0" ]] || { echo "Run with sudo: sudo bash $0"; exit 1; }
|
||||||
|
|
||||||
|
_SELF_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
_COMMON="$_SELF_DIR/../lib/common.sh"
|
||||||
|
|
||||||
|
if [[ -f "$_COMMON" ]]; then
|
||||||
|
# Full repo present — use the real helpers (picks up ~/docker/.config too)
|
||||||
|
# shellcheck source=../lib/common.sh
|
||||||
|
source "$_COMMON"
|
||||||
|
else
|
||||||
|
# One-off copy — inline minimal stubs so the script works without the repo
|
||||||
|
log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; }
|
||||||
|
log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; }
|
||||||
|
log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; }
|
||||||
|
log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; }
|
||||||
|
|
||||||
|
require_docker() {
|
||||||
|
command -v docker &>/dev/null || {
|
||||||
|
log_error "Docker not found. Install it first:"
|
||||||
|
log_error " curl -fsSL https://get.docker.com | sudo sh"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
docker compose version &>/dev/null || {
|
||||||
|
log_error "Docker Compose plugin missing:"
|
||||||
|
log_error " sudo apt-get install -y docker-compose-plugin"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_docker_dir_ownership() {
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
generate_password() {
|
||||||
|
local _len="${1:-32}"
|
||||||
|
tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_readme() {
|
||||||
|
local _dir="$1"; shift
|
||||||
|
[[ "${DRY_RUN:-false}" == "true" ]] && return 0
|
||||||
|
mkdir -p "$_dir"
|
||||||
|
cat > "$_dir/README.md"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Match common.sh's eval-based pattern so local vars in install_* are set correctly
|
||||||
|
prompt_text() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt_yn() {
|
||||||
|
local _q="$1" _def="$2" _var="$3" _r
|
||||||
|
[[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; }
|
||||||
|
read -r -p " $_q " _r
|
||||||
|
eval "$_var='${_r:-$_def}'"
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_caddy_for_service() {
|
||||||
|
local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}"
|
||||||
|
local _caddy_dir="$DOCKER_DIR/caddy"
|
||||||
|
local _caddyfile="$_caddy_dir/Caddyfile"
|
||||||
|
|
||||||
|
# Support remote Caddy host via CADDY_REMOTE_HOST
|
||||||
|
if [[ -n "${CADDY_REMOTE_HOST:-}" ]]; then
|
||||||
|
log_info "Remote Caddy detected at $CADDY_REMOTE_HOST — printing block to add manually."
|
||||||
|
echo ""
|
||||||
|
echo " Add the following to your Caddyfile on $CADDY_REMOTE_HOST:"
|
||||||
|
echo " ──────────────────────────────────────────────────────────"
|
||||||
|
echo " # $_name"
|
||||||
|
echo " ${_subdomain}.${SITE_DOMAIN:-example.com} {"
|
||||||
|
echo " reverse_proxy $_upstream"
|
||||||
|
[[ -n "$_extra" ]] && echo "$_extra"
|
||||||
|
echo " }"
|
||||||
|
echo " ──────────────────────────────────────────────────────────"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -d "$_caddy_dir" ]]; then
|
||||||
|
log_info "Access $_name directly on port ${_upstream##*:}."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
local _do_caddy=""
|
||||||
|
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||||||
|
[[ "${_do_caddy,,}" == "y" ]] || {
|
||||||
|
log_info "Skipping — access at: http://localhost:${_upstream##*:}"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
local _domain=""
|
||||||
|
read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain
|
||||||
|
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||||||
|
|
||||||
|
# Back up before touching
|
||||||
|
if [[ -f "$_caddyfile" ]]; then
|
||||||
|
local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)"
|
||||||
|
cp "$_caddyfile" "$_bk"
|
||||||
|
log_info "Backed up Caddyfile to $(basename "$_bk")"
|
||||||
|
else
|
||||||
|
touch "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove existing block for this domain if present
|
||||||
|
if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then
|
||||||
|
log_warning "$_domain already in Caddyfile"
|
||||||
|
local _ow=""
|
||||||
|
read -r -p " Overwrite? [y/N]: " _ow
|
||||||
|
[[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; }
|
||||||
|
sed -i "/^${_domain}/,/^}/d" "$_caddyfile"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >> "$_caddyfile" << CBLOCK
|
||||||
|
|
||||||
|
# $_name
|
||||||
|
$_domain {
|
||||||
|
reverse_proxy $_upstream
|
||||||
|
|
||||||
|
header {
|
||||||
|
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
|
||||||
|
X-Content-Type-Options "nosniff"
|
||||||
|
X-Frame-Options "SAMEORIGIN"
|
||||||
|
Referrer-Policy "strict-origin-when-cross-origin"
|
||||||
|
}
|
||||||
|
|
||||||
|
log {
|
||||||
|
output file /var/log/caddy/${_domain}.log
|
||||||
|
format json
|
||||||
|
}
|
||||||
|
${_extra}
|
||||||
|
}
|
||||||
|
CBLOCK
|
||||||
|
|
||||||
|
log_success "Added $_domain to Caddyfile"
|
||||||
|
docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true
|
||||||
|
if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then
|
||||||
|
log_success "$_name accessible at: https://$_domain"
|
||||||
|
else
|
||||||
|
log_warning "Reload failed — check: docker logs caddy"
|
||||||
|
log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR
|
||||||
|
# ($HOME under sudo is /root, not the real user's home)
|
||||||
|
ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}"
|
||||||
|
ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")"
|
||||||
|
DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}"
|
||||||
|
DRY_RUN="${DRY_RUN:-false}"
|
||||||
|
UNATTENDED="${UNATTENDED:-false}"
|
||||||
|
SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}"
|
||||||
|
SITE_DOMAIN="${SITE_DOMAIN:-example.com}"
|
||||||
|
SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||||||
|
|
||||||
|
register_service() { :; } # no-op — no wizard to register into
|
||||||
|
_RUN_STANDALONE=1
|
||||||
|
fi
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server (Nextcloud/FileBrowser)" 8082
|
||||||
|
|
||||||
|
# ── Helper: install yq v4 if absent ──────────────────────────────────────────
|
||||||
|
_ensure_yq() {
|
||||||
|
command -v yq &>/dev/null && return 0
|
||||||
|
log_info "Installing yq (required for FileBrowser config patching)..."
|
||||||
|
local _arch; _arch=$(uname -m)
|
||||||
|
local _binary="yq_linux_amd64"
|
||||||
|
[[ "$_arch" == "aarch64" || "$_arch" == "arm64" ]] && _binary="yq_linux_arm64"
|
||||||
|
curl -fsSL "https://github.com/mikefarah/yq/releases/latest/download/${_binary}" \
|
||||||
|
-o /usr/local/bin/yq && chmod +x /usr/local/bin/yq \
|
||||||
|
&& log_success "yq installed" || log_warning "yq install failed — FileBrowser wiring skipped"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Helper: wire OnlyOffice into Nextcloud (idempotent) ───────────────────────
|
||||||
|
_wire_nextcloud() {
|
||||||
|
local _jwt="$1"
|
||||||
|
local _nc_container="nextcloud"
|
||||||
|
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^${_nc_container}$" || {
|
||||||
|
log_info "Nextcloud container not running — skipping Nextcloud wiring"
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
log_info "Wiring OnlyOffice into Nextcloud..."
|
||||||
|
docker exec "$_nc_container" php occ app:enable onlyoffice 2>/dev/null || true
|
||||||
|
docker exec "$_nc_container" php occ config:system:set onlyoffice DocumentServerUrl \
|
||||||
|
--value="https://office.${SITE_DOMAIN:-example.com}/" 2>/dev/null \
|
||||||
|
&& log_success "DocumentServerUrl set" || log_warning "Could not set DocumentServerUrl"
|
||||||
|
docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_secret \
|
||||||
|
--value="$_jwt" 2>/dev/null \
|
||||||
|
&& log_success "jwt_secret set" || log_warning "Could not set jwt_secret"
|
||||||
|
docker exec "$_nc_container" php occ config:system:set onlyoffice jwt_header \
|
||||||
|
--value="AuthorizationJwt" 2>/dev/null \
|
||||||
|
&& log_success "jwt_header set" || log_warning "Could not set jwt_header"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Helper: patch FileBrowser Quantum config.yaml with OnlyOffice endpoint ───
|
||||||
|
_wire_filebrowser() {
|
||||||
|
local _fbq_config="$DOCKER_DIR/filebrowser/config.yaml"
|
||||||
|
[[ -f "$_fbq_config" ]] || { log_info "FileBrowser config not found — skipping"; return 0; }
|
||||||
|
command -v yq &>/dev/null || { log_info "yq not found — skipping FileBrowser wiring"; return 0; }
|
||||||
|
log_info "Wiring OnlyOffice into FileBrowser Quantum..."
|
||||||
|
yq e '.officeServer = "http://onlyoffice:80/"' -i "$_fbq_config" \
|
||||||
|
&& log_success "FileBrowser officeServer set" || log_warning "Could not patch FileBrowser config"
|
||||||
|
docker restart filebrowser 2>/dev/null && log_success "FileBrowser restarted" || true
|
||||||
|
}
|
||||||
|
|
||||||
|
install_onlyoffice() {
|
||||||
|
require_docker || return 1
|
||||||
|
_ensure_yq
|
||||||
|
|
||||||
|
log_info "Installing OnlyOffice Document Server..."
|
||||||
|
local DIR="$DOCKER_DIR/onlyoffice"
|
||||||
|
|
||||||
|
if [ "$DRY_RUN" = true ]; then
|
||||||
|
echo "[DRY-RUN] Would create $DIR with docker-compose.yml, .env"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$DIR"
|
||||||
|
ensure_docker_dir_ownership "$DIR"
|
||||||
|
cd "$DIR" || return 1
|
||||||
|
|
||||||
|
# ── Preserve JWT secret across re-runs ──────────────────────────────────
|
||||||
|
local JWT_SECRET=""
|
||||||
|
if [[ -f "$DIR/.env" ]]; then
|
||||||
|
JWT_SECRET=$(grep "^JWT_SECRET=" "$DIR/.env" 2>/dev/null | cut -d= -f2-)
|
||||||
|
fi
|
||||||
|
[[ -z "$JWT_SECRET" ]] && JWT_SECRET="$(generate_password 32)"
|
||||||
|
|
||||||
|
# ── docker-compose.yml ──────────────────────────────────────────────────
|
||||||
|
cat > docker-compose.yml << 'OOCOMPOSE'
|
||||||
|
name: onlyoffice
|
||||||
|
services:
|
||||||
|
onlyoffice:
|
||||||
|
image: onlyoffice/documentserver:latest
|
||||||
|
container_name: onlyoffice
|
||||||
|
hostname: onlyoffice
|
||||||
|
restart: unless-stopped
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- ./logs:/var/log/onlyoffice
|
||||||
|
- ./data:/var/www/onlyoffice/Data
|
||||||
|
- ./fonts:/usr/share/fonts/truetype/custom
|
||||||
|
ports:
|
||||||
|
- "8082:80"
|
||||||
|
networks:
|
||||||
|
- caddy_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
caddy_net:
|
||||||
|
external: true
|
||||||
|
name: ${CADDY_NET:-caddy_net}
|
||||||
|
OOCOMPOSE
|
||||||
|
|
||||||
|
# ── .env ────────────────────────────────────────────────────────────────
|
||||||
|
cat > .env << OOENV
|
||||||
|
CADDY_NET=$SITE_CADDY_NET
|
||||||
|
# JWT authentication — keep JWT_SECRET private
|
||||||
|
JWT_ENABLED=true
|
||||||
|
JWT_SECRET=$JWT_SECRET
|
||||||
|
JWT_HEADER=AuthorizationJwt
|
||||||
|
OOENV
|
||||||
|
chmod 600 .env
|
||||||
|
|
||||||
|
# ── Subdirectories ──────────────────────────────────────────────────────
|
||||||
|
mkdir -p logs data fonts
|
||||||
|
chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
log_success "OnlyOffice configured at $DIR"
|
||||||
|
|
||||||
|
# OnlyOffice must be embeddable as an iframe (Nextcloud / FileBrowser open
|
||||||
|
# documents in a frame). Override the default X-Frame-Options header that
|
||||||
|
# Caddy would otherwise set to SAMEORIGIN.
|
||||||
|
local OO_EXTRA_BLOCK=' header {
|
||||||
|
-X-Frame-Options
|
||||||
|
Content-Security-Policy "frame-ancestors '\''self'\'' *"
|
||||||
|
}'
|
||||||
|
configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" "$OO_EXTRA_BLOCK"
|
||||||
|
|
||||||
|
# ── Prompt to start ─────────────────────────────────────────────────────
|
||||||
|
local START_OO=""
|
||||||
|
prompt_yn "Start OnlyOffice now? (y/n):" "y" START_OO
|
||||||
|
if [ "$START_OO" = "y" ] || [ "$START_OO" = "Y" ]; then
|
||||||
|
docker compose up -d \
|
||||||
|
&& log_success "OnlyOffice started" \
|
||||||
|
|| log_warning "Start failed — check: docker compose logs"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Wire integrations (runs every install/re-install) ───────────────────
|
||||||
|
echo ""
|
||||||
|
_wire_nextcloud "$JWT_SECRET"
|
||||||
|
_wire_filebrowser
|
||||||
|
|
||||||
|
# ── README ───────────────────────────────────────────────────────────────
|
||||||
|
write_readme "$DIR" << OOREAD
|
||||||
|
# OnlyOffice Document Server
|
||||||
|
|
||||||
|
Self-hosted document editing server, integrated with Nextcloud and FileBrowser Quantum.
|
||||||
|
|
||||||
|
## Access
|
||||||
|
|
||||||
|
- URL: https://office.${SITE_DOMAIN:-example.com} (or http://localhost:8082)
|
||||||
|
- The document server itself has no user-facing login page — it is accessed
|
||||||
|
through Nextcloud or FileBrowser Quantum.
|
||||||
|
|
||||||
|
## Manage
|
||||||
|
|
||||||
|
\`\`\`bash
|
||||||
|
docker compose up -d # start
|
||||||
|
docker compose down # stop
|
||||||
|
docker compose logs -f # follow logs
|
||||||
|
docker compose pull && docker compose up -d # update
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
## JWT secret rotation
|
||||||
|
|
||||||
|
1. Generate a new secret:
|
||||||
|
\`\`\`bash
|
||||||
|
openssl rand -hex 24
|
||||||
|
\`\`\`
|
||||||
|
2. Update \`JWT_SECRET\` in \`$DIR/.env\`
|
||||||
|
3. Restart OnlyOffice:
|
||||||
|
\`\`\`bash
|
||||||
|
docker compose restart
|
||||||
|
\`\`\`
|
||||||
|
4. Update Nextcloud's stored secret:
|
||||||
|
\`\`\`bash
|
||||||
|
docker exec nextcloud php occ config:system:set onlyoffice jwt_secret --value="<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 " OnlyOffice Document Server"
|
||||||
|
echo " Access URL: http://localhost:8082"
|
||||||
|
echo " JWT secret: $JWT_SECRET"
|
||||||
|
echo " Config dir: $DIR"
|
||||||
|
echo ""
|
||||||
|
echo " Integration status:"
|
||||||
|
docker ps --format '{{.Names}}' 2>/dev/null | grep -q "^nextcloud$" \
|
||||||
|
&& echo " Nextcloud: wired (onlyoffice app + JWT configured)" \
|
||||||
|
|| echo " Nextcloud: not running — wire manually after starting Nextcloud"
|
||||||
|
[[ -f "$DOCKER_DIR/filebrowser/config.yaml" ]] \
|
||||||
|
&& echo " FileBrowser: config.yaml patched" \
|
||||||
|
|| echo " FileBrowser: config not found — will wire on next onlyoffice install"
|
||||||
|
echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run immediately when executed directly (deferred until after function definition)
|
||||||
|
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_onlyoffice
|
||||||
Vendored
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# ================================================================
|
||||||
|
# Easy Asterisk — Environment Configuration
|
||||||
|
# Copy to .env and fill in your values.
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
# ── Domain (REQUIRED for remote/FQDN access) ──────────────────
|
||||||
|
# Your FQDN pointing to this server's public IP.
|
||||||
|
# Leave empty for LAN-only mode.
|
||||||
|
DOMAIN_NAME=asterisk.example.com
|
||||||
|
|
||||||
|
# ── TURN/STUN ──────────────────────────────────────────────────
|
||||||
|
# Generate a strong password: openssl rand -base64 18
|
||||||
|
TURN_USERNAME=easyasterisk
|
||||||
|
TURN_PASSWORD=changeme
|
||||||
|
TURN_PORT=3478
|
||||||
|
# Points to coturn. For LAN-only leave empty.
|
||||||
|
TURN_SERVER=${DOMAIN_NAME}:${TURN_PORT}
|
||||||
|
|
||||||
|
# ── RTP port range ─────────────────────────────────────────────
|
||||||
|
RTP_START=10000
|
||||||
|
RTP_END=20000
|
||||||
|
|
||||||
|
# ── Web admin ──────────────────────────────────────────────────
|
||||||
|
WEB_ADMIN_PORT=8080
|
||||||
|
# Set to true if Authelia or another reverse proxy handles auth
|
||||||
|
WEB_ADMIN_AUTH_DISABLED=false
|
||||||
|
|
||||||
|
# ── Public IP (optional — auto-detected if empty) ─────────────
|
||||||
|
PUBLIC_IP=
|
||||||
|
|
||||||
|
# ── Local network CIDR (optional — auto-detected if empty) ────
|
||||||
|
LOCAL_CIDR=
|
||||||
Vendored
+60
@@ -0,0 +1,60 @@
|
|||||||
|
FROM ubuntu:24.04
|
||||||
|
|
||||||
|
ENV LANG=en_US.UTF-8 \
|
||||||
|
LANGUAGE=en_US:en \
|
||||||
|
LC_ALL=en_US.UTF-8 \
|
||||||
|
DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
asterisk \
|
||||||
|
asterisk-core-sounds-en \
|
||||||
|
asterisk-core-sounds-en-wav \
|
||||||
|
asterisk-moh-opsound-wav \
|
||||||
|
tcpdump \
|
||||||
|
sngrep \
|
||||||
|
curl \
|
||||||
|
dnsutils \
|
||||||
|
iproute2 \
|
||||||
|
net-tools \
|
||||||
|
openssl \
|
||||||
|
python3 \
|
||||||
|
python3-pip \
|
||||||
|
python3-bcrypt \
|
||||||
|
locales \
|
||||||
|
&& locale-gen en_US.UTF-8 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# NOTE: Opus transcoding (codec_opus.so) is NOT available on Ubuntu 24.04
|
||||||
|
# due to a packaging bug. Opus pass-through still works via res_format_attr_opus.so.
|
||||||
|
|
||||||
|
RUN mkdir -p /etc/asterisk/certs \
|
||||||
|
/var/lib/asterisk/static-http \
|
||||||
|
/var/log/asterisk \
|
||||||
|
/var/spool/asterisk \
|
||||||
|
/var/run/asterisk \
|
||||||
|
&& chown -R asterisk:asterisk \
|
||||||
|
/etc/asterisk \
|
||||||
|
/var/lib/asterisk \
|
||||||
|
/var/log/asterisk \
|
||||||
|
/var/spool/asterisk \
|
||||||
|
/var/run/asterisk
|
||||||
|
|
||||||
|
# Management script and helpers
|
||||||
|
COPY easy-asterisk-v0.10.0.sh /usr/local/bin/easy-asterisk
|
||||||
|
COPY scripts/vpn-diagnostics.sh /usr/local/bin/vpn-diagnostics
|
||||||
|
COPY scripts/dns-whitelist.sh /usr/local/bin/dns-whitelist
|
||||||
|
COPY docker/entrypoint.sh /entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/easy-asterisk \
|
||||||
|
/usr/local/bin/vpn-diagnostics \
|
||||||
|
/usr/local/bin/dns-whitelist \
|
||||||
|
/entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 5060/udp 5060/tcp 5061/tcp
|
||||||
|
EXPOSE 8080/tcp 8088/tcp 8089/tcp
|
||||||
|
EXPOSE 3478/udp
|
||||||
|
EXPOSE 10000-10100/udp
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD asterisk -rx "core show version" || exit 1
|
||||||
|
|
||||||
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# coturn-entrypoint.sh — robust wrapper for the coturn Docker image.
|
||||||
|
#
|
||||||
|
# The coturn image's default entrypoint uses:
|
||||||
|
# exec $(eval "echo $@")
|
||||||
|
# which fails when detect-external-ip returns empty — produces a blank token
|
||||||
|
# and coturn logs "ERROR: CONFIG: Unknown argument:"
|
||||||
|
#
|
||||||
|
# This wrapper avoids eval word-splitting and only adds --external-ip when
|
||||||
|
# an IP is actually obtained.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# Use explicitly set PUBLIC_IP, or try auto-detection
|
||||||
|
ext_ip="${PUBLIC_IP:-}"
|
||||||
|
if [[ -z "$ext_ip" ]] && command -v detect-external-ip &>/dev/null; then
|
||||||
|
ext_ip=$(detect-external-ip 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$ext_ip" ]]; then
|
||||||
|
exec turnserver "$@" --external-ip="$ext_ip"
|
||||||
|
else
|
||||||
|
exec turnserver "$@"
|
||||||
|
fi
|
||||||
+449
@@ -0,0 +1,449 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ================================================================
|
||||||
|
# Easy Asterisk Docker Entrypoint
|
||||||
|
#
|
||||||
|
# Fully automated:
|
||||||
|
# - Detects public IP
|
||||||
|
# - Generates TURN credentials if not provided
|
||||||
|
# - Configures Asterisk with FQDN, TLS, ICE, STUN, TURN
|
||||||
|
# - Starts web admin + Asterisk
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
RED='\033[0;31m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
log_info() { echo -e "${GREEN}[entrypoint]${NC} $1"; }
|
||||||
|
log_warn() { echo -e "${YELLOW}[entrypoint]${NC} $1"; }
|
||||||
|
log_error() { echo -e "${RED}[entrypoint]${NC} $1"; }
|
||||||
|
|
||||||
|
CONFIG_DIR="/etc/easy-asterisk"
|
||||||
|
CONFIG_FILE="${CONFIG_DIR}/config"
|
||||||
|
WEB_ADMIN_SCRIPT="/usr/local/bin/easy-asterisk-webadmin"
|
||||||
|
|
||||||
|
# ── Helper: generate random password ─────────────────────────
|
||||||
|
gen_password() {
|
||||||
|
openssl rand -base64 18 | tr -dc 'a-zA-Z0-9' | head -c 24
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── 1. Ensure asterisk user exists ───────────────────────────
|
||||||
|
if ! id asterisk >/dev/null 2>&1; then
|
||||||
|
useradd -r -s /bin/false -d /var/lib/asterisk asterisk 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Detect public IP ──────────────────────────────────────
|
||||||
|
PUBLIC_IP="${PUBLIC_IP:-}"
|
||||||
|
if [[ -z "$PUBLIC_IP" ]]; then
|
||||||
|
log_info "Auto-detecting public IP..."
|
||||||
|
PUBLIC_IP=$(curl -s -4 --connect-timeout 5 ifconfig.me 2>/dev/null || true)
|
||||||
|
if [[ -z "$PUBLIC_IP" ]]; then
|
||||||
|
PUBLIC_IP=$(curl -s -4 --connect-timeout 5 icanhazip.com 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
if [[ -z "$PUBLIC_IP" ]]; then
|
||||||
|
PUBLIC_IP=$(curl -s -4 --connect-timeout 5 api.ipify.org 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$PUBLIC_IP" ]]; then
|
||||||
|
log_info "Public IP: ${PUBLIC_IP}"
|
||||||
|
else
|
||||||
|
log_warn "Could not detect public IP. Set PUBLIC_IP in .env"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. TURN credentials ─────────────────────────────────────────
|
||||||
|
# The password MUST match what coturn was started with. In Docker, both
|
||||||
|
# read from the same env-var / .env file, so we use the value as-is.
|
||||||
|
# Auto-generating a different password here would create a mismatch
|
||||||
|
# (coturn is already running with ITS copy of the env-var).
|
||||||
|
TURN_USERNAME="${TURN_USERNAME:-easyasterisk}"
|
||||||
|
TURN_PASSWORD="${TURN_PASSWORD:-changeme}"
|
||||||
|
if [[ "${TURN_PASSWORD}" == "changeme" ]]; then
|
||||||
|
log_warn "TURN password is the default 'changeme' — set TURN_PASSWORD in .env for better security"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Detect local network ──────────────────────────────────
|
||||||
|
local_ip=$(hostname -I 2>/dev/null | awk '{print $1}')
|
||||||
|
raw_cidr=$(ip -o -f inet addr show 2>/dev/null | awk '/scope global/ {print $4}' | head -1)
|
||||||
|
default_cidr="$raw_cidr"
|
||||||
|
if [[ "$raw_cidr" =~ \.([0-9]+)/([0-9]+)$ ]]; then
|
||||||
|
default_cidr="${raw_cidr%.*}.0/${BASH_REMATCH[2]}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 5. Generate self-signed certs ──────────────────────────────
|
||||||
|
# Regenerate if missing OR if existing cert lacks SANs (modern TLS clients require them)
|
||||||
|
regen_cert=false
|
||||||
|
if [[ ! -f /etc/asterisk/certs/server.crt ]]; then
|
||||||
|
regen_cert=true
|
||||||
|
elif ! openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:"; then
|
||||||
|
log_info "Existing TLS cert lacks SANs — regenerating for mobile phone compatibility"
|
||||||
|
regen_cert=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $regen_cert; then
|
||||||
|
log_info "Generating self-signed TLS certificate..."
|
||||||
|
mkdir -p /etc/asterisk/certs
|
||||||
|
cn="${DOMAIN_NAME:-asterisk-local}"
|
||||||
|
# Include Subject Alternative Names — required by modern TLS clients (iOS/Android SIP apps)
|
||||||
|
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \
|
||||||
|
-keyout /etc/asterisk/certs/server.key \
|
||||||
|
-out /etc/asterisk/certs/server.crt \
|
||||||
|
-subj "/CN=${cn}" \
|
||||||
|
-addext "subjectAltName=DNS:${cn}${PUBLIC_IP:+,IP:${PUBLIC_IP}}" \
|
||||||
|
2>/dev/null
|
||||||
|
chown asterisk:asterisk /etc/asterisk/certs/server.*
|
||||||
|
chmod 644 /etc/asterisk/certs/server.crt
|
||||||
|
chmod 600 /etc/asterisk/certs/server.key
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 6. Write config file ─────────────────────────────────────
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
|
||||||
|
# Determine TURN/STUN server address
|
||||||
|
turn_server="${TURN_SERVER:-${DOMAIN_NAME:-$local_ip}:${TURN_PORT:-3478}}"
|
||||||
|
|
||||||
|
cat > "$CONFIG_FILE" << EOF
|
||||||
|
# Easy Asterisk Configuration (Docker) - $(date)
|
||||||
|
KIOSK_USER=""
|
||||||
|
KIOSK_UID=""
|
||||||
|
KIOSK_EXTENSION=""
|
||||||
|
KIOSK_NAME=""
|
||||||
|
SIP_PASSWORD=""
|
||||||
|
ASTERISK_HOST="${DOMAIN_NAME:-$local_ip}"
|
||||||
|
DOMAIN_NAME="${DOMAIN_NAME:-}"
|
||||||
|
ENABLE_TLS="${ENABLE_TLS:-y}"
|
||||||
|
HAS_VLANS="${HAS_VLANS:-n}"
|
||||||
|
VLAN_SUBNETS="${VLAN_SUBNETS:-}"
|
||||||
|
CERT_PATH=""
|
||||||
|
KEY_PATH=""
|
||||||
|
INSTALLED_SERVER="y"
|
||||||
|
INSTALLED_CLIENT="n"
|
||||||
|
CURRENT_PUBLIC_IP="${PUBLIC_IP}"
|
||||||
|
PTT_DEVICE=""
|
||||||
|
PTT_KEYCODE=""
|
||||||
|
LOCAL_CIDR="${LOCAL_CIDR:-$default_cidr}"
|
||||||
|
WEB_ADMIN_PORT="${WEB_ADMIN_PORT:-8080}"
|
||||||
|
WEB_ADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}"
|
||||||
|
VPN_ICE_ENABLED="y"
|
||||||
|
CUSTOM_STUN_SERVER="${turn_server}"
|
||||||
|
TURN_ENABLED="y"
|
||||||
|
TURN_SERVER="${turn_server}"
|
||||||
|
TURN_USERNAME="${TURN_USERNAME}"
|
||||||
|
TURN_PASSWORD="${TURN_PASSWORD}"
|
||||||
|
EOF
|
||||||
|
chmod 644 "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# ── 7. Initialize categories & rooms if missing ──────────────
|
||||||
|
CATEGORIES_FILE="${CONFIG_DIR}/categories.conf"
|
||||||
|
if [[ ! -f "$CATEGORIES_FILE" ]]; then
|
||||||
|
log_info "Creating default device categories..."
|
||||||
|
cat > "$CATEGORIES_FILE" << 'EOF'
|
||||||
|
kiosks|Kiosks|yes|Fixed wall-mount tablets & intercoms
|
||||||
|
mobile|Mobile|no|Phones & tablets (ring normally)
|
||||||
|
custom|Custom|no|Custom configuration
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
ROOMS_FILE="${CONFIG_DIR}/rooms.conf"
|
||||||
|
if [[ ! -f "$ROOMS_FILE" ]]; then
|
||||||
|
cat > "$ROOMS_FILE" << 'EOF'
|
||||||
|
# ext|name|members|timeout|type
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 8. Generate Asterisk configs ─────────────────────────────
|
||||||
|
|
||||||
|
# Build local_net entries
|
||||||
|
all_local_nets="local_net=${LOCAL_CIDR:-$default_cidr}"
|
||||||
|
if [[ "${HAS_VLANS:-n}" == "y" && -n "${VLAN_SUBNETS:-}" ]]; then
|
||||||
|
for subnet in $VLAN_SUBNETS; do
|
||||||
|
all_local_nets="${all_local_nets}
|
||||||
|
local_net=${subnet}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# NAT settings - always include external addresses for FQDN mode
|
||||||
|
nat_settings=""
|
||||||
|
if [[ -n "$PUBLIC_IP" ]]; then
|
||||||
|
nat_settings="external_media_address=${PUBLIC_IP}
|
||||||
|
external_signaling_address=${PUBLIC_IP}
|
||||||
|
${all_local_nets}"
|
||||||
|
else
|
||||||
|
nat_settings="${all_local_nets}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── pjsip.conf (only if empty/missing - preserves existing devices) ──
|
||||||
|
if [[ ! -f /etc/asterisk/pjsip.conf ]] || [[ ! -s /etc/asterisk/pjsip.conf ]]; then
|
||||||
|
log_info "Generating PJSIP configuration..."
|
||||||
|
cat > /etc/asterisk/pjsip.conf << EOF
|
||||||
|
; Easy Asterisk (Docker) - FQDN: ${DOMAIN_NAME:-none}
|
||||||
|
[global]
|
||||||
|
type=global
|
||||||
|
user_agent=EasyAsterisk
|
||||||
|
|
||||||
|
[transport-udp]
|
||||||
|
type=transport
|
||||||
|
protocol=udp
|
||||||
|
bind=0.0.0.0:5060
|
||||||
|
; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown}
|
||||||
|
${nat_settings}
|
||||||
|
|
||||||
|
[transport-tcp]
|
||||||
|
type=transport
|
||||||
|
protocol=tcp
|
||||||
|
bind=0.0.0.0:5060
|
||||||
|
; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown}
|
||||||
|
${nat_settings}
|
||||||
|
|
||||||
|
[transport-tls]
|
||||||
|
type=transport
|
||||||
|
protocol=tls
|
||||||
|
bind=0.0.0.0:5061
|
||||||
|
; Server IP: ${local_ip} | Public IP: ${PUBLIC_IP:-unknown}
|
||||||
|
cert_file=/etc/asterisk/certs/server.crt
|
||||||
|
priv_key_file=/etc/asterisk/certs/server.key
|
||||||
|
; ca_list_file not set — only needed for verify_client=yes (client cert auth)
|
||||||
|
method=tlsv1_2
|
||||||
|
${nat_settings}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
chown asterisk:asterisk /etc/asterisk/pjsip.conf
|
||||||
|
else
|
||||||
|
# Update NAT settings in existing pjsip.conf transports if public IP changed
|
||||||
|
if [[ -n "$PUBLIC_IP" ]]; then
|
||||||
|
current_ext=$(grep "^external_media_address=" /etc/asterisk/pjsip.conf 2>/dev/null | head -1 | cut -d= -f2)
|
||||||
|
if [[ "$current_ext" != "$PUBLIC_IP" && -n "$current_ext" ]]; then
|
||||||
|
log_info "Updating public IP in pjsip.conf: ${current_ext} -> ${PUBLIC_IP}"
|
||||||
|
sed -i "s|external_media_address=.*|external_media_address=${PUBLIC_IP}|g" /etc/asterisk/pjsip.conf
|
||||||
|
sed -i "s|external_signaling_address=.*|external_signaling_address=${PUBLIC_IP}|g" /etc/asterisk/pjsip.conf
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Sanitize pjsip.conf: remove endpoint-only options from aor sections ──
|
||||||
|
if [[ -f /etc/asterisk/pjsip.conf ]]; then
|
||||||
|
# Options that are only valid in [endpoint] sections, not in [aor] sections
|
||||||
|
endpoint_only_opts="direct_media|rtp_symmetric|force_rport|rewrite_contact|rtp_keepalive|rtp_timeout|rtp_timeout_hold|ice_support|context|disallow|allow|auth|aors|callerid|media_encryption|transport"
|
||||||
|
current_type=""
|
||||||
|
needs_fix=false
|
||||||
|
while IFS= read -r line; do
|
||||||
|
if [[ "$line" =~ ^type=(.*) ]]; then
|
||||||
|
current_type="${BASH_REMATCH[1]}"
|
||||||
|
fi
|
||||||
|
if [[ "$current_type" == "aor" ]] && echo "$line" | grep -qE "^(${endpoint_only_opts})="; then
|
||||||
|
needs_fix=true
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done < /etc/asterisk/pjsip.conf
|
||||||
|
|
||||||
|
if $needs_fix; then
|
||||||
|
log_info "Sanitizing pjsip.conf (removing misplaced options from aor sections)..."
|
||||||
|
awk -v opts="$endpoint_only_opts" '
|
||||||
|
BEGIN { split(opts, arr, "|"); for (i in arr) bad[arr[i]]=1 }
|
||||||
|
/^type=/ { current_type = substr($0, 6) }
|
||||||
|
{
|
||||||
|
if (current_type == "aor") {
|
||||||
|
split($0, kv, "=")
|
||||||
|
if (kv[1] in bad) next
|
||||||
|
}
|
||||||
|
print
|
||||||
|
}
|
||||||
|
' /etc/asterisk/pjsip.conf > /tmp/pjsip_sanitized.conf
|
||||||
|
mv /tmp/pjsip_sanitized.conf /etc/asterisk/pjsip.conf
|
||||||
|
chown asterisk:asterisk /etc/asterisk/pjsip.conf
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Ensure transport-tls exists in pjsip.conf (upgrade / migration path) ──
|
||||||
|
# If pjsip.conf was preserved from a pre-TLS config or a non-Docker install it
|
||||||
|
# will have no [transport-tls] section. Asterisk starts without TLS silently,
|
||||||
|
# and mobile devices cannot register. Inject the section when it is absent.
|
||||||
|
if [[ -f /etc/asterisk/pjsip.conf ]] && ! grep -q "^\[transport-tls\]" /etc/asterisk/pjsip.conf; then
|
||||||
|
log_info "transport-tls missing from pjsip.conf — adding TLS transport (required for mobile registration)..."
|
||||||
|
cat >> /etc/asterisk/pjsip.conf << EOF
|
||||||
|
|
||||||
|
[transport-tls]
|
||||||
|
type=transport
|
||||||
|
protocol=tls
|
||||||
|
bind=0.0.0.0:5061
|
||||||
|
cert_file=/etc/asterisk/certs/server.crt
|
||||||
|
priv_key_file=/etc/asterisk/certs/server.key
|
||||||
|
; ca_list_file not set — only needed for verify_client=yes (client cert auth)
|
||||||
|
method=tlsv1_2
|
||||||
|
${nat_settings}
|
||||||
|
|
||||||
|
EOF
|
||||||
|
chown asterisk:asterisk /etc/asterisk/pjsip.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── rtp.conf (always regenerated) ──
|
||||||
|
# ICE is enabled so Asterisk participates in ICE negotiation with clients.
|
||||||
|
# stunaddr/turnaddr are NOT set here because:
|
||||||
|
# - Asterisk already knows its public IP via external_media_address in pjsip.conf
|
||||||
|
# - Its RTP ports are port-forwarded, so host candidates are sufficient
|
||||||
|
# - Setting stunaddr/turnaddr causes STUN/TURN gather timeouts (~27s per call)
|
||||||
|
# when the STUN/TURN server is unreachable or misconfigured
|
||||||
|
# coturn is for SIP CLIENTS behind strict NAT — they configure TURN in their
|
||||||
|
# own app settings, independently of Asterisk's rtp.conf.
|
||||||
|
log_info "Configuring RTP with ICE support..."
|
||||||
|
cat > /etc/asterisk/rtp.conf << EOF
|
||||||
|
[general]
|
||||||
|
rtpstart=${RTP_START:-10000}
|
||||||
|
rtpend=${RTP_END:-20000}
|
||||||
|
strictrtp=yes
|
||||||
|
icesupport=yes
|
||||||
|
EOF
|
||||||
|
chown asterisk:asterisk /etc/asterisk/rtp.conf
|
||||||
|
|
||||||
|
# ── extensions.conf (only if missing) ──
|
||||||
|
if [[ ! -f /etc/asterisk/extensions.conf ]] || [[ ! -s /etc/asterisk/extensions.conf ]]; then
|
||||||
|
log_info "Generating dialplan..."
|
||||||
|
cat > /etc/asterisk/extensions.conf << 'EOF'
|
||||||
|
[general]
|
||||||
|
static=yes
|
||||||
|
writeprotect=no
|
||||||
|
[default]
|
||||||
|
exten => _X.,1,Hangup()
|
||||||
|
[intercom]
|
||||||
|
EOF
|
||||||
|
chown asterisk:asterisk /etc/asterisk/extensions.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Other core configs (only if missing) ──
|
||||||
|
if [[ ! -f /etc/asterisk/asterisk.conf ]]; then
|
||||||
|
cat > /etc/asterisk/asterisk.conf << 'EOF'
|
||||||
|
[directories]
|
||||||
|
[options]
|
||||||
|
runuser = asterisk
|
||||||
|
rungroup = asterisk
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── logger.conf (always regenerated - ensures security logging is on) ──
|
||||||
|
cat > /etc/asterisk/logger.conf << 'EOF'
|
||||||
|
[general]
|
||||||
|
[logfiles]
|
||||||
|
; security level captures TLS handshake failures and auth issues
|
||||||
|
console => notice,warning,error,security
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── modules.conf (always regenerated - ensures chan_sip stays disabled) ──
|
||||||
|
cat > /etc/asterisk/modules.conf << 'EOF'
|
||||||
|
[modules]
|
||||||
|
autoload=yes
|
||||||
|
noload => chan_sip.so
|
||||||
|
noload => chan_iax2.so
|
||||||
|
; Opus transcoding unavailable on Ubuntu 24.04 (bug #2044135)
|
||||||
|
; Opus pass-through still works via res_format_attr_opus.so
|
||||||
|
noload => codec_opus.so
|
||||||
|
noload => format_ogg_opus.so
|
||||||
|
load => res_pjsip.so
|
||||||
|
load => res_pjsip_session.so
|
||||||
|
load => res_pjsip_logger.so
|
||||||
|
load => chan_pjsip.so
|
||||||
|
load => codec_ulaw.so
|
||||||
|
load => codec_alaw.so
|
||||||
|
load => codec_g722.so
|
||||||
|
load => res_rtp_asterisk.so
|
||||||
|
load => app_dial.so
|
||||||
|
load => app_page.so
|
||||||
|
load => pbx_config.so
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# ── Remove incompatible Digium codec_opus if present on volume ──
|
||||||
|
# The Digium binary is ABI-incompatible with Ubuntu 24.04's Asterisk and crashes it
|
||||||
|
MODULES_DIR=$(find /usr/lib -type d -name modules -path "*/asterisk/*" 2>/dev/null | head -1)
|
||||||
|
if [[ -n "$MODULES_DIR" ]]; then
|
||||||
|
for bad_module in codec_opus.so format_ogg_opus.so; do
|
||||||
|
if [[ -f "$MODULES_DIR/$bad_module" ]] && ! dpkg -S "$MODULES_DIR/$bad_module" >/dev/null 2>&1; then
|
||||||
|
log_warn "Removing incompatible $bad_module (not from Ubuntu package)"
|
||||||
|
rm -f "$MODULES_DIR/$bad_module"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 9. Fix permissions ───────────────────────────────────────
|
||||||
|
chown -R asterisk:asterisk /etc/asterisk /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk 2>/dev/null || true
|
||||||
|
|
||||||
|
# ── 10. Start Web Admin in background ─────────────────────────
|
||||||
|
# The web admin script is generated by the 'easy-asterisk' management tool.
|
||||||
|
# On first run: docker exec -it easy-asterisk easy-asterisk → Web Admin menu → Start
|
||||||
|
if [[ -f "$WEB_ADMIN_SCRIPT" ]]; then
|
||||||
|
log_info "Starting Web Admin on port ${WEB_ADMIN_PORT:-8080}..."
|
||||||
|
WEBADMIN_PORT="${WEB_ADMIN_PORT:-8080}" \
|
||||||
|
WEBADMIN_AUTH_DISABLED="${WEB_ADMIN_AUTH_DISABLED:-false}" \
|
||||||
|
python3 "$WEB_ADMIN_SCRIPT" &
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 11. Signal handling for clean shutdown ────────────────────
|
||||||
|
cleanup() {
|
||||||
|
log_info "Shutting down..."
|
||||||
|
pkill -f "easy-asterisk-webadmin" 2>/dev/null || true
|
||||||
|
asterisk -rx "core stop now" 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
trap cleanup SIGTERM SIGINT
|
||||||
|
|
||||||
|
# ── 12. Start Asterisk ───────────────────────────────────────
|
||||||
|
log_info "Starting Asterisk PBX..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start Asterisk in the background, then print management info once ready
|
||||||
|
asterisk -f -U asterisk -G asterisk &
|
||||||
|
ASTERISK_PID=$!
|
||||||
|
|
||||||
|
# Wait for Asterisk to be ready (up to 60 seconds)
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if asterisk -rx "core show version" >/dev/null 2>&1; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Verify PJSIP transports are listening
|
||||||
|
tls_ok=false
|
||||||
|
udp_ok=false
|
||||||
|
if asterisk -rx "pjsip show transports" 2>/dev/null | grep -q "transport-tls"; then
|
||||||
|
tls_ok=true
|
||||||
|
fi
|
||||||
|
if asterisk -rx "pjsip show transports" 2>/dev/null | grep -q "transport-udp"; then
|
||||||
|
udp_ok=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check if port 5061 is actually bound
|
||||||
|
tls_listen=""
|
||||||
|
if command -v ss &>/dev/null; then
|
||||||
|
tls_listen=$(ss -tlnp 2>/dev/null | grep ":5061 " || true)
|
||||||
|
elif command -v netstat &>/dev/null; then
|
||||||
|
tls_listen=$(netstat -tlnp 2>/dev/null | grep ":5061 " || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e "${CYAN} Easy Asterisk (Docker)${NC}"
|
||||||
|
echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo -e " FQDN: ${GREEN}${DOMAIN_NAME:-not set}${NC}"
|
||||||
|
echo -e " Public IP: ${GREEN}${PUBLIC_IP:-unknown}${NC}"
|
||||||
|
echo -e " TURN/STUN: ${GREEN}${turn_server}${NC}"
|
||||||
|
if $tls_ok && [[ -n "$tls_listen" ]]; then
|
||||||
|
echo -e " TLS: ${GREEN}Enabled (port 5061)${NC}"
|
||||||
|
elif $tls_ok; then
|
||||||
|
echo -e " TLS: ${YELLOW}Transport loaded but port 5061 not bound — check certs${NC}"
|
||||||
|
else
|
||||||
|
echo -e " TLS: ${RED}NOT LOADED — check Asterisk logs${NC}"
|
||||||
|
fi
|
||||||
|
echo -e " ICE: ${GREEN}Enabled${NC}"
|
||||||
|
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
|
||||||
|
echo -e " SIP clients connect to: ${GREEN}${DOMAIN_NAME:-$local_ip}:5061${NC} (TLS)"
|
||||||
|
echo -e " Web Admin: ${GREEN}http://${local_ip}:${WEB_ADMIN_PORT:-8080}/clients${NC}"
|
||||||
|
echo -e "${CYAN}──────────────────────────────────────────────────────────────${NC}"
|
||||||
|
echo -e " Management: ${YELLOW}docker exec -it easy-asterisk easy-asterisk${NC}"
|
||||||
|
echo -e " Diagnostics: docker exec -it easy-asterisk vpn-diagnostics"
|
||||||
|
echo -e "${CYAN}══════════════════════════════════════════════════════════════${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Wait for Asterisk process (keeps container running)
|
||||||
|
wait $ASTERISK_PID
|
||||||
+6929
File diff suppressed because it is too large
Load Diff
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ================================================================
|
||||||
|
# DNS Whitelist Checker for Easy Asterisk
|
||||||
|
#
|
||||||
|
# Checks which domains need to be whitelisted when DNS filtering
|
||||||
|
# is active on the server, caller, or receiver networks.
|
||||||
|
#
|
||||||
|
# Usage: dns-whitelist [--check] [--sipnetic] [--linphone]
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
|
CYAN='\033[0;36m'
|
||||||
|
BOLD='\033[1m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
CONFIG_FILE="/etc/easy-asterisk/config"
|
||||||
|
CHECK_MODE=false
|
||||||
|
SHOW_SIPNETIC=false
|
||||||
|
SHOW_LINPHONE=false
|
||||||
|
SHOW_ALL=true
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--check) CHECK_MODE=true; shift ;;
|
||||||
|
--sipnetic) SHOW_SIPNETIC=true; SHOW_ALL=false; shift ;;
|
||||||
|
--linphone) SHOW_LINPHONE=true; SHOW_ALL=false; shift ;;
|
||||||
|
--help|-h)
|
||||||
|
echo "Usage: dns-whitelist [OPTIONS]"
|
||||||
|
echo " --check Test reachability of each domain"
|
||||||
|
echo " --sipnetic Show Sipnetic-specific domains"
|
||||||
|
echo " --linphone Show Linphone-specific domains"
|
||||||
|
exit 0 ;;
|
||||||
|
*) shift ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
source "$CONFIG_FILE" 2>/dev/null || true
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}━━━ DNS Whitelist for Easy Asterisk ━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Mode: ${NC}$( [[ -n "$DOMAIN_NAME" ]] && echo "FQDN ($DOMAIN_NAME)" || echo "LAN/VPN (no domain)" )"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Server DNS filter:${NC}"
|
||||||
|
echo -e " ifconfig.me, icanhazip.com (public IP detection, FQDN mode only)"
|
||||||
|
echo -e " acme-v02.api.letsencrypt.org (Let's Encrypt, if used)"
|
||||||
|
echo ""
|
||||||
|
echo -e "${BOLD}Client DNS filter (Sipnetic/Linphone):${NC}"
|
||||||
|
echo -e " LAN/VPN mode: none (configure by IP)"
|
||||||
|
echo -e " FQDN mode: your domain ($DOMAIN_NAME)"
|
||||||
|
echo ""
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# ================================================================
|
||||||
|
# VPN Diagnostics for Easy Asterisk
|
||||||
|
# Validates PJSIP, TLS, ICE, RTP, and device configuration.
|
||||||
|
# Usage: vpn-diagnostics [--auto] [--client-ip <ip>]
|
||||||
|
# ================================================================
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
|
||||||
|
|
||||||
|
echo -e "${CYAN}━━━ Easy Asterisk VPN Diagnostics ━━━${NC}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check Asterisk is running
|
||||||
|
if ! asterisk -rx "core show version" &>/dev/null; then
|
||||||
|
echo -e "${RED}✗ Asterisk is not running${NC}"; exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}✓ Asterisk running:${NC} $(asterisk -rx "core show version" 2>/dev/null)"
|
||||||
|
|
||||||
|
# Check transports
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Transports:${NC}"
|
||||||
|
asterisk -rx "pjsip show transports" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check registered endpoints
|
||||||
|
echo ""
|
||||||
|
echo -e "${CYAN}Endpoints:${NC}"
|
||||||
|
asterisk -rx "pjsip show endpoints" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Check TLS cert
|
||||||
|
if [[ -f /etc/asterisk/certs/server.crt ]]; then
|
||||||
|
exp=$(openssl x509 -in /etc/asterisk/certs/server.crt -noout -enddate 2>/dev/null | cut -d= -f2)
|
||||||
|
echo -e "${GREEN}✓ TLS cert:${NC} expires $exp"
|
||||||
|
openssl x509 -in /etc/asterisk/certs/server.crt -noout -ext subjectAltName 2>/dev/null | grep -q "DNS:" \
|
||||||
|
&& echo -e "${GREEN}✓ SANs present (mobile-compatible)${NC}" \
|
||||||
|
|| echo -e "${YELLOW}! No SANs — mobile clients may reject cert${NC}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
Reference in New Issue
Block a user