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 |
|
||||
|-------|---------|
|
||||
| `base` | `net-tools`, `ncdu`, `git`, `curl`, `wget`, `htop`, `tree`, `zip`/`unzip`, `ca-certificates`, `gnupg`, `jq`, `rsync`; `glow` (terminal markdown reader, Charm apt repo) |
|
||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant` |
|
||||
| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mealie`, `meshcentral`, `ntfy`, `portainer`, `rustdesk`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
|
||||
| `homelab` | `caddy`, `crowdsec`, `authelia`, `homeassistant`, `asterisk` |
|
||||
| `utilities` | `actualbudget`, `ddclient`, `filebrowser`, `fmd`, `gatus`, `magicmirror`, `mail-archiver`, `mattermost`, `mealie`, `meshcentral`, `nextcloud`, `ntfy`, `onlyoffice`, `portainer`, `rustdesk`, `syncthing`, `traccar`, `unifi`, `uptimekuma`, `vaultwarden`, `watchyourlan`, `watchtower`, `wg-easy` |
|
||||
| `media` | `arm`, `audiobookshelf`, `emby`, `immich`, `jellyfin`, `lyrion` |
|
||||
| `cameras` | `frigate`, `frigate-audio`, `frigate-notify`, `sky-cam` |
|
||||
| `gaming` | `js99er`, `minecraft`, `wolf`, `wolf-pair` |
|
||||
|
||||
@@ -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