#!/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" # Mirrors configure_caddy_for_service's own mode resolution (lib/common.sh): # explicit CADDY_MODE from the site config wins, then a local ~/docker/caddy, # then the legacy CADDY_REMOTE_HOST var. Only "local" joins caddy_net — a # remote Caddy box can't resolve container names on this host's bridge # network anyway; it reaches this service via the host's published port. local _CADDY_MODE="${CADDY_MODE:-none}" [ "$_CADDY_MODE" = "none" ] && [ -d "$DOCKER_DIR/caddy" ] && _CADDY_MODE="local" [ "$_CADDY_MODE" = "none" ] && [ -n "${CADDY_REMOTE_HOST:-}" ] && _CADDY_MODE="remote" local _CADDY_NET_BLOCK="" local _CADDY_NET_SECTION="" if [ "$_CADDY_MODE" = "local" ]; then _CADDY_NET_BLOCK=" networks: - caddy_net " _CADDY_NET_SECTION=" networks: caddy_net: external: true name: ${SITE_CADDY_NET:-caddy_net} " fi 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 ${_CADDY_NET_BLOCK} 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" ${_CADDY_NET_BLOCK} 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 ${_CADDY_NET_SECTION} 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 — this account becomes System Admin automatically. ## Teams Team Edition (the free edition this installer uses) includes multiple Teams natively — separate spaces (their own channels, their own members) on the same server, same database, same login. No Enterprise license needed; this is the resource-efficient alternative to running a second Mattermost instance for a second group. Create one: - Click the **+** at the bottom of the team sidebar (the narrow column on the far left) → **Create a new team**, or - System Console → User Management → Teams → **Create Team** Add people to a team: - Team name (top-left) → **Invite People** → share the invite link, or send email invites (requires SMTP — System Console → Environment → SMTP), or - System Console → User Management → Teams → the team → **Add Members** (adds existing server accounts directly, no invite flow) Users can belong to more than one team and switch between them via the team sidebar icons. By default any user can create a team — restrict that at System Console → User Management → Permissions if you only want admins creating them. ## 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 " Teams: team sidebar '+' → Create a new team (see README.md — no" echo " Enterprise license needed, Team Edition includes this)." 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