diff --git a/services/mattermost.sh b/services/mattermost.sh new file mode 100644 index 0000000..b85a58c --- /dev/null +++ b/services/mattermost.sh @@ -0,0 +1,431 @@ +#!/bin/bash +# services/mattermost.sh — Team messaging with voice/video calls (Mattermost + coturn). +# Part of the modular post-install system (sourced by setup.sh). +# +# Mattermost Team Edition with PostgreSQL and a dedicated coturn TURN server +# (port 3479 — distinct from Easy Asterisk's coturn on 3478). +# +# Can also be run standalone on any machine: +# sudo bash mattermost.sh +# (Docker must already be installed when run standalone) + +# ── Standalone bootstrap ────────────────────────────────────────────────────── +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 + # shellcheck source=../lib/common.sh + source "$_COMMON" + else + 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 + } + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' /dev/null || true + } + + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port ${_upstream##*:}." + return 0 + fi + + echo "" + local _do_caddy="" + read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy + [[ "${_do_caddy,,}" == "y" ]] || { + log_info "Skipping — access at: http://localhost:${_upstream##*:}" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + 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() { :; } + _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 Team Edition..." + + local DIR="$DOCKER_DIR/mattermost" + + if [ "$DRY_RUN" = true ]; then + echo "[DRY-RUN] Would create $DIR with subdirectories: data logs config plugins db" + echo "[DRY-RUN] Would generate DB password, MM secret key, and TURN secret" + echo "[DRY-RUN] Would write docker-compose.yml and .env" + echo "[DRY-RUN] Would open UFW ports: 3479/udp+tcp, 49153-49352/udp" + echo "[DRY-RUN] Would configure Caddy reverse proxy for Mattermost" + return 0 + fi + + # ── Create directory structure ──────────────────────────────────────────── + mkdir -p "$DIR"/{data,logs,config,plugins,db} + # Mattermost runs as UID 2000 inside the container + chown -R 2000:2000 "$DIR/data" "$DIR/logs" "$DIR/config" "$DIR/plugins" + ensure_docker_dir_ownership "$DIR/db" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + # ── Generate secrets ────────────────────────────────────────────────────── + local DB_PASS MM_SECRET TURN_SECRET + DB_PASS="$(generate_password 32)" + MM_SECRET="$(generate_password 48)" + TURN_SECRET="$(openssl rand -hex 32 2>/dev/null || generate_password 32)" + + # ── Site URL ────────────────────────────────────────────────────────────── + local SITE_URL="http://localhost:8065" + if [[ -n "$SITE_DOMAIN" && "$SITE_DOMAIN" != "example.com" ]]; then + SITE_URL="https://chat.${SITE_DOMAIN}" + fi + local CONFIGURED_SITEURL="" + prompt_text "Mattermost site URL [${SITE_URL}]:" "$SITE_URL" CONFIGURED_SITEURL + [[ -n "$CONFIGURED_SITEURL" ]] && SITE_URL="$CONFIGURED_SITEURL" + + # ── docker-compose.yml ──────────────────────────────────────────────────── + cat > docker-compose.yml << COMPOSE +# Mattermost Team Edition — generated by ubuntu-post-install +# Manage: docker compose up -d / down / logs -f +# Admin setup: \${MATTERMOST_SITE_URL}/signup_user_complete + +name: mattermost + +services: + + db: + image: postgres:15-alpine + container_name: mattermost-db + restart: unless-stopped + security_opt: + - no-new-privileges:true + pids_limit: 100 + volumes: + - ./db:/var/lib/postgresql/data + environment: + - POSTGRES_USER=mattermost + - POSTGRES_PASSWORD=\${DB_PASS} + - POSTGRES_DB=mattermost + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mattermost"] + interval: 10s + timeout: 5s + retries: 5 + + mattermost: + image: mattermost/mattermost-team-edition:latest + container_name: mattermost + restart: unless-stopped + security_opt: + - no-new-privileges:true + pids_limit: 200 + depends_on: + db: + condition: service_healthy + ports: + - "8065:8065" + volumes: + - ./data:/mattermost/data + - ./logs:/mattermost/logs + - ./config:/mattermost/config + - ./plugins:/mattermost/plugins + environment: + - MM_SQLSETTINGS_DRIVERNAME=postgres + - MM_SQLSETTINGS_DATASOURCE=postgres://mattermost:\${DB_PASS}@db:5432/mattermost?sslmode=disable + - MM_SERVICESETTINGS_SITEURL=\${MATTERMOST_SITE_URL} + - MM_PLUGINSETTINGS_ENABLEUPLOADS=true + - MM_SERVICESETTINGS_ENABLELOCALMODE=true + - TZ=\${TZ} + networks: + - default + - caddy_net + + coturn: + image: coturn/coturn:latest + container_name: mattermost-coturn + restart: unless-stopped + network_mode: host + command: + - -n + - --listening-port=3479 + - --tls-listening-port=5350 + - --listening-ip=0.0.0.0 + - --fingerprint + - --use-auth-secret + - --static-auth-secret=\${TURN_SECRET} + - --realm=\${TURN_REALM} + - --min-port=49153 + - --max-port=49352 + - --no-tls + - --no-dtls + - --no-cli + - --no-multicast-peers + - --log-file=stdout + +networks: + default: + caddy_net: + external: true + name: \${CADDY_NET:-caddy_net} +COMPOSE + + # ── .env ────────────────────────────────────────────────────────────────── + cat > .env << ENV +# Mattermost — environment configuration +# Edit and restart: docker compose down && docker compose up -d + +# PostgreSQL password (do not change after first start without migrating data) +DB_PASS=$DB_PASS + +# Mattermost secret key (used for signing session tokens) +MM_SECRET=$MM_SECRET + +# Site URL — must match the public URL clients use to access Mattermost +MATTERMOST_SITE_URL=$SITE_URL + +# Timezone +TZ=$SITE_TZ + +# TURN server shared secret for Mattermost Calls plugin +# Generate a new one: openssl rand -hex 32 +TURN_SECRET=$TURN_SECRET + +# TURN realm (typically your domain) +TURN_REALM=${SITE_DOMAIN:-localhost} + +# Caddy network name +CADDY_NET=$SITE_CADDY_NET +ENV + + chmod 600 .env + chown "$ACTUAL_USER:$ACTUAL_USER" .env + + # ── UFW firewall rules ───────────────────────────────────────────────────── + echo "" + log_info "Firewall — Mattermost coturn uses port 3479 (avoiding conflict with Easy Asterisk on 3478)." + if command -v ufw >/dev/null 2>&1 && ufw status 2>/dev/null | grep -q "Status: active"; then + log_info "Opening UFW ports for Mattermost coturn..." + ufw allow 3479/udp comment "Mattermost coturn STUN/TURN" + ufw allow 3479/tcp comment "Mattermost coturn STUN/TURN TCP" + ufw allow 49153:49352/udp comment "Mattermost coturn relay" + log_success "UFW rules added" + else + log_info "UFW not active — add these rules manually if needed:" + echo " ufw allow 3479/udp comment \"Mattermost coturn STUN/TURN\"" + echo " ufw allow 3479/tcp comment \"Mattermost coturn STUN/TURN TCP\"" + echo " ufw allow 49153:49352/udp comment \"Mattermost coturn relay\"" + fi + + # ── Router port-forward instructions ────────────────────────────────────── + echo "" + echo " ┌─────────────────────────────────────────────────────────────────┐" + echo " │ Router port-forwards needed for Mattermost Calls (external) │" + echo " ├──────────────────┬──────────┬──────────────────────────────────┤" + echo " │ Port(s) │ Protocol │ Service │" + echo " ├──────────────────┼──────────┼──────────────────────────────────┤" + echo " │ 3479 │ UDP+TCP │ coturn STUN/TURN │" + echo " │ 49153–49352 │ UDP │ coturn relay range │" + echo " └──────────────────┴──────────┴──────────────────────────────────┘" + echo "" + + ensure_docker_dir_ownership "$DIR" + + # ── Caddy reverse proxy ─────────────────────────────────────────────────── + configure_caddy_for_service "Mattermost" "mattermost:8065" "chat" + + # ── README ──────────────────────────────────────────────────────────────── + write_readme "$DIR" << MD +# Mattermost + +Team messaging platform with voice/video calls via the Calls plugin and self-hosted coturn TURN server. + +## Access +- Direct: http://localhost:8065 +- Via Caddy: see your configured domain (e.g. https://chat.${SITE_DOMAIN:-example.com}) + +## Initial admin setup +Visit: \`${SITE_URL}/signup_user_complete\` + +The first user to sign up becomes the System Admin. + +## Calls plugin (voice/video) +The Mattermost Calls plugin provides voice/video channels. + +### Enable the plugin +1. Go to **System Console → Plugins → Plugin Management** +2. Enable the **Calls** plugin (pre-installed in Team Edition) + +### Configure TURN server +1. Go to **System Console → Plugins → Calls** +2. Set **TURN server URL**: \`turn::3479\` +3. Set **TURN credentials type**: Static credentials (auth secret) +4. Set **TURN static auth secret**: (see TURN_SECRET in \`$DIR/.env\`) +5. Save and test a call + +Clients outside your LAN need the TURN server to relay media. The coturn +container listens on port 3479 (UDP+TCP) with relay range 49153–49352/UDP. + +## Router port-forwards (for external calls) +| Port(s) | Protocol | Service | +|--------------|----------|--------------------| +| 3479 | UDP+TCP | coturn STUN/TURN | +| 49153–49352 | UDP | coturn relay range | + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # all logs +docker compose logs -f mattermost # app logs only +docker compose logs -f coturn # TURN server logs +docker compose pull && docker compose up -d # update images +\`\`\` + +## Backup +Important paths to back up: +- \`$DIR/data/\` — uploaded files and attachments +- \`$DIR/config/\` — server configuration +- \`$DIR/plugins/\` — installed plugins +- \`$DIR/db/\` — PostgreSQL data directory +- \`$DIR/.env\` — secrets and configuration + +## Configuration +Main config file: \`$DIR/config/config.json\` (created on first start). +Environment variables in \`.env\` override config.json values. +After editing .env: \`docker compose down && docker compose up -d\` +MD + + # ── Start ────────────────────────────────────────────────────────────────── + echo "" + local START="" + prompt_yn "Start Mattermost now? (y/n):" "y" START + if [[ "$START" =~ ^[Yy]$ ]]; then + log_info "Pulling images and starting Mattermost (first start may take a minute)..." + if docker compose pull 2>&1 | tail -3 && docker compose up -d; then + log_success "Mattermost started" + echo "" + echo " App: http://localhost:8065" + echo " Admin setup: ${SITE_URL}/signup_user_complete" + echo "" + log_info "Enable the Calls plugin and configure TURN at:" + log_info " System Console → Plugins → Calls" + log_info " TURN URL: turn::3479" + log_info " TURN secret: (see $DIR/.env → TURN_SECRET)" + else + log_warning "Start failed — check: docker compose logs" + fi + fi + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_mattermost diff --git a/services/nextcloud.sh b/services/nextcloud.sh new file mode 100644 index 0000000..adc6645 --- /dev/null +++ b/services/nextcloud.sh @@ -0,0 +1,362 @@ +#!/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). +# +# Uses a custom Dockerfile (nextcloud:apache + smbclient) so SMB external storage +# works without AIO. All data uses bind mounts under ~/docker/nextcloud/ so that +# Kopia/Borg backup scripts cover everything automatically. +# +# Can also be run standalone on any machine: +# 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 + log_info() { echo -e "\033[0;34m[INFO]\033[0m $*"; } + log_success() { echo -e "\033[0;32m[OK]\033[0m $*"; } + log_warning() { echo -e "\033[1;33m[WARN]\033[0m $*"; } + log_error() { echo -e "\033[0;31m[ERROR]\033[0m $*" >&2; } + + require_docker() { + command -v docker &>/dev/null || { + log_error "Docker not found. Install it first:" + log_error " curl -fsSL https://get.docker.com | sudo sh" + return 1 + } + docker compose version &>/dev/null || { + log_error "Docker Compose plugin missing:" + log_error " sudo apt-get install -y docker-compose-plugin" + return 1 + } + } + + ensure_docker_dir_ownership() { + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$@" 2>/dev/null || true + } + + generate_password() { + local _len="${1:-32}" + tr -dc 'A-Za-z0-9' < /dev/urandom | head -c "$_len" + } + + # Match common.sh's eval-based pattern so local vars in install_* are set correctly + prompt_text() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + prompt_yn() { + local _q="$1" _def="$2" _var="$3" _r + [[ "${UNATTENDED:-false}" == "true" ]] && { eval "$_var='$_def'"; return; } + read -r -p " $_q " _r + eval "$_var='${_r:-$_def}'" + } + + configure_caddy_for_service() { + local _name="$1" _upstream="$2" _subdomain="$3" _extra="${4:-}" + local _caddy_dir="$DOCKER_DIR/caddy" + local _caddyfile="$_caddy_dir/Caddyfile" + + if [[ ! -d "$_caddy_dir" ]]; then + log_info "Access $_name directly on port 8080." + 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:8080" + return 0 + } + + local _domain="" + read -r -p " Domain (e.g. ${_subdomain}.${SITE_DOMAIN:-example.com}): " _domain + [[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; } + + # Back up before touching + if [[ -f "$_caddyfile" ]]; then + local _bk="$_caddy_dir/Caddyfile.backup.$(date +%Y%m%d-%H%M%S)" + cp "$_caddyfile" "$_bk" + log_info "Backed up Caddyfile to $(basename "$_bk")" + else + touch "$_caddyfile" + fi + + # Remove existing block for this domain if present + if grep -q "^${_domain}" "$_caddyfile" 2>/dev/null; then + log_warning "$_domain already in Caddyfile" + local _ow="" + read -r -p " Overwrite? [y/N]: " _ow + [[ "${_ow,,}" == "y" ]] || { log_info "Keeping existing entry."; return 0; } + sed -i "/^${_domain}/,/^}/d" "$_caddyfile" + fi + + cat >> "$_caddyfile" << CBLOCK + +# $_name +$_domain { + reverse_proxy $_upstream + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + } + + log { + output file /var/log/caddy/${_domain}.log + format json + } +${_extra} +} +CBLOCK + + log_success "Added $_domain to Caddyfile" + docker exec caddy caddy fmt --overwrite /etc/caddy/Caddyfile 2>/dev/null || true + if docker exec caddy caddy reload --config /etc/caddy/Caddyfile 2>/dev/null; then + log_success "$_name accessible at: https://$_domain" + else + log_warning "Reload failed — check: docker logs caddy" + log_info "Manual reload: docker exec caddy caddy reload --config /etc/caddy/Caddyfile" + fi + } + + write_readme() { + local _dir="$1"; shift + mkdir -p "$_dir" + cat > "$_dir/README.md" + } + fi + + # Globals — ACTUAL_USER/ACTUAL_HOME must come before DOCKER_DIR + # ($HOME under sudo is /root, not the real user's home) + ACTUAL_USER="${ACTUAL_USER:-${SUDO_USER:-$USER}}" + ACTUAL_HOME="$(getent passwd "$ACTUAL_USER" 2>/dev/null | cut -d: -f6 || echo "${HOME:-/root}")" + DOCKER_DIR="${DOCKER_DIR:-$ACTUAL_HOME/docker}" + DRY_RUN="${DRY_RUN:-false}" + UNATTENDED="${UNATTENDED:-false}" + SITE_TZ="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + SITE_DOMAIN="${SITE_DOMAIN:-example.com}" + SITE_CADDY_NET="${SITE_CADDY_NET:-caddy_net}" + + register_service() { :; } # no-op — no wizard to register into + _RUN_STANDALONE=1 +fi +# ───────────────────────────────────────────────────────────────────────────── + +register_service 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:" + echo "[DRY-RUN] Dockerfile (nextcloud:apache + smbclient)" + echo "[DRY-RUN] docker-compose.yml (nextcloud + mariadb:10.11)" + echo "[DRY-RUN] .env with generated DB and admin passwords" + echo "[DRY-RUN] Bind-mount directories: html/ db/ config/ custom_apps/" + echo "[DRY-RUN] Would expose Nextcloud on port 8080" + echo "[DRY-RUN] Would enable files_external app via occ after deploy" + return 0 + fi + + mkdir -p "$DIR/html" "$DIR/db" "$DIR/config" "$DIR/custom_apps" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local DB_PASS NC_ADMIN_PASS TZ_VAL + DB_PASS=$(generate_password 32) + NC_ADMIN_PASS=$(generate_password 24) + TZ_VAL="${SITE_TZ:-$(cat /etc/timezone 2>/dev/null || echo UTC)}" + + # ── Dockerfile — adds SMB support to the official apache image ──────────── + cat > Dockerfile << 'DOCKERFILE' +FROM nextcloud:apache + +RUN apt-get update \ + && apt-get install -y --no-install-recommends procps smbclient \ + && rm -rf /var/lib/apt/lists/* +DOCKERFILE + + # ── docker-compose.yml — single-quoted EOF prevents variable expansion ──── + cat > docker-compose.yml << 'EOF' +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} +EOF + + # ── .env — actual variable values (NOT inside the compose heredoc) ──────── + cat > .env << NC_ENV +# ── Timezone & network ──────────────────────────────────────────────────────── +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 ─────────────────────────────────────────────────────── +# These are used only on the very first startup to create the admin account +# and wire up the database. They are ignored on subsequent startups. +NEXTCLOUD_ADMIN_USER=admin +NEXTCLOUD_ADMIN_PASSWORD=$NC_ADMIN_PASS +NEXTCLOUD_DB_TYPE=mysql +MYSQL_HOST=db +NC_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + log_success "Nextcloud configured at $DIR" + + configure_caddy_for_service "Nextcloud" "nextcloud:80" "cloud" + + write_readme "$DIR" << MD +# Nextcloud + +Self-hosted cloud storage — files, contacts, calendar, notes, and more. +SMB/local external storage is enabled via a custom Docker image (nextcloud:apache + smbclient). + +## Access +- URL: http://localhost:8080 +- Admin user: \`admin\` +- Admin password: see \`NEXTCLOUD_ADMIN_PASSWORD\` in \`.env\` + +## Directory layout (all bind-mounted — covered by Kopia/Borg backups) +\`\`\` +$DIR/ + html/ # Nextcloud web root (PHP app + uploaded files) + config/ # config.php and other Nextcloud config files + custom_apps/ # manually installed apps not shipped with Nextcloud + db/ # MariaDB data directory + Dockerfile # custom image definition (adds smbclient) + docker-compose.yml + .env # secrets — chmod 600 +\`\`\` + +## External Storage (SMB / local paths) +The \`files_external\` app is enabled automatically during setup. +Add mounts in the Nextcloud web UI: +**Admin → Administration → External Storage** + +Supported backends: Local, SMB/CIFS, FTP, S3, WebDAV, and more. + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose build --pull && docker compose up -d # rebuild image + update +docker exec --user www-data nextcloud php occ list # occ CLI +\`\`\` + +## Backup note +All data lives under \`$DIR/\` as bind mounts. +Include this directory in your Kopia/Borg backup policy. +Run \`docker compose down\` before a cold backup of \`db/\` for consistency, +or use \`mysqldump\` for a hot backup: +\`\`\`bash +docker exec nextcloud-db mysqldump -u nextcloud -p\$MYSQL_PASSWORD nextcloud > nextcloud_db.sql +\`\`\` +MD + + local START_NC="" + prompt_yn "Start Nextcloud now? (y/n):" "y" START_NC + if [ "$START_NC" = "y" ] || [ "$START_NC" = "Y" ]; then + docker compose up -d \ + && log_success "Nextcloud started — first boot may take 1-2 minutes" \ + || { log_warning "Start failed — check: docker compose logs"; return 1; } + + # Wait for Nextcloud to finish first-boot initialisation before running occ + log_info "Waiting for Nextcloud to finish initialising (up to 90 s)..." + local _waited=0 + until docker exec --user www-data nextcloud php occ status --output=json 2>/dev/null \ + | grep -q '"installed":true'; do + sleep 5 + _waited=$(( _waited + 5 )) + if (( _waited >= 90 )); then + log_warning "Nextcloud did not finish initialising within 90 s." + log_warning "Run the occ command manually once the container is ready:" + log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" + break + fi + done + + if (( _waited < 90 )); then + if docker exec --user www-data nextcloud php occ app:enable files_external; then + log_success "External Storage app enabled" + else + log_warning "Could not enable files_external — run manually:" + log_warning " docker exec --user www-data nextcloud php occ app:enable files_external" + fi + fi + fi + + echo "" + echo " URL: http://localhost:8080" + echo " Admin user: admin" + echo " Admin password: $NC_ADMIN_PASS" + echo " (Credentials also saved to $DIR/.env)" + echo "" + echo " To add SMB or local external storage:" + echo " Nextcloud → Admin → Administration → External Storage" + echo "" +} + +# Run immediately when executed directly (deferred until after function definition) +[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_nextcloud diff --git a/services/onlyoffice.sh b/services/onlyoffice.sh new file mode 100644 index 0000000..4fb73f0 --- /dev/null +++ b/services/onlyoffice.sh @@ -0,0 +1,170 @@ +#!/bin/bash +# services/onlyoffice.sh — Self-hosted OnlyOffice Document Server for Nextcloud/FileBrowser. +# Part of the modular post-install system (sourced by setup.sh). +# +# OnlyOffice Document Server provides collaborative editing for Nextcloud and +# other platforms. JWT is enabled to secure the API endpoint. + +register_service onlyoffice utilities "Self-hosted OnlyOffice Document Server for Nextcloud/FileBrowser" 8082 + +install_onlyoffice() { + require_docker || return 1 + 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 and .env" + echo "[DRY-RUN] Would deploy onlyoffice/documentserver:latest on port 8082" + echo "[DRY-RUN] Would generate JWT secret" + echo "[DRY-RUN] Would configure Nextcloud via occ (if $DOCKER_DIR/nextcloud exists)" + echo "[DRY-RUN] Would configure FileBrowser config.yaml (if present)" + return 0 + fi + + mkdir -p "$DIR" + ensure_docker_dir_ownership "$DIR" + cd "$DIR" || return 1 + + local JWT_SECRET + JWT_SECRET=$(generate_password 32) + + cat > docker-compose.yml << 'OO_COMPOSE' +name: onlyoffice + +services: + onlyoffice: + image: onlyoffice/documentserver:latest + container_name: onlyoffice + hostname: onlyoffice + restart: unless-stopped + env_file: .env + ports: + - "8082:80" + networks: + - caddy_net + +networks: + caddy_net: + external: true + name: ${CADDY_NET:-caddy_net} +OO_COMPOSE + + cat > .env << OO_ENV +# ── OnlyOffice Document Server ──────────────────────────────────────────────── +CADDY_NET=$SITE_CADDY_NET + +# JWT authentication — keep JWT_SECRET secret; used by Nextcloud integration +JWT_ENABLED=true +JWT_SECRET=$JWT_SECRET +JWT_HEADER=AuthorizationJwt +OO_ENV + + chmod 600 .env + chown -R "$ACTUAL_USER:$ACTUAL_USER" "$DIR" + log_success "OnlyOffice configured at $DIR" + + configure_caddy_for_service "OnlyOffice" "onlyoffice:80" "office" + + # ── Start container ──────────────────────────────────────────────────────── + local START="" + prompt_yn "Start OnlyOffice now? (y/n):" "y" START + if [ "$START" = "y" ] || [ "$START" = "Y" ]; then + docker compose up -d \ + && log_success "OnlyOffice started" \ + || log_warning "Start failed — check: docker compose logs" + fi + + # ── Nextcloud integration ────────────────────────────────────────────────── + if [ -d "$DOCKER_DIR/nextcloud" ]; then + log_info "Nextcloud detected — configuring OnlyOffice integration via occ..." + docker exec --user www-data nextcloud php occ app:enable onlyoffice \ + && log_success "OnlyOffice app enabled in Nextcloud" \ + || log_warning "Could not enable OnlyOffice app — run manually: docker exec --user www-data nextcloud php occ app:enable onlyoffice" + docker exec --user www-data nextcloud php occ config:app:set onlyoffice DocumentServerUrl --value "http://onlyoffice:80/" \ + && log_success "Nextcloud DocumentServerUrl set" \ + || log_warning "Could not set DocumentServerUrl" + docker exec --user www-data nextcloud php occ config:app:set onlyoffice jwt_secret --value "$JWT_SECRET" \ + && log_success "Nextcloud jwt_secret set" \ + || log_warning "Could not set jwt_secret" + docker exec --user www-data nextcloud php occ config:app:set onlyoffice jwt_header --value "AuthorizationJwt" \ + && log_success "Nextcloud jwt_header set" \ + || log_warning "Could not set jwt_header" + else + echo "" + echo " Nextcloud not found. To integrate OnlyOffice with Nextcloud manually:" + echo " 1. Install the OnlyOffice app in Nextcloud (Apps > Office & Text)" + echo " 2. Go to Settings > OnlyOffice and set:" + echo " Document Server URL: http://onlyoffice:80/" + echo " JWT Secret: $JWT_SECRET" + echo " JWT Header: AuthorizationJwt" + echo "" + fi + + # ── FileBrowser integration ──────────────────────────────────────────────── + local FB_CONFIG="$DOCKER_DIR/filebrowser/data/config.yaml" + if [ -f "$FB_CONFIG" ]; then + if command -v yq >/dev/null 2>&1; then + yq e -i '.officeServer = "http://onlyoffice:80/"' "$FB_CONFIG" \ + && log_success "FileBrowser config.yaml updated with officeServer" \ + || log_warning "yq failed to update $FB_CONFIG — set officeServer manually" + else + log_info "yq not found. To enable OnlyOffice in FileBrowser, add to $FB_CONFIG:" + log_info " officeServer: \"http://onlyoffice:80/\"" + fi + fi + + write_readme "$DIR" << MD +# OnlyOffice Document Server + +Self-hosted document editing server. Integrates with Nextcloud and FileBrowser +to provide collaborative editing of ODT, DOCX, XLSX, and PPTX files. + +## JWT Secret +The JWT secret is stored in \`.env\` (chmod 600). If you rotate it, update: +- Nextcloud: Settings > OnlyOffice > JWT Secret +- Any other integrations using this server + +JWT Secret (at install time): see \`JWT_SECRET\` in .env + +## Nextcloud Integration +If Nextcloud was running at install time, the OnlyOffice app was auto-configured. +To reconfigure or verify: +\`\`\`bash +docker exec --user www-data nextcloud php occ config:app:get onlyoffice DocumentServerUrl +docker exec --user www-data nextcloud php occ config:app:get onlyoffice jwt_secret +\`\`\` + +## FileBrowser Integration +Set \`officeServer: "http://onlyoffice:80/"\` in FileBrowser's config.yaml, then +restart FileBrowser. + +## Manage +\`\`\`bash +cd $DIR +docker compose up -d # start +docker compose down # stop +docker compose logs -f # logs +docker compose pull && docker compose up -d # update +\`\`\` +MD + + echo "" + echo " OnlyOffice Document Server" + echo " Directory: $DIR" + echo " Port: 8082 (internal: 80)" + echo " JWT Secret: $JWT_SECRET" + echo " (Secret also saved to $DIR/.env)" + echo "" +} + +# ── Standalone bootstrap ─────────────────────────────────────────────────────── +# Run this file directly to install OnlyOffice without the full setup.sh wizard: +# sudo _RUN_STANDALONE=1 bash services/onlyoffice.sh +if [[ "${_RUN_STANDALONE:-0}" == 1 ]]; then + SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # shellcheck source=../lib/common.sh + source "$SCRIPT_DIR/../lib/common.sh" + require_root + load_site_config 2>/dev/null || true + install_onlyoffice +fi