diff --git a/config/grafana/grafana.ini b/config/grafana/grafana.ini new file mode 100644 index 00000000..93aeee4a --- /dev/null +++ b/config/grafana/grafana.ini @@ -0,0 +1,35 @@ +# ============================================================================= +# HomeLab Stack — Grafana Configuration +# ============================================================================= + +[paths] +provisioning = /etc/grafana/provisioning + +[server] +domain = grafana.$__env{DOMAIN} +root_url = https://grafana.$__env{DOMAIN} + +[security] +admin_user = $__env{GRAFANA_ADMIN_USER} +admin_password = $__env{GRAFANA_ADMIN_PASSWORD} + +[auth.generic_oauth] +enabled = true +name = Authentik +allow_sign_up = true +client_id = $__env{GRAFANA_OAUTH_CLIENT_ID} +client_secret = $__env{GRAFANA_OAUTH_CLIENT_SECRET} +scopes = openid profile email +auth_url = https://$__env{AUTHENTIK_DOMAIN}/application/o/authorize/ +token_url = https://$__env{AUTHENTIK_DOMAIN}/application/o/token/ +api_url = https://$__env{AUTHENTIK_DOMAIN}/application/o/userinfo/ +signout_redirect_url = https://$__env{AUTHENTIK_DOMAIN}/application/o/grafana/end-session/ +role_attribute_path = contains(groups, 'Grafana Admins') && 'Admin' || contains(groups, 'Grafana Editors') && 'Editor' || 'Viewer' + +[users] +auto_assign_org = true +auto_assign_org_role = Viewer + +[analytics] +reporting_enabled = false +check_for_updates = false diff --git a/scripts/authentik-setup.sh b/scripts/authentik-setup.sh new file mode 100755 index 00000000..ddee6863 --- /dev/null +++ b/scripts/authentik-setup.sh @@ -0,0 +1,291 @@ +#!/usr/bin/env bash +# ============================================================================= +# HomeLab Stack -- Authentik SSO Setup Script +# Creates OIDC providers for Grafana, Gitea, Nextcloud, Outline, Open WebUI, Portainer +# Supports --dry-run, safe credential output, user groups, and idempotency. +# Usage: ./scripts/authentik-setup.sh [--dry-run] +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT_DIR=$(dirname "$SCRIPT_DIR") + +# Load .env from root +if [ -f "$ROOT_DIR/.env" ]; then + set -a; source "$ROOT_DIR/.env"; set +a +fi + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${RESET} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } +log_step() { echo; echo -e "${BOLD}${CYAN}==> $*${RESET}"; } + +# Dry-run flag +DRY_RUN=false +if [ "${1:-}" = "--dry-run" ]; then + DRY_RUN=true + log_warn "DRY-RUN MODE ENABLED - No changes will be applied to Authentik" +fi + +DOMAIN="${DOMAIN:-example.com}" +AUTHENTIK_DOMAIN="${AUTHENTIK_DOMAIN:-auth.${DOMAIN}}" +AUTHENTIK_URL="https://${AUTHENTIK_DOMAIN}" +API_URL="$AUTHENTIK_URL/api/v3" +TOKEN="${AUTHENTIK_BOOTSTRAP_TOKEN:-}" + +# Check for required tools +for cmd in curl jq; do + if ! command -v "$cmd" &> /dev/null; then + log_error "Required tool '$cmd' is not installed." + exit 1 + fi +done + +# If no token, attempt to authenticate using bootstrap credentials +if [ -z "$TOKEN" ] && [ -n "${AUTHENTIK_BOOTSTRAP_PASSWORD:-}" ]; then + if [ "$DRY_RUN" = "true" ]; then + log_info "Dry-run: Would attempt to authenticate using bootstrap password" + TOKEN="dry-run-token" + else + log_info "No AUTHENTIK_BOOTSTRAP_TOKEN found. Attempting login to get token..." + LOGIN_RESP=$(curl -sf -X POST "$API_URL/admin/token/" \ + -H "Content-Type: application/json" \ + -d "{\"username\": \"akadmin\", \"password\": \"${AUTHENTIK_BOOTSTRAP_PASSWORD}\"}" || true) + if [ -n "$LOGIN_RESP" ]; then + TOKEN=$(echo "$LOGIN_RESP" | jq -r '.token // empty') + fi + fi +fi + +if [ -z "$TOKEN" ] && [ "$DRY_RUN" = "false" ]; then + log_error "AUTHENTIK_BOOTSTRAP_TOKEN or AUTHENTIK_BOOTSTRAP_PASSWORD must be set in .env" + exit 1 +fi + +AUTH_HEADER="Authorization: Bearer $TOKEN" + +# ------------------------------------------------------------------ +# Wait for Authentik to be ready +# ------------------------------------------------------------------ +if [ "$DRY_RUN" = "false" ]; then + log_step "Waiting for Authentik API..." + for i in $(seq 1 30); do + if curl -sf "$AUTHENTIK_URL/-/health/ready/" -o /dev/null; then + log_info "Authentik is ready" + break + fi + if [ "$i" -eq 30 ]; then + log_error "Authentik did not become ready in 150s" + exit 1 + fi + echo -n "." + sleep 5 + done +fi + +get_default_flow() { + local designation="$1" + if [ "$DRY_RUN" = "true" ]; then + echo "dry-run-flow-pk" + return + fi + curl -sf "$API_URL/flows/instances/?designation=${designation}&ordering=slug" \ + -H "$AUTH_HEADER" | jq -r '.results[0].pk // empty' +} + +get_signing_key() { + if [ "$DRY_RUN" = "true" ]; then + echo "dry-run-key-pk" + return + fi + curl -sf "$API_URL/crypto/certificatekeypairs/?has_key=true&ordering=name" \ + -H "$AUTH_HEADER" | jq -r '.results[0].pk // empty' +} + +# ------------------------------------------------------------------ +# Group Creation (homelab-admins, homelab-users, media-users) +# ------------------------------------------------------------------ +create_group() { + local name="$1" + log_step "Checking/Creating Group: $name" + + if [ "$DRY_RUN" = "true" ]; then + log_info "Dry-run: Would check/create group '$name'" + return + fi + + local existing + existing=$(curl -sf "$API_URL/core/groups/?name=${name}" -H "$AUTH_HEADER" | jq -r '.results[0].pk // empty') + if [ -n "$existing" ]; then + log_info "Group '$name' already exists (PK: $existing)" + else + local payload + payload=$(jq -n --arg name "$name" '{name: $name}') + local response + response=$(curl -sf -X POST "$API_URL/core/groups/" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$payload") + local new_pk + new_pk=$(echo "$response" | jq -r '.pk') + log_info "Group '$name' created successfully (PK: $new_pk)" + fi +} + +create_group "homelab-admins" +create_group "homelab-users" +create_group "media-users" + +# ------------------------------------------------------------------ +# OIDC Provider & Application Creation +# ------------------------------------------------------------------ +create_oidc_provider() { + local name="$1" + local redirect_uri="$2" + local client_id_var="$3" + local client_secret_var="$4" + + log_step "Setup OIDC provider: $name" + + if [ "$DRY_RUN" = "true" ]; then + log_info "Dry-run: Would create OIDC Provider for '$name'" + log_info " Redirect URI: $redirect_uri" + log_info " Client ID Var: $client_id_var" + log_info " Client Sec Var: $client_secret_var" + return + fi + + local flow_pk signing_key + flow_pk=$(get_default_flow authorize) + signing_key=$(get_signing_key) + local slug + slug=$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' ' '-') + + # Idempotence check: check if provider already exists by name + local existing_provider_pk existing_client_id existing_client_secret + local existing_provider_resp + existing_provider_resp=$(curl -sf "$API_URL/providers/oauth2/?name=${name}%20Provider" -H "$AUTH_HEADER" || true) + + if [ -n "$existing_provider_resp" ] && [ "$(echo "$existing_provider_resp" | jq -r '.results | length')" -gt 0 ]; then + existing_provider_pk=$(echo "$existing_provider_resp" | jq -r '.results[0].pk') + existing_client_id=$(echo "$existing_provider_resp" | jq -r '.results[0].client_id') + existing_client_secret=$(echo "$existing_provider_resp" | jq -r '.results[0].client_secret') + log_info " Provider '${name} Provider' already exists (PK: $existing_provider_pk)" + else + local payload + payload=$(jq -n \ + --arg name "${name} Provider" \ + --arg flow "$flow_pk" \ + --arg uri "$redirect_uri" \ + --arg key "$signing_key" \ + '{ + name: $name, + authorization_flow: $flow, + client_type: "confidential", + redirect_uris: $uri, + sub_mode: "hashed_user_id", + include_claims_in_id_token: true, + signing_key: $key + }') + + local response + response=$(curl -sf -X POST "$API_URL/providers/oauth2/" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$payload") + + existing_provider_pk=$(echo "$response" | jq -r '.pk') + existing_client_id=$(echo "$response" | jq -r '.client_id') + existing_client_secret=$(echo "$response" | jq -r '.client_secret') + log_info " Provider created successfully (PK: $existing_provider_pk)" + fi + + # Idempotence check: check if application already exists by slug + local existing_app + existing_app=$(curl -sf "$API_URL/core/applications/?slug=${slug}" -H "$AUTH_HEADER" | jq -r '.results[0].pk // empty') + if [ -n "$existing_app" ]; then + log_info " Application '$name' already exists (PK: $existing_app)" + else + local app_payload + app_payload=$(jq -n \ + --arg name "$name" \ + --arg slug "$slug" \ + --argjson pk "$existing_provider_pk" \ + '{name: $name, slug: $slug, provider: $pk}') + + curl -sf -X POST "$API_URL/core/applications/" \ + -H "$AUTH_HEADER" \ + -H "Content-Type: application/json" \ + -d "$app_payload" > /dev/null + log_info " Application created successfully: $name" + fi + + # Write back credentials safely to root .env if it exists + if [ -f "$ROOT_DIR/.env" ]; then + if grep -q "^${client_id_var}=" "$ROOT_DIR/.env"; then + sed -i "s|^${client_id_var}=.*|${client_id_var}=${existing_client_id}|" "$ROOT_DIR/.env" + else + echo "${client_id_var}=${existing_client_id}" >> "$ROOT_DIR/.env" + fi + + if grep -q "^${client_secret_var}=" "$ROOT_DIR/.env"; then + sed -i "s|^${client_secret_var}=.*|${client_secret_var}=${existing_client_secret}|" "$ROOT_DIR/.env" + else + echo "${client_secret_var}=${existing_client_secret}" >> "$ROOT_DIR/.env" + fi + fi + + # Display safe credentials to console + echo -e " -------------------------------------------------" + echo -e " [OK] Created/Verified provider: ${BOLD}$name${RESET}" + echo -e " Client ID: ${GREEN}$existing_client_id${RESET}" + echo -e " Client Secret: ${YELLOW}$existing_client_secret${RESET}" + echo -e " Redirect URI: $redirect_uri" + echo -e " -------------------------------------------------" +} + +# ------------------------------------------------------------------ +# Define all OIDC Providers +# ------------------------------------------------------------------ +create_oidc_provider \ + "Grafana" \ + "https://grafana.${DOMAIN}/login/generic_oauth" \ + "GRAFANA_OAUTH_CLIENT_ID" \ + "GRAFANA_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Gitea" \ + "https://git.${DOMAIN}/user/oauth2/Authentik/callback" \ + "GITEA_OAUTH_CLIENT_ID" \ + "GITEA_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Nextcloud" \ + "https://nextcloud.${DOMAIN}/index.php/apps/sociallogin/custom_oidc/Authentik" \ + "NEXTCLOUD_OAUTH_CLIENT_ID" \ + "NEXTCLOUD_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Outline" \ + "https://docs.${DOMAIN}/auth/oidc.callback" \ + "OUTLINE_OAUTH_CLIENT_ID" \ + "OUTLINE_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Open WebUI" \ + "https://ai.${DOMAIN}/oauth/oidc/callback" \ + "OPEN_WEBUI_OAUTH_CLIENT_ID" \ + "OPEN_WEBUI_OAUTH_CLIENT_SECRET" + +create_oidc_provider \ + "Portainer" \ + "https://portainer.${DOMAIN}/" \ + "PORTAINER_OAUTH_CLIENT_ID" \ + "PORTAINER_OAUTH_CLIENT_SECRET" + +log_step "All OIDC/OAuth Providers checked/configured successfully!" +if [ "$DRY_RUN" = "false" ]; then + log_info "Credentials have been automatically written to the root .env file." +fi diff --git a/scripts/nextcloud-oidc-setup.sh b/scripts/nextcloud-oidc-setup.sh new file mode 100755 index 00000000..636ee5f9 --- /dev/null +++ b/scripts/nextcloud-oidc-setup.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# ============================================================================= +# HomeLab Stack -- Nextcloud OIDC Setup Script +# Installs sociallogin Nextcloud app and configures custom OIDC with Authentik. +# Usage: ./scripts/nextcloud-oidc-setup.sh +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) +ROOT_DIR=$(dirname "$SCRIPT_DIR") + +# Load .env from root +if [ -f "$ROOT_DIR/.env" ]; then + set -a; source "$ROOT_DIR/.env"; set +a +fi + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' +CYAN='\033[0;36m'; BOLD='\033[1m'; RESET='\033[0m' +log_info() { echo -e "${GREEN}[INFO]${RESET} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${RESET} $*"; } +log_error() { echo -e "${RED}[ERROR]${RESET} $*" >&2; } +log_step() { echo; echo -e "${BOLD}${CYAN}==> $*${RESET}"; } + +DOMAIN="${DOMAIN:-example.com}" +AUTHENTIK_DOMAIN="${AUTHENTIK_DOMAIN:-auth.${DOMAIN}}" +AUTHENTIK_URL="https://${AUTHENTIK_DOMAIN}" + +NEXTCLOUD_CONTAINER="nextcloud" + +if ! docker ps --format '{{.Names}}' | grep -q "^${NEXTCLOUD_CONTAINER}$"; then + log_error "Nextcloud container is not running. Start the storage stack first!" + exit 1 +fi + +log_step "Installing 'sociallogin' Nextcloud app..." +if docker exec -u www-data "$NEXTCLOUD_CONTAINER" php occ app:list | grep -q "sociallogin"; then + log_info "Nextcloud app 'sociallogin' is already installed." +else + docker exec -u www-data "$NEXTCLOUD_CONTAINER" php occ app:install sociallogin + log_info "Nextcloud app 'sociallogin' installed successfully." +fi + +log_step "Configuring custom OIDC provider (Authentik) in Nextcloud..." +# Configure sociallogin Custom OIDC settings using Nextcloud OCC +# Read client ID and client secret from environment +CLIENT_ID="${NEXTCLOUD_OAUTH_CLIENT_ID:-}" +CLIENT_SECRET="${NEXTCLOUD_OAUTH_CLIENT_SECRET:-}" + +if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then + log_warn "NEXTCLOUD_OAUTH_CLIENT_ID or NEXTCLOUD_OAUTH_CLIENT_SECRET not set in .env." + log_warn "Please run ./scripts/authentik-setup.sh first to generate credentials." + exit 1 +fi + +# Authentik Custom OIDC configuration payload JSON +OIDC_CONFIG=$(jq -n \ + --arg id "$CLIENT_ID" \ + --arg secret "$CLIENT_SECRET" \ + --arg auth_url "https://${AUTHENTIK_DOMAIN}/application/o/authorize/" \ + --arg token_url "https://${AUTHENTIK_DOMAIN}/application/o/token/" \ + --arg user_url "https://${AUTHENTIK_DOMAIN}/application/o/userinfo/" \ + '{ + "custom_providers": { + "custom_oidc": { + "Authentik": { + "appid": $id, + "secret": $secret, + "authUrl": $auth_url, + "tokenUrl": $token_url, + "userinfoUrl": $user_url, + "scopes": ["openid", "profile", "email"], + "style": "custom", + "icon": "key", + "title": "Authentik" + } + } + } + }') + +log_info "Applying sociallogin custom OIDC configuration to Nextcloud..." +# Using OCC config:app:set to write OIDC configuration +docker exec -i -u www-data "$NEXTCLOUD_CONTAINER" php occ config:app:set sociallogin custom_providers --value="$OIDC_CONFIG" + +# Allow login without password (only SSO) if configured, or keep dual login options +docker exec -u www-data "$NEXTCLOUD_CONTAINER" php occ config:app:set sociallogin prevent_create_email --value="0" +docker exec -u www-data "$NEXTCLOUD_CONTAINER" php occ config:app:set sociallogin auto_create_groups --value="1" + +log_step "Nextcloud OIDC integration completed successfully!" diff --git a/stacks/ai/.env.example b/stacks/ai/.env.example new file mode 100644 index 00000000..61b58e77 --- /dev/null +++ b/stacks/ai/.env.example @@ -0,0 +1,12 @@ +# AI Stack -- Environment Variables +# Copy to .env and fill in required values before running. + +DOMAIN=yourdomain.com +TZ=Asia/Shanghai + +# Authentik domain +AUTHENTIK_DOMAIN=auth.yourdomain.com + +# Open WebUI OIDC / OAuth2 credentials -- filled by scripts/authentik-setup.sh +OPEN_WEBUI_OAUTH_CLIENT_ID= +OPEN_WEBUI_OAUTH_CLIENT_SECRET= diff --git a/stacks/ai/docker-compose.yml b/stacks/ai/docker-compose.yml index 1ef0e1c4..c32e15f6 100644 --- a/stacks/ai/docker-compose.yml +++ b/stacks/ai/docker-compose.yml @@ -34,6 +34,14 @@ services: - OLLAMA_BASE_URL=http://ollama:11434 - WEBUI_SECRET_KEY=${WEBUI_SECRET_KEY:-changeme-secret-32chars} - DEFAULT_LOCALE=zh-CN + - ENABLE_OAUTH_SIGNUP=true + - OAUTH_ENABLED=true + - OAUTH_NAME=Authentik + - OAUTH_CLIENT_ID=${OPEN_WEBUI_OAUTH_CLIENT_ID} + - OAUTH_CLIENT_SECRET=${OPEN_WEBUI_OAUTH_CLIENT_SECRET} + - OPENID_PROVIDER_URL=https://${AUTHENTIK_DOMAIN}/application/o/open-webui/.well-known/openid-configuration + - OAUTH_SCOPES=openid profile email + - OAUTH_MERGE_ACCOUNTS_BY_EMAIL=true depends_on: ollama: condition: service_healthy diff --git a/stacks/base/.env.example b/stacks/base/.env.example new file mode 100644 index 00000000..9e68c559 --- /dev/null +++ b/stacks/base/.env.example @@ -0,0 +1,9 @@ +# Base Infrastructure Stack -- Environment Variables +# Copy to .env and fill in required values before running. + +DOMAIN=yourdomain.com +TZ=Asia/Shanghai + +# Portainer OAuth credentials -- filled by scripts/authentik-setup.sh +PORTAINER_OAUTH_CLIENT_ID= +PORTAINER_OAUTH_CLIENT_SECRET= diff --git a/stacks/monitoring/docker-compose.yml b/stacks/monitoring/docker-compose.yml index ea1a2718..0f4cfd9d 100644 --- a/stacks/monitoring/docker-compose.yml +++ b/stacks/monitoring/docker-compose.yml @@ -56,6 +56,7 @@ services: volumes: - grafana_data:/var/lib/grafana - ../../config/grafana/provisioning:/etc/grafana/provisioning:ro + - ../../config/grafana/grafana.ini:/etc/grafana/grafana.ini:ro healthcheck: test: ["CMD", "wget", "-q", "--spider", "http://localhost:3000/api/health"] interval: 30s diff --git a/stacks/productivity/docker-compose.yml b/stacks/productivity/docker-compose.yml index 4b295a98..c472bcba 100644 --- a/stacks/productivity/docker-compose.yml +++ b/stacks/productivity/docker-compose.yml @@ -21,6 +21,8 @@ services: - GITEA__mailer__ENABLED=false - GITEA__oauth2__ENABLE=true - GITEA__oauth2__JWT_SECRET=${GITEA_OAUTH2_JWT_SECRET} + - GITEA_OAUTH_CLIENT_ID=${GITEA_OAUTH_CLIENT_ID} + - GITEA_OAUTH_CLIENT_SECRET=${GITEA_OAUTH_CLIENT_SECRET} volumes: - gitea-data:/data labels: diff --git a/stacks/sso/docker-compose.yml b/stacks/sso/docker-compose.yml index 98660da2..08fbfb40 100644 --- a/stacks/sso/docker-compose.yml +++ b/stacks/sso/docker-compose.yml @@ -34,7 +34,7 @@ services: # PostgreSQL — Authentik database # --------------------------------------------------------------------------- postgresql: - image: postgres:16-alpine + image: postgres:16.4-alpine container_name: authentik-postgres restart: unless-stopped volumes: @@ -56,7 +56,7 @@ services: # Redis — Authentik cache/queue # --------------------------------------------------------------------------- redis: - image: redis:7-alpine + image: redis:7.4.0-alpine container_name: authentik-redis restart: unless-stopped command: redis-server --requirepass ${AUTHENTIK_REDIS_PASSWORD} --save 60 1 --loglevel warning