With 70+ services sharing a handful of common default ports (emby and jellyfin both default to 8096, changedetection and frigate both default to 5000, arm and nextcloud both default to 8080...), nothing previously checked whether a service's default port was actually free on the host. Whichever service installed second would silently write a compose file claiming an already-held port, only failing at `docker compose up` time. Adds two shared helpers to lib/common.sh: - port_in_use PORT [PROTO] — true if something's already listening - find_free_port VARNAME START [PROTO] — scans upward, writes back the first free port Every service that publishes a fixed host port now scans before writing docker-compose.yml, on every install (not just when adding an explicit additional instance). On a normal single-install host this is a silent no-op; it only changes behavior when something else already holds the port. - The 19 services already given multi-instance support this session had their port scan moved out of the "add instance" branch to run unconditionally, since the same collision risk exists on a plain first install. - 20 more services with previously-hardcoded ports gained scanning for the first time: archivebox, arm, calibre-web, changedetection, drum-rhythm-game, gatus, n8n, nextcloud, onlyoffice, stirling-pdf, uptimekuma, portainer, iopaint (both GPU/CPU compose branches), koha (paired), syncthing (paired), wg-easy (paired, plus WG_PORT env so generated peer configs keep the right Endpoint), homeassistant (bridge-mode only — host mode can only warn), frigate and frigate-audio (multi-port stacks, moved together). - caddy.sh is the deliberate exception: 80/443 stay fixed and only warn on collision, since silently moving Caddy itself would leave nothing listening where any client actually looks. - authelia.sh needs no change — it has no published host port at all. - Every service's standalone bootstrap fallback (sudo bash services/x.sh with no sibling files) got the same two helpers duplicated into its stub block, matching how every other shared helper is already handled there. Documents the full pattern in CLAUDE.md's new "Port collision avoidance" section, including the quoted-heredoc/backtick-escaping gotcha and the network_mode:host limitation (can only scan ports the app takes as a configurable env var). Verified via bash -n on every changed file, plus functional runs seeding occupied ports for each collision shape used here (single, paired, multi-port stacks) and confirming the scan/shift and generated compose/README output are correct — including the emby/jellyfin, nextcloud/arm, and frigate/changedetection collision scenarios that originally motivated this.
664 lines
28 KiB
Bash
664 lines
28 KiB
Bash
#!/bin/bash
|
||
# services/authelia.sh — Authelia SSO + 2FA portal (forward-auth for Caddy).
|
||
# Part of the modular post-install system (sourced by setup.sh).
|
||
#
|
||
# Can also be run standalone on any machine:
|
||
# sudo bash authelia.sh
|
||
# (Docker must already be installed when run standalone)
|
||
#
|
||
# Ported from the authelia-setup repo / the monolith's working block.
|
||
|
||
# ── 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
|
||
}
|
||
|
||
port_in_use() {
|
||
local _port="$1" _proto="${2:-tcp}"
|
||
local _flag="-tlnH"
|
||
[ "$_proto" = "udp" ] && _flag="-ulnH"
|
||
ss "$_flag" "sport = :${_port}" 2>/dev/null | grep -q .
|
||
}
|
||
|
||
find_free_port() {
|
||
local _varname="$1" _port="$2" _proto="${3:-tcp}"
|
||
while port_in_use "$_port" "$_proto"; do
|
||
_port=$((_port + 1))
|
||
done
|
||
eval "$_varname='$_port'"
|
||
}
|
||
|
||
# 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"
|
||
local _display_port="${_upstream##*:}"
|
||
|
||
# Determine mode: local Caddy, remote Caddy, or none
|
||
local _mode="none"
|
||
[[ -d "$_caddy_dir" ]] && _mode="local"
|
||
[[ -n "${CADDY_REMOTE_HOST:-}" ]] && [[ "$_mode" != "local" ]] && _mode="remote"
|
||
[[ "$_mode" == "none" ]] && {
|
||
log_info "Access $_name directly on port $_display_port."
|
||
return 0
|
||
}
|
||
|
||
echo ""
|
||
local _do_caddy=""
|
||
if [[ "$_mode" == "remote" ]]; then
|
||
log_info "Remote Caddy configured (${CADDY_REMOTE_HOST})."
|
||
log_info "A snippet file will be saved to ~/docker/caddy-snippets/."
|
||
fi
|
||
read -r -p " Configure Caddy reverse proxy for $_name? [y/N]: " _do_caddy
|
||
[[ "${_do_caddy,,}" == "y" ]] || {
|
||
log_info "Skipping — access at: http://localhost:$_display_port"
|
||
return 0
|
||
}
|
||
|
||
# Domain prompt — pre-fill from SITE_DOMAIN when available
|
||
local _default_domain=""
|
||
if [[ -n "${SITE_DOMAIN:-}" ]] && [[ "$SITE_DOMAIN" != "example.com" ]]; then
|
||
_default_domain="${_subdomain}.${SITE_DOMAIN}"
|
||
log_info "Default: $_default_domain"
|
||
fi
|
||
local _domain=""
|
||
read -r -p " Domain [${_default_domain:-required}]: " _domain
|
||
_domain="${_domain:-$_default_domain}"
|
||
[[ -n "$_domain" ]] || { log_warning "No domain entered — skipping Caddy."; return 0; }
|
||
|
||
# Build upstream — remote Caddy uses host IP:port, not container name
|
||
local _block_upstream="$_upstream"
|
||
if [[ "$_mode" == "remote" ]]; then
|
||
_block_upstream="${CADDY_REMOTE_HOST}:${_display_port}"
|
||
fi
|
||
|
||
local _site_block
|
||
_site_block="$(cat << CBLOCK
|
||
|
||
# $_name
|
||
${_domain} {
|
||
reverse_proxy ${_block_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
|
||
)"
|
||
|
||
if [[ "$_mode" == "local" ]]; then
|
||
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
|
||
|
||
printf '%s\n' "$_site_block" >> "$_caddyfile"
|
||
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
|
||
else
|
||
local _snippet_dir="$DOCKER_DIR/caddy-snippets"
|
||
local _snippet_file="$_snippet_dir/${_subdomain}.caddy"
|
||
mkdir -p "$_snippet_dir"
|
||
printf '%s\n' "$_site_block" > "$_snippet_file"
|
||
chown "$ACTUAL_USER:$ACTUAL_USER" "$_snippet_file" 2>/dev/null || true
|
||
log_success "Snippet saved: $_snippet_file"
|
||
log_info "Copy to Caddy machine:"
|
||
log_info " scp $_snippet_file caddy-host:~/caddy-snippets/"
|
||
log_info " rsync -av $_snippet_dir/ caddy-host:~/caddy-snippets/ (all at once)"
|
||
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 authelia homelab "SSO + 2FA auth portal (Authelia)" 9091
|
||
|
||
install_authelia() {
|
||
require_docker || return 1
|
||
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
|
||
|
||
if [ "$DRY_RUN" = true ]; then
|
||
echo "[DRY-RUN] Would set up Authelia:"
|
||
echo " • Create $AUTHELIA_DIR (config/secrets, data)"
|
||
echo " • Generate jwt/session/storage secrets + admin password hash"
|
||
echo " • Write docker-compose.yml, configuration.yml, users.yml, README.md"
|
||
echo " • Create the caddy_net network and add the forward-auth snippet to the Caddyfile"
|
||
return 0
|
||
fi
|
||
|
||
# Don't clobber an existing install (it would regenerate secrets and break sessions).
|
||
if [ -f "$AUTHELIA_DIR/docker-compose.yml" ]; then
|
||
echo " ⚠ Authelia already exists at $AUTHELIA_DIR."
|
||
echo ""
|
||
echo " 1) Add another protected domain to this instance (non-destructive —"
|
||
echo " one Authelia+Redis, multiple independent apex domains/logins)"
|
||
echo " 2) Reconfigure from scratch (regenerates secrets/users — breaks"
|
||
echo " existing sessions for every domain already on this instance)"
|
||
echo " 3) Leave as-is"
|
||
echo ""
|
||
local EXISTING_CHOICE=""
|
||
prompt_text " Choice [1/2/3]:" "3" EXISTING_CHOICE
|
||
case "$EXISTING_CHOICE" in
|
||
1)
|
||
add_authelia_domain
|
||
return 0
|
||
;;
|
||
2)
|
||
: # fall through to the full reinstall flow below
|
||
;;
|
||
*)
|
||
echo " Keeping existing Authelia. (Edit config/users.yml then: cd $AUTHELIA_DIR && docker compose restart authelia)"
|
||
return 0
|
||
;;
|
||
esac
|
||
fi
|
||
|
||
log_info "Installing Authelia..."
|
||
mkdir -p "$AUTHELIA_DIR/config/secrets" "$AUTHELIA_DIR/data"
|
||
|
||
# ── Collect configuration ────────────────────────────────────────────────
|
||
echo ""
|
||
echo " Authelia needs a few details to configure."
|
||
echo ""
|
||
local CADDY_NET="${SITE_CADDY_NET:-caddy_net}"
|
||
local AUTHELIA_DOMAIN AUTHELIA_ADMIN_USER AUTHELIA_ADMIN_DISPLAY AUTHELIA_ADMIN_EMAIL
|
||
local AUTHELIA_SMTP_HOST AUTHELIA_SMTP_PORT AUTHELIA_SMTP_USER AUTHELIA_SMTP_PASS AUTHELIA_TZ
|
||
prompt_text " Your domain (e.g., example.com):" "${SITE_DOMAIN:-example.com}" AUTHELIA_DOMAIN
|
||
prompt_text " Admin username:" "admin" AUTHELIA_ADMIN_USER
|
||
prompt_text " Admin display name:" "Administrator" AUTHELIA_ADMIN_DISPLAY
|
||
prompt_text " Admin email:" "admin@${AUTHELIA_DOMAIN}" AUTHELIA_ADMIN_EMAIL
|
||
prompt_text " SMTP server (e.g., smtp.migadu.com):" "smtp.migadu.com" AUTHELIA_SMTP_HOST
|
||
prompt_text " SMTP port:" "587" AUTHELIA_SMTP_PORT
|
||
prompt_text " SMTP username (full email):" "authelia@${AUTHELIA_DOMAIN}" AUTHELIA_SMTP_USER
|
||
prompt_text " SMTP password:" "" AUTHELIA_SMTP_PASS
|
||
prompt_text " Timezone (e.g., America/New_York):" "${SITE_TZ:-America/New_York}" AUTHELIA_TZ
|
||
|
||
# ── Secrets ──────────────────────────────────────────────────────────────
|
||
echo ""
|
||
echo " Generating secrets..."
|
||
echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/jwt_secret"
|
||
echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/session_secret"
|
||
echo "$(openssl rand -hex 32)" > "$AUTHELIA_DIR/config/secrets/storage_secret"
|
||
echo "$AUTHELIA_SMTP_PASS" > "$AUTHELIA_DIR/config/secrets/smtp_password"
|
||
chmod 600 "$AUTHELIA_DIR/config/secrets/"*
|
||
echo " ✓ Secrets generated"
|
||
|
||
# ── Admin password hash ──────────────────────────────────────────────────
|
||
echo ""
|
||
local AUTHELIA_TEMP_PASS AUTHELIA_HASH
|
||
prompt_text " Temporary password for admin (users reset via email):" "TempPass2026!" AUTHELIA_TEMP_PASS
|
||
echo " Generating password hash..."
|
||
AUTHELIA_HASH=$(docker run --rm authelia/authelia:4.39.20 \
|
||
authelia crypto hash generate argon2 --password "$AUTHELIA_TEMP_PASS" 2>/dev/null \
|
||
| grep -oP '(?<=Digest: ).*' || echo "REPLACE_WITH_HASH")
|
||
if [ "$AUTHELIA_HASH" = "REPLACE_WITH_HASH" ]; then
|
||
log_warning "Could not generate hash automatically. After install run:"
|
||
echo " docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'yourpassword'"
|
||
echo " then update $AUTHELIA_DIR/config/users.yml"
|
||
else
|
||
echo " ✓ Password hash generated"
|
||
fi
|
||
|
||
ensure_docker_dir_ownership "$AUTHELIA_DIR"
|
||
cd "$AUTHELIA_DIR" || return 1
|
||
|
||
# ── .env ─────────────────────────────────────────────────────────────────
|
||
cat > .env << AUTHELIA_ENV
|
||
MY_DOMAIN=${AUTHELIA_DOMAIN}
|
||
SMTP_USER=${AUTHELIA_SMTP_USER}
|
||
DOCKER_MY_NETWORK=${CADDY_NET}
|
||
TZ=${AUTHELIA_TZ}
|
||
AUTHELIA_ENV
|
||
|
||
# ── docker-compose.yml (quoted heredoc: ${SMTP_USER} resolved by compose/.env) ──
|
||
cat > docker-compose.yml << 'AUTHELIA_COMPOSE'
|
||
name: authelia
|
||
|
||
services:
|
||
authelia:
|
||
image: authelia/authelia:4.39.20
|
||
pull_policy: missing
|
||
container_name: authelia
|
||
user: "1000:1000"
|
||
volumes:
|
||
- ./config:/config
|
||
- ./data:/data
|
||
environment:
|
||
- AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/config/secrets/jwt_secret
|
||
- AUTHELIA_SESSION_SECRET_FILE=/config/secrets/session_secret
|
||
- AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/config/secrets/storage_secret
|
||
- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/config/secrets/smtp_password
|
||
- AUTHELIA_NOTIFIER_SMTP_USERNAME=${SMTP_USER}
|
||
- AUTHELIA_NOTIFIER_SMTP_SENDER=Authelia <${SMTP_USER}>
|
||
expose:
|
||
- 9091
|
||
restart: unless-stopped
|
||
networks:
|
||
- caddy_net
|
||
|
||
networks:
|
||
caddy_net:
|
||
external: true
|
||
AUTHELIA_COMPOSE
|
||
[ "$CADDY_NET" != "caddy_net" ] && sed -i "s/caddy_net/${CADDY_NET}/g" docker-compose.yml
|
||
|
||
# ── configuration.yml ────────────────────────────────────────────────────
|
||
cat > config/configuration.yml << AUTHELIA_CONFIG
|
||
---
|
||
# Authelia configuration. Secrets injected via AUTHELIA_* env vars in compose.
|
||
theme: dark
|
||
|
||
server:
|
||
address: tcp://0.0.0.0:9091
|
||
|
||
log:
|
||
level: info
|
||
file_path: /data/authelia.log
|
||
|
||
totp:
|
||
period: 30
|
||
skew: 1
|
||
|
||
authentication_backend:
|
||
file:
|
||
path: /config/users.yml
|
||
password:
|
||
algorithm: argon2
|
||
argon2:
|
||
variant: argon2id
|
||
iterations: 3
|
||
memory: 65536
|
||
parallelism: 4
|
||
key_length: 32
|
||
salt_length: 16
|
||
|
||
access_control:
|
||
default_policy: deny
|
||
rules:
|
||
- domain: "*.${AUTHELIA_DOMAIN}"
|
||
policy: two_factor
|
||
|
||
session:
|
||
name: authelia_session
|
||
expiration: 12h
|
||
inactivity: 2h
|
||
remember_me: 7d
|
||
cookies:
|
||
- domain: ${AUTHELIA_DOMAIN}
|
||
authelia_url: https://auth.${AUTHELIA_DOMAIN}
|
||
default_redirection_url: https://${AUTHELIA_DOMAIN}
|
||
|
||
storage:
|
||
local:
|
||
path: /data/db.sqlite3
|
||
|
||
notifier:
|
||
disable_startup_check: false
|
||
smtp:
|
||
address: smtp://${AUTHELIA_SMTP_HOST}:${AUTHELIA_SMTP_PORT}
|
||
timeout: 10s
|
||
identifier: localhost
|
||
subject: "[Authelia] {title}"
|
||
startup_check_address: ${AUTHELIA_SMTP_USER}
|
||
disable_require_tls: false
|
||
disable_starttls: false
|
||
AUTHELIA_CONFIG
|
||
|
||
# ── users.yml ────────────────────────────────────────────────────────────
|
||
cat > config/users.yml << AUTHELIA_USERS
|
||
---
|
||
# Authelia users database
|
||
# Add users: copy a block, change username/email/displayname, restart authelia.
|
||
# Generate a hash: docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword'
|
||
# Login with username (not email). Use "Forgot Password" to set a real password.
|
||
|
||
users:
|
||
${AUTHELIA_ADMIN_USER}:
|
||
displayname: "${AUTHELIA_ADMIN_DISPLAY}"
|
||
email: ${AUTHELIA_ADMIN_EMAIL}
|
||
password: "${AUTHELIA_HASH}"
|
||
groups:
|
||
- admins
|
||
- users
|
||
AUTHELIA_USERS
|
||
|
||
chown -R 1000:1000 "$AUTHELIA_DIR/config" "$AUTHELIA_DIR/data"
|
||
log_success "Authelia configured at $AUTHELIA_DIR"
|
||
|
||
# $CADDY_NET already exists at this point — require_docker (called at the
|
||
# top of this function) creates it via ensure_caddy_network in lib/common.sh.
|
||
|
||
# ── Caddyfile forward-auth snippet + portal block ────────────────────────
|
||
local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile"
|
||
if [ -f "$CADDY_FILE" ]; then
|
||
echo " Configuring Caddy for Authelia..."
|
||
if ! grep -q "(authelia)" "$CADDY_FILE"; then
|
||
cp "$CADDY_FILE" "$CADDY_FILE.backup.$(date +%Y%m%d-%H%M%S)"
|
||
{ cat << 'SNIPPET_EOF'
|
||
# ── Authelia forward auth snippet ─────────────────────────────────────────────
|
||
(authelia) {
|
||
forward_auth authelia:9091 {
|
||
uri /api/authz/forward-auth
|
||
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
|
||
}
|
||
}
|
||
|
||
SNIPPET_EOF
|
||
cat "$CADDY_FILE"; } > "$CADDY_FILE.tmp" && mv "$CADDY_FILE.tmp" "$CADDY_FILE"
|
||
echo " ✓ Authelia snippet added to Caddyfile"
|
||
fi
|
||
if ! grep -q "auth.${AUTHELIA_DOMAIN}" "$CADDY_FILE"; then
|
||
cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK
|
||
|
||
# ── Authelia login portal ──────────────────────────────────────────────────────
|
||
auth.${AUTHELIA_DOMAIN} {
|
||
# header_up pins X-Forwarded-Host to whatever the client actually sent.
|
||
# Without it, Caddy's reverse_proxy recomputes X-Forwarded-Host from its
|
||
# own incoming request (always auth.${AUTHELIA_DOMAIN} itself) and
|
||
# overwrites the value a forward_auth caller (e.g. a remote site's
|
||
# "forward_auth https://auth.${AUTHELIA_DOMAIN}" block, see
|
||
# services/asterisk.sh's droplet-mode Caddy block) set for its own domain. Confirmed
|
||
# live: every forward-auth check evaluated as if it were for
|
||
# auth.${AUTHELIA_DOMAIN} itself (which has policy: bypass in
|
||
# access_control.rules so its own login portal isn't gated behind
|
||
# itself), so every domain behind it silently passed through with no
|
||
# 2FA prompt regardless of that domain's own policy.
|
||
reverse_proxy authelia:9091 {
|
||
header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host}
|
||
}
|
||
log {
|
||
output file /var/log/caddy/auth.log
|
||
}
|
||
}
|
||
CADDY_AUTH_BLOCK
|
||
echo " ✓ Authelia portal block added for auth.${AUTHELIA_DOMAIN}"
|
||
fi
|
||
docker ps --format '{{.Names}}' | grep -q "^caddy$" && \
|
||
{ docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && echo " ✓ Caddy reloaded" || echo " ⚠ Reload manually after checking the Caddyfile"; }
|
||
else
|
||
echo " ℹ Caddy not installed yet — add the (authelia) snippet + auth.${AUTHELIA_DOMAIN} block to your Caddyfile later (see README)."
|
||
fi
|
||
|
||
# ── README for the service folder ────────────────────────────────────────
|
||
write_readme "$AUTHELIA_DIR" << README_MD
|
||
# Authelia — SSO + 2FA portal
|
||
|
||
Single login (with TOTP two-factor) that protects any Caddy subdomain via
|
||
forward-auth. Portal: **https://auth.${AUTHELIA_DOMAIN}**
|
||
|
||
## Layout
|
||
\`\`\`
|
||
$AUTHELIA_DIR/
|
||
├── docker-compose.yml
|
||
├── .env
|
||
├── config/
|
||
│ ├── configuration.yml
|
||
│ ├── users.yml
|
||
│ └── secrets/ # jwt/session/storage/smtp — never commit
|
||
└── data/ # sqlite db + log
|
||
\`\`\`
|
||
|
||
## Protect a service with Authelia
|
||
In that service's Caddy site block, add \`import authelia\`:
|
||
\`\`\`
|
||
myservice.${AUTHELIA_DOMAIN} {
|
||
import authelia
|
||
reverse_proxy localhost:PORT
|
||
}
|
||
\`\`\`
|
||
The \`(authelia)\` snippet and the \`auth.${AUTHELIA_DOMAIN}\` portal block were
|
||
added to \`$DOCKER_DIR/caddy/Caddyfile\` automatically.
|
||
|
||
## Protecting a second (or third) apex domain
|
||
Re-run this installer (\`sudo ./setup.sh authelia\` or \`sudo bash authelia.sh\`)
|
||
and choose **"Add another protected domain to this instance"** when it detects
|
||
the existing install. That domain gets its own \`session.cookies\` entry and its
|
||
own \`auth.<domain>\` portal — a separate login/session from ${AUTHELIA_DOMAIN},
|
||
so no accidental cross-domain SSO — but it's still one shared Authelia + Redis
|
||
container and one shared user database, not a second full stack. Cheaper than
|
||
standing up an entirely separate instance, and the right way to protect
|
||
multiple unrelated domains from the same box.
|
||
|
||
## Manage
|
||
\`\`\`
|
||
cd $AUTHELIA_DIR
|
||
docker compose up -d # start
|
||
docker compose restart authelia
|
||
docker compose logs -f authelia
|
||
docker compose down # stop
|
||
\`\`\`
|
||
|
||
## Users
|
||
- Login with the **username** (not email). Admin user: \`${AUTHELIA_ADMIN_USER}\`.
|
||
- Tell users to click **Forgot Password** on first login to set their own
|
||
password (Authelia emails a reset link via SMTP).
|
||
- Add a user: copy a block in \`config/users.yml\`, change username/email/
|
||
displayname, generate a hash, then \`docker compose restart authelia\`:
|
||
\`\`\`
|
||
docker run --rm authelia/authelia:4.39.20 authelia crypto hash generate argon2 --password 'thepassword'
|
||
\`\`\`
|
||
|
||
## Notes
|
||
- Authelia listens on 9091 **internally only** (no published port) and is
|
||
reached through Caddy on the shared \`caddy_net\` docker network.
|
||
- Two-factor is **required** (\`default_policy: deny\`, rule \`two_factor\` for
|
||
\`*.${AUTHELIA_DOMAIN}\`).
|
||
README_MD
|
||
|
||
local START_AUTHELIA=""
|
||
prompt_yn "Start Authelia now? (y/n):" "y" START_AUTHELIA
|
||
if [ "$START_AUTHELIA" = "y" ] || [ "$START_AUTHELIA" = "Y" ]; then
|
||
docker compose up -d 2>/dev/null && log_success "Authelia started" || log_warning "Failed to start Authelia"
|
||
fi
|
||
|
||
echo ""
|
||
echo " Auth portal: https://auth.${AUTHELIA_DOMAIN}"
|
||
echo " Admin login: ${AUTHELIA_ADMIN_USER} (use Forgot Password to set a real password)"
|
||
echo " README: $AUTHELIA_DIR/README.md"
|
||
echo ""
|
||
}
|
||
|
||
# Adds a second (or third, etc.) independent apex domain to an EXISTING Authelia
|
||
# instance instead of standing up a whole separate Authelia+Redis stack for it.
|
||
# Authelia natively supports this: session.cookies and access_control.rules are
|
||
# both lists, so one instance can hold a distinct cookie scope + login portal per
|
||
# domain, each with its own session (no cross-domain SSO, but also no collision —
|
||
# see the "Running more than one Authelia instance" note in CLAUDE.md for why two
|
||
# domains can't just share one session.cookies entry). Far cheaper on RAM than a
|
||
# second full instance, which matters most on a small droplet.
|
||
add_authelia_domain() {
|
||
local AUTHELIA_DIR="$DOCKER_DIR/authelia"
|
||
local CONFIG_FILE="$AUTHELIA_DIR/config/configuration.yml"
|
||
local CADDY_FILE="$DOCKER_DIR/caddy/Caddyfile"
|
||
|
||
if [ ! -f "$CONFIG_FILE" ]; then
|
||
log_warning "No configuration.yml found at $CONFIG_FILE — install Authelia first."
|
||
return 1
|
||
fi
|
||
|
||
echo ""
|
||
echo " Add another apex domain to this Authelia instance."
|
||
echo " It gets its own session-cookie scope and its own auth.<domain> portal —"
|
||
echo " a separate login/session from your other domain(s) — but shares this"
|
||
echo " same Authelia + Redis container, not a second full stack."
|
||
echo ""
|
||
local NEW_DOMAIN=""
|
||
prompt_text " New domain (e.g., example.com):" "" NEW_DOMAIN
|
||
if [ -z "$NEW_DOMAIN" ]; then
|
||
log_warning "No domain entered — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
if grep -qF "\"*.${NEW_DOMAIN}\"" "$CONFIG_FILE" 2>/dev/null; then
|
||
log_warning "$NEW_DOMAIN is already configured in $CONFIG_FILE — nothing to do."
|
||
return 0
|
||
fi
|
||
|
||
# ── access_control.rules: insert right after "rules:" ────────────────────
|
||
awk -v domain="$NEW_DOMAIN" '
|
||
{ print }
|
||
/^ rules:$/ && !done {
|
||
print " - domain: \"*." domain "\""
|
||
print " policy: two_factor"
|
||
done=1
|
||
}
|
||
' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE"
|
||
|
||
# ── session.cookies: insert right after "cookies:" ────────────────────────
|
||
awk -v domain="$NEW_DOMAIN" '
|
||
{ print }
|
||
/^ cookies:$/ && !done {
|
||
print " - domain: " domain
|
||
print " authelia_url: https://auth." domain
|
||
print " default_redirection_url: https://" domain
|
||
done=1
|
||
}
|
||
' "$CONFIG_FILE" > "$CONFIG_FILE.tmp" && mv "$CONFIG_FILE.tmp" "$CONFIG_FILE"
|
||
|
||
chown 1000:1000 "$CONFIG_FILE" 2>/dev/null || true
|
||
log_success "Added $NEW_DOMAIN to $CONFIG_FILE (access_control rule + session cookie scope)"
|
||
|
||
# ── Caddy portal block for the new domain ─────────────────────────────────
|
||
if [ -f "$CADDY_FILE" ]; then
|
||
if ! grep -q "^auth.${NEW_DOMAIN} {" "$CADDY_FILE"; then
|
||
cat >> "$CADDY_FILE" << CADDY_AUTH_BLOCK2
|
||
|
||
# ── Authelia login portal (${NEW_DOMAIN}) ─────────────────────────────────────
|
||
auth.${NEW_DOMAIN} {
|
||
# See auth.${AUTHELIA_DOMAIN:-<original domain>}'s block above for why
|
||
# header_up X-Forwarded-Host is required here, not optional.
|
||
reverse_proxy authelia:9091 {
|
||
header_up X-Forwarded-Host {http.request.header.X-Forwarded-Host}
|
||
}
|
||
log {
|
||
output file /var/log/caddy/auth.${NEW_DOMAIN}.log
|
||
}
|
||
}
|
||
CADDY_AUTH_BLOCK2
|
||
echo " ✓ Authelia portal block added for auth.${NEW_DOMAIN}"
|
||
docker ps --format '{{.Names}}' | grep -q "^caddy$" && \
|
||
{ docker exec -w /etc/caddy caddy caddy reload 2>/dev/null && echo " ✓ Caddy reloaded" || echo " ⚠ Reload manually: docker exec caddy caddy reload --config /etc/caddy/Caddyfile"; }
|
||
else
|
||
echo " ✓ auth.${NEW_DOMAIN} portal block already exists in the Caddyfile"
|
||
fi
|
||
else
|
||
echo " ℹ Caddy not installed — add an auth.${NEW_DOMAIN} portal block manually later (see README)."
|
||
fi
|
||
|
||
# ── Restart Authelia to pick up the new config ────────────────────────────
|
||
local RESTART_AUTH=""
|
||
prompt_yn " Restart Authelia to apply the new domain? (y/n):" "y" RESTART_AUTH
|
||
if [ "$RESTART_AUTH" = "y" ] || [ "$RESTART_AUTH" = "Y" ]; then
|
||
(cd "$AUTHELIA_DIR" && docker compose restart authelia 2>/dev/null) \
|
||
&& log_success "Authelia restarted" \
|
||
|| log_warning "Restart failed — check: docker compose logs authelia"
|
||
fi
|
||
|
||
echo ""
|
||
echo " Auth portal for $NEW_DOMAIN: https://auth.${NEW_DOMAIN}"
|
||
echo " Protect a service under this domain the same way as any other:"
|
||
echo " myservice.${NEW_DOMAIN} {"
|
||
echo " import authelia"
|
||
echo " reverse_proxy localhost:PORT"
|
||
echo " }"
|
||
echo " Same users/passwords work across every domain on this instance —"
|
||
echo " it's one shared user database, just separate sessions per domain."
|
||
echo ""
|
||
}
|
||
|
||
# Run immediately when executed directly (deferred until after function definition)
|
||
[[ "${_RUN_STANDALONE:-0}" == 1 ]] && install_authelia
|