feat/traefik-migration #4

Merged
Damien merged 2 commits from feat/traefik-migration into main 2026-05-13 14:37:28 +00:00
Showing only changes of commit 9f6e583135 - Show all commits

View File

@@ -33,14 +33,16 @@ check_debian() {
} }
# Configuration variables (can be overridden via environment) # Configuration variables (can be overridden via environment)
HOSTNAME="${PROXY_HOSTNAME:-proxy}" PROXY_HOSTNAME="${PROXY_HOSTNAME:-proxy}"
TRAEFIK_DIR="$HOME/traefik" TRAEFIK_DIR="$HOME/traefik"
TIMEZONE="${TZ:-Europe/Paris}"
# INFOMANIAK_ACCESS_TOKEN must be set in the environment or will be prompted. # ACME_EMAIL is required for Let's Encrypt certificate issuance notifications.
# WARNING: Without this token, Traefik cannot issue certificates via DNS challenge. # Export it before running: export ACME_EMAIL=you@example.com
# Export it before running: export INFOMANIAK_ACCESS_TOKEN=your_token_here ACME_EMAIL="${ACME_EMAIL:-}"
INFOMANIAK_ACCESS_TOKEN="${INFOMANIAK_ACCESS_TOKEN:-}"
# Optional: pre-authorize Tailscale non-interactively (recommended for curl|bash).
# Generate at https://login.tailscale.com/admin/settings/keys
TS_AUTHKEY="${TS_AUTHKEY:-}"
main() { main() {
log_info "=== Proxy Server Deployment (Traefik v3) ===" log_info "=== Proxy Server Deployment (Traefik v3) ==="
@@ -48,36 +50,69 @@ main() {
check_root check_root
check_debian check_debian
# Prompt for token if not set # Prompt for ACME email if not set. Only attempt interactive prompt when a
if [[ -z "$INFOMANIAK_ACCESS_TOKEN" ]]; then # TTY is available — when invoked via `curl … | bash`, stdin is the pipe
log_warn "INFOMANIAK_ACCESS_TOKEN is not set in the environment." # and reading from /dev/tty may also fail (e.g. non-interactive runners).
read -rsp "Enter your Infomaniak API token: " INFOMANIAK_ACCESS_TOKEN if [[ -z "$ACME_EMAIL" ]]; then
echo if [[ -r /dev/tty ]]; then
if [[ -z "$INFOMANIAK_ACCESS_TOKEN" ]]; then log_warn "ACME_EMAIL is not set in the environment."
log_error "Token is required for DNS challenge certificate issuance. Aborting." read -rp "Enter your ACME email address: " ACME_EMAIL < /dev/tty || true
fi
if [[ -z "$ACME_EMAIL" ]]; then
log_error "ACME_EMAIL is required. Export it before running:"
log_error " export ACME_EMAIL=you@example.com"
exit 1 exit 1
fi fi
fi fi
log_info "Setting hostname to: $HOSTNAME" if [[ "$(hostname)" != "$PROXY_HOSTNAME" ]]; then
echo "$HOSTNAME" | sudo tee /etc/hostname > /dev/null log_info "Setting hostname to: $PROXY_HOSTNAME"
sudo hostnamectl set-hostname "$HOSTNAME" echo "$PROXY_HOSTNAME" | sudo tee /etc/hostname > /dev/null
sudo hostnamectl set-hostname "$PROXY_HOSTNAME"
else
log_info "Hostname already set to $PROXY_HOSTNAME, skipping."
fi
log_info "Installing base packages..." log_info "Installing base packages..."
sudo apt update -qq sudo apt update -qq
sudo apt install -y -qq vim ca-certificates curl gnupg lsb-release fail2ban unattended-upgrades at ufw > /dev/null sudo apt install -y -qq vim ca-certificates curl gnupg lsb-release fail2ban unattended-upgrades ufw ethtool networkd-dispatcher > /dev/null
log_info "Installing Tailscale..." log_info "Installing Tailscale..."
curl -fsSL https://tailscale.com/install.sh | sh curl -fsSL https://tailscale.com/install.sh | sh
log_info "Connecting to Tailscale..."
sudo tailscale up --ssh --advertise-exit-node
log_info "Configuring sysctl for exit-node support..." log_info "Configuring sysctl for exit-node support..."
echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf > /dev/null echo 'net.ipv4.ip_forward = 1' | sudo tee /etc/sysctl.d/99-tailscale.conf > /dev/null
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf > /dev/null echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf > /dev/null
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf > /dev/null sudo sysctl -p /etc/sysctl.d/99-tailscale.conf > /dev/null
log_info "Configuring ethtool for Tailscale UDP GRO forwarding..."
# Determine the default-route interface and disable rx-gro-list / enable
# rx-udp-gro-forwarding to avoid the Tailscale throughput warning.
NETDEV=$(ip -o route show default | awk '{print $5; exit}')
if [[ -z "$NETDEV" ]]; then
log_error "Could not determine default network interface."
exit 1
fi
sudo ethtool -K "$NETDEV" rx-udp-gro-forwarding on rx-gro-list off
# Persist across reboots via networkd-dispatcher
sudo mkdir -p /etc/networkd-dispatcher/routable.d
printf '#!/bin/sh\nethtool -K %s rx-udp-gro-forwarding on rx-gro-list off\n' "$NETDEV" \
| sudo tee /etc/networkd-dispatcher/routable.d/50-tailscale > /dev/null
sudo chmod 755 /etc/networkd-dispatcher/routable.d/50-tailscale
# Connect to Tailscale only if not already logged in (idempotent re-runs).
if ! sudo tailscale status >/dev/null 2>&1; then
log_info "Connecting to Tailscale..."
if [[ -n "$TS_AUTHKEY" ]]; then
sudo tailscale up --ssh --advertise-exit-node --authkey="$TS_AUTHKEY"
else
log_warn "TS_AUTHKEY not set — interactive browser auth required."
sudo tailscale up --ssh --advertise-exit-node
fi
else
log_info "Tailscale already connected, skipping."
fi
log_info "Installing Docker..." log_info "Installing Docker..."
sudo mkdir -m 0755 -p /etc/apt/keyrings sudo mkdir -m 0755 -p /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg curl -fsSL https://download.docker.com/linux/debian/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
@@ -89,16 +124,55 @@ main() {
sudo usermod -aG docker "$USER" sudo usermod -aG docker "$USER"
log_info "Configuring UFW firewall..." log_info "Configuring UFW firewall..."
sudo ufw --force reset > /dev/null # Idempotent: only reset if our marker rules are absent. This preserves any
sudo ufw default deny incoming > /dev/null # rules added later by the operator on re-runs.
sudo ufw default allow outgoing > /dev/null if ! sudo ufw status | grep -q "tailscale0"; then
# Allow HTTP/HTTPS from the public internet (handled by Traefik) sudo ufw --force reset > /dev/null
sudo ufw allow 80/tcp > /dev/null sudo ufw default deny incoming > /dev/null
sudo ufw allow 443/tcp > /dev/null sudo ufw default allow outgoing > /dev/null
# Allow all traffic on Tailscale interface (dashboard, metrics, SSH, admin — everything internal) # Allow HTTP/HTTPS from the public internet (handled by Traefik)
sudo ufw allow in on tailscale0 > /dev/null sudo ufw allow 80/tcp > /dev/null
# Port 22 is intentionally NOT opened publicly; Tailscale SSH covers management access sudo ufw allow 443/tcp > /dev/null
sudo ufw --force enable > /dev/null # Allow all traffic on Tailscale interface (dashboard, metrics, SSH, admin — everything internal)
sudo ufw allow in on tailscale0 > /dev/null
# Port 22 is intentionally NOT opened publicly; Tailscale SSH covers management access
sudo ufw --force enable > /dev/null
else
log_info "UFW already configured, skipping reset."
fi
log_info "Configuring Fail2ban for Traefik..."
# Create the log file and fail2ban socket dir now so:
# - fail2ban can open the log file when the jail loads.
# - docker doesn't bind-mount /var/run/fail2ban as an empty dir if the
# exporter container starts before fail2ban writes its socket.
sudo mkdir -p /var/log/traefik /var/run/fail2ban
sudo chown "$USER":"$USER" /var/log/traefik
sudo touch /var/log/traefik/access.log
sudo tee /etc/fail2ban/filter.d/traefik.conf > /dev/null << 'EOF'
[Definition]
# Match JSON log lines where ClientHost is the offending IP and DownstreamStatus
# is an auth/abuse status (401, 403, 429) or a server error (5xx).
# Legitimate 404s on missing assets are excluded so dev traffic doesn't ban users.
# Two patterns cover both possible field orderings in the JSON.
failregex = ^.*"ClientHost":"<HOST>".*"DownstreamStatus":(401|403|429|5[0-9]{2})
^.*"DownstreamStatus":(401|403|429|5[0-9]{2}).*"ClientHost":"<HOST>"
ignoreregex =
EOF
sudo tee /etc/fail2ban/jail.d/traefik.conf > /dev/null << 'EOF'
[traefik-auth]
enabled = true
filter = traefik
logpath = /var/log/traefik/access.log
maxretry = 10
findtime = 5m
bantime = 1h
action = iptables-multiport[name=traefik, port="80,443", protocol=tcp]
EOF
sudo systemctl restart fail2ban
log_info "Creating Traefik stack under $TRAEFIK_DIR..." log_info "Creating Traefik stack under $TRAEFIK_DIR..."
mkdir -p "$TRAEFIK_DIR/conf.d" mkdir -p "$TRAEFIK_DIR/conf.d"
@@ -107,27 +181,24 @@ main() {
touch "$TRAEFIK_DIR/acme.json" touch "$TRAEFIK_DIR/acme.json"
chmod 600 "$TRAEFIK_DIR/acme.json" chmod 600 "$TRAEFIK_DIR/acme.json"
# Host log directory (mounted into container so Fail2ban can parse access logs)
sudo mkdir -p /var/log/traefik
sudo chown "$USER":"$USER" /var/log/traefik
# Write .env so the token survives reboots without embedding it in compose
cat > "$TRAEFIK_DIR/.env" << EOF
INFOMANIAK_ACCESS_TOKEN=${INFOMANIAK_ACCESS_TOKEN}
EOF
chmod 600 "$TRAEFIK_DIR/.env"
# --- docker-compose.yml --- # --- docker-compose.yml ---
# SECURITY: The dashboard/API entrypoint is bound to 127.0.0.1 ONLY.
# Combined with `api.insecure: true` in traefik.yml, the dashboard has no
# authentication — it is only reachable via the host loopback and exposed
# selectively over the tailnet through `tailscale serve`. DO NOT change
# this port binding to 0.0.0.0 or any non-loopback address.
cat > "$TRAEFIK_DIR/docker-compose.yml" << 'EOF' cat > "$TRAEFIK_DIR/docker-compose.yml" << 'EOF'
services: services:
traefik: traefik:
image: traefik:v3 image: traefik:v3
container_name: traefik container_name: traefik
restart: unless-stopped restart: unless-stopped
dns:
- 100.100.100.100
ports: ports:
- "80:80" - "80:80"
- "443:443" - "443:443"
# Dashboard bound to localhost only; exposed via Tailscale serve # MUST stay on 127.0.0.1: dashboard is unauthenticated (see traefik.yml).
- "127.0.0.1:8080:8080" - "127.0.0.1:8080:8080"
volumes: volumes:
- ./traefik.yml:/etc/traefik/traefik.yml:ro - ./traefik.yml:/etc/traefik/traefik.yml:ro
@@ -135,22 +206,24 @@ services:
- ./acme.json:/acme.json - ./acme.json:/acme.json
- /var/log/traefik:/var/log/traefik - /var/log/traefik:/var/log/traefik
- /etc/localtime:/etc/localtime:ro - /etc/localtime:/etc/localtime:ro
environment:
- INFOMANIAK_ACCESS_TOKEN=${INFOMANIAK_ACCESS_TOKEN}
fail2ban-exporter: fail2ban-exporter:
image: hectorjsmith/fail2ban-prometheus-exporter:latest image: registry.gitlab.com/hctrdev/fail2ban-prometheus-exporter:latest
container_name: fail2ban-exporter container_name: fail2ban-exporter
restart: unless-stopped restart: unless-stopped
user: root
ports: ports:
# Metrics reachable only via Tailscale (127.0.0.1 binding + UFW blocks public access) # Metrics reachable only via Tailscale (127.0.0.1 binding + UFW blocks public access)
- "127.0.0.1:9191:9191" - "127.0.0.1:9191:9191"
volumes: volumes:
- /var/run/fail2ban/fail2ban.sock:/var/run/fail2ban/fail2ban.sock # Mount the directory, not the socket file: avoids Docker creating a directory
# at the path when fail2ban is briefly down and recreating its socket.
- /var/run/fail2ban:/var/run/fail2ban
EOF EOF
# --- traefik.yml (static config) --- # --- traefik.yml (static config) ---
cat > "$TRAEFIK_DIR/traefik.yml" << 'EOF' # Unquoted EOF: ${ACME_EMAIL} must expand at write time into the static config.
cat > "$TRAEFIK_DIR/traefik.yml" << EOF
entryPoints: entryPoints:
web: web:
address: ":80" address: ":80"
@@ -167,11 +240,12 @@ entryPoints:
address: ":8080" address: ":8080"
certificatesResolvers: certificatesResolvers:
infomaniak: letsencrypt:
acme: acme:
email: "${ACME_EMAIL}"
storage: /acme.json storage: /acme.json
dnsChallenge: httpChallenge:
provider: infomaniak entryPoint: web
providers: providers:
file: file:
@@ -187,6 +261,9 @@ metrics:
api: api:
dashboard: true dashboard: true
# insecure exposes the dashboard on the :8080 entrypoint without auth.
# This is acceptable ONLY because docker-compose.yml binds 8080 to 127.0.0.1.
# Public reach requires going through `tailscale serve` (tailnet-authenticated).
insecure: true insecure: true
accessLog: accessLog:
@@ -204,7 +281,7 @@ http:
- websecure - websecure
service: gitea service: gitea
tls: tls:
certResolver: infomaniak certResolver: letsencrypt
services: services:
gitea: gitea:
@@ -214,17 +291,24 @@ http:
EOF EOF
log_info "Starting Traefik stack..." log_info "Starting Traefik stack..."
# Use sg to apply the docker group without requiring a re-login # Use sg to apply the docker group without requiring a re-login.
sg docker -c "docker compose -f $TRAEFIK_DIR/docker-compose.yml up -d" # cd into the dir so paths inside the command don't break on spaces in $HOME.
(cd "$TRAEFIK_DIR" && sg docker -c "docker compose up -d")
log_info "Exposing Traefik dashboard via Tailscale serve..." # Idempotent: only register the serve mapping if it isn't already present.
sudo tailscale serve --bg http://localhost:8080 # Use --json (stable contract) and capture stdout+stderr so any help/error
# output on older tailscale builds doesn't leak to the user's terminal.
if ! sudo tailscale serve status --json 2>&1 | grep -q '"127.0.0.1:8080"'; then
log_info "Exposing Traefik dashboard via Tailscale serve..."
sudo tailscale serve --bg http://localhost:8080
else
log_info "Tailscale serve already configured for dashboard, skipping."
fi
log_info "Configuring MOTD..." log_info "Configuring MOTD..."
sudo chmod -x /etc/update-motd.d/* 2>/dev/null || true # /etc/profile.d/ runs for every interactive login shell regardless of the SSH
# implementation (works for both Tailscale SSH and regular OpenSSH).
cat << 'MOTD' | sudo tee /etc/update-motd.d/00-proxy > /dev/null cat << 'MOTD' | sudo tee /etc/profile.d/00-proxy.sh > /dev/null
#!/bin/bash
TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' ' TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' '
/"Self"/ { in_self=1 } /"Self"/ { in_self=1 }
in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit } in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit }
@@ -253,12 +337,11 @@ echo " sudo tailscale serve status"
echo "─────────────────────────────────────────" echo "─────────────────────────────────────────"
echo "" echo ""
MOTD MOTD
sudo chmod +x /etc/update-motd.d/00-proxy
TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' ' TS_FQDN=$(tailscale status --json 2>/dev/null | awk -F'"' '
/"Self"/ { in_self=1 } /"Self"/ { in_self=1 }
in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit } in_self && /"DNSName"/ { gsub(/\.$/, "", $4); print $4; exit }
' || echo "${HOSTNAME}.ts.net") ' || echo "${PROXY_HOSTNAME}.ts.net")
echo "" echo ""
log_info "==========================================" log_info "=========================================="