diff --git a/.gitignore b/.gitignore index 28c97c913..9df4afb1d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,8 @@ testing_temp/ **/target/ WEB-INF/ log*.xml -port.txt \ No newline at end of file +port.txt +*.env +forwardauth/.env +forwardauth/auth-app/oauth2-proxy/oauth2-proxy.cfg +*.log \ No newline at end of file diff --git a/forwardauth/auth-app/compose.yml b/forwardauth/auth-app/compose.yml new file mode 100644 index 000000000..ea49db2b9 --- /dev/null +++ b/forwardauth/auth-app/compose.yml @@ -0,0 +1,50 @@ +name: proxy +services: + traefik: + image: traefik:v3.6 + container_name: traefik + ports: + - "2025:80" # web entrypoint + # - "443:443" # websecure entrypoint + - "8080:8080" # dashboard + volumes: + # docker + # - /var/run/docker.sock:/var/run/docker.sock:ro + # - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro + # - ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro + + # podman and SELinux + - ${XDG_RUNTIME_DIR}/podman/podman.sock:/var/run/docker.sock:ro + - ./traefik/traefik.yml:/etc/traefik/traefik.yml:ro,Z + - ./traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro,Z + networks: + - auth + environment: + - STACK_NAME=auth + # podman + security_opt: + - label=disable + restart: unless-stopped + oauth2-proxy: + container_name: oauth2-proxy + image: quay.io/oauth2-proxy/oauth2-proxy:v7.14.2 + command: --config /oauth2-proxy.cfg + hostname: oauth2-proxy + env_file: + - .env + volumes: + - "./oauth2-proxy/oauth2-proxy.cfg:/oauth2-proxy.cfg:ro,Z" + restart: unless-stopped + networks: + - auth + + # Test service with authentication + whoami: + image: traefik/whoami + container_name: whoami + networks: + - auth + +networks: + auth: + name: auth diff --git a/forwardauth/auth-app/deploy/README.md b/forwardauth/auth-app/deploy/README.md new file mode 100644 index 000000000..b7c14060b --- /dev/null +++ b/forwardauth/auth-app/deploy/README.md @@ -0,0 +1,141 @@ +# Traefik/OAuth2-Proxy Deployment on credo hosts + +This script automates the deployment of the Traefik reverse proxy with OAuth2-Proxy authentication across multiple CReDO hosts. + +## Supported Hosts + +| Host | User | Traefik Port | Nginx Port | Short hostname | +|------|------|--------------|------------|------| +| credo-integration-01.dafni.rl.ac.uk | shared | 9050 | 8050 | ci01.credo | +| credo-datahost-01.dafni.rl.ac.uk | cadent | 9051 | 8051 | cd01.credo | +| credo-datahost-02.dafni.rl.ac.uk | ngt | 9051 | 8051 | cd02.credo | +| credo-datahost-03.dafni.rl.ac.uk | shared | 9050 | 8050 | cd03.credo | + +The script automatically detects the hostname and maps: +- `credo-integration-01` → `ci01.credo` +- `credo-datahost-01` → `cd01.credo` +- `credo-datahost-02` → `cd02.credo` +- `credo-datahost-03` → `cd03.credo` + +## Prerequisites + +- Podman and podman-compose installed +- Access to the Keycloak admin console to retrieve/configure client secrets +- Appropriate user permissions on the target host + +## Usage + +### 1. Basic Deployment + +Run the script without arguments to auto-detect the host, or specify a short hostname: + +```bash +# Auto-detect current host from $(hostname) +./deploy.sh + +``` + +The script will automatically map full hostnames to short names: +- On `credo-integration-01`: auto-detects as `ci01.credo` +- On `credo-datahost-01`: auto-detects as `cd01.credo` +- On `credo-datahost-02`: auto-detects as `cd02.credo` +- On `credo-datahost-03`: auto-detects as `cd03.credo` + +Run as the appropriate user for the host: + +```bash +# Auto-detect current host +sudo -u ./deploy.sh +``` + + +```bash +# ci01.credo (auto-detected from credo-integration-01) +sudo -u shared bash -c 'cd ~ && rm -rf stack && git clone https://github.com/TheWorldAvatar/stack.git && cd stack && git checkout add-traefik-support && cd forwardauth/auth-app && ./deploy/deploy.sh' + +# cd01.credo (auto-detected from credo-datahost-01) +sudo -u cadent bash -c 'cd ~ && rm -rf stack && git clone https://github.com/TheWorldAvatar/stack.git && cd stack && git checkout add-traefik-support && cd forwardauth/auth-app && ./deploy/deploy.sh' + +# cd02.credo (auto-detected from credo-datahost-02) +sudo -u ngt bash -c 'cd ~ && rm -rf stack && git clone https://github.com/TheWorldAvatar/stack.git && cd stack && git checkout add-traefik-support && cd forwardauth/auth-app && ./deploy/deploy.sh' + +# cd03.credo (auto-detected from credo-datahost-03) +sudo -u shared bash -c 'cd ~ && rm -rf stack && git clone https://github.com/TheWorldAvatar/stack.git && cd stack && git checkout add-traefik-support && cd forwardauth/auth-app && ./deploy/deploy.sh' +``` + + +Examples for each host: +```bash +# ci01.credo (auto-detected from credo-integration-01) +sudo -u shared ./deploy.sh + +# cd01.credo (auto-detected from credo-datahost-01) +sudo -u cadent ./deploy.sh + +# cd02.credo (auto-detected from credo-datahost-02) +sudo -u ngt ./deploy.sh + +# cd03.credo (auto-detected from credo-datahost-03) +sudo -u shared ./deploy.sh +``` + +### 2. First-Time Setup + +On first deployment, you'll be prompted for the Keycloak client secret: + +``` +Enter Keycloak client secret for the 'traefik' client : +``` + +### 3. What the Script Does + +1. **Detects/validates** the current host +2. **Reads secrets** for OAuth2 configuration +3. **Creates `.env` file** with: + - Keycloak client credentials + - Generated security secrets + - Host-specific configuration +4. **Generates `oauth2-proxy.cfg`** from template with: + - Correct hostname/FQDN + - Keycloak connection details + - Cookie and JWT settings +5. **Updates `compose.yml`** to expose correct Traefik port +6. **Updates `traefik/dynamic.yml`** to proxy to correct Nginx port +7. **Stops any existing containers** +8. **Starts the stack** using podman-compose + +## Configuration Files + +After running the script, the following files will be created/updated: + +- **`.env`** - Environment variables with secrets and configuration +- **`oauth2-proxy/oauth2-proxy.cfg`** - OAuth2-Proxy configuration +- **`compose.yml`** - Updated with correct Traefik port +- **`traefik/dynamic.yml`** - Updated with correct Nginx port + +## Accessing the Services + +After deployment: + +- **Traefik Dashboard:** http://localhost:8080 +- **Traefik Entry Point:** http://localhost:[TRAEFIK_PORT] +- **Protected Test Service:** http://localhost:[TRAEFIK_PORT]/whoami +- **Public Test Service:** http://localhost:[TRAEFIK_PORT]/whoami-public +- **External Access:** https://[FQDN] + +## Keycloak Configuration + +Ensure the following settings in Keycloak for the `traefik` client: + +- **Client ID:** traefik +- **Access Type:** confidential +- **Valid Redirect URIs:** + - https://ci01.credo/* + - https://cd01.credo/* + - https://cd02.credo/* + - https://cd03.credo/* + - http://localhost:[TRAEFIK_PORT]/* +- **Web Origins:** + +- **Client Protocol:** openid-connect + +Note: The FQDNs now use the short hostnames from `/etc/hosts` (e.g., `ci01.credo`) rather than the full DAFNI domain names. diff --git a/forwardauth/auth-app/deploy/deploy.sh b/forwardauth/auth-app/deploy/deploy.sh new file mode 100755 index 000000000..4d78e42f8 --- /dev/null +++ b/forwardauth/auth-app/deploy/deploy.sh @@ -0,0 +1,298 @@ +#!/bin/bash + +set -e + +# Script to deploy Traefik/OAuth2-Proxy stack on different CReDO hosts +# Usage: ./deploy.sh [hostname] + +# Color codes for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get the directory where this script is located +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +BASE_DIR="$(dirname "$SCRIPT_DIR")" + +# Function to print colored messages +log_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +log_warn() { + echo -e "${YELLOW}[WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Host configuration table +declare -A HOST_USER +declare -A HOST_TRAEFIK_PORT +declare -A HOST_NGINX_PORT +declare -A HOST_FQDN +declare -A HOSTNAME_TO_SHORT + +# Mapping from full hostname to short name +HOSTNAME_TO_SHORT["credo-integration-01"]="ci01.credo" +HOSTNAME_TO_SHORT["credo-integration-01.dafni.rl.ac.uk"]="ci01.credo" +HOSTNAME_TO_SHORT["credo-datahost-01"]="cd01.credo" +HOSTNAME_TO_SHORT["credo-datahost-01.dafni.rl.ac.uk"]="cd01.credo" +HOSTNAME_TO_SHORT["credo-datahost-02"]="cd02.credo" +HOSTNAME_TO_SHORT["credo-datahost-02.dafni.rl.ac.uk"]="cd02.credo" +HOSTNAME_TO_SHORT["credo-datahost-03"]="cd03.credo" +HOSTNAME_TO_SHORT["credo-datahost-03.dafni.rl.ac.uk"]="cd03.credo" + +# ci01.credo configuration +HOST_USER["ci01.credo"]="shared" +HOST_TRAEFIK_PORT["ci01.credo"]="9050" +HOST_NGINX_PORT["ci01.credo"]="8050" +HOST_FQDN["ci01.credo"]="ci01.credo" + +# cd01.credo configuration +HOST_USER["cd01.credo"]="cadent" +HOST_TRAEFIK_PORT["cd01.credo"]="9051" +HOST_NGINX_PORT["cd01.credo"]="8051" +HOST_FQDN["cd01.credo"]="cd01.credo" + +# cd02.credo configuration +HOST_USER["cd02.credo"]="ngt" +HOST_TRAEFIK_PORT["cd02.credo"]="9051" +HOST_NGINX_PORT["cd02.credo"]="8051" +HOST_FQDN["cd02.credo"]="cd02.credo" + +# cd03.credo configuration +HOST_USER["cd03.credo"]="shared" +HOST_TRAEFIK_PORT["cd03.credo"]="9050" +HOST_NGINX_PORT["cd03.credo"]="8050" +HOST_FQDN["cd03.credo"]="cd03.credo" + +# Keycloak configuration (same for all hosts) +KEYCLOAK_REALM="CReDO" +KEYCLOAK_URL="https://idm-credo.hartree.app/realms/${KEYCLOAK_REALM}" +KEYCLOAK_CLIENT_ID="traefik" + +# Detect current host or use provided argument +if [ -n "$1" ]; then + CURRENT_HOST="$1" +else + # Get the full hostname and map to short name + FULL_HOSTNAME=$(hostname) + CURRENT_HOST="${HOSTNAME_TO_SHORT[$FULL_HOSTNAME]}" + + # If not found in mapping, try using hostname directly + if [ -z "$CURRENT_HOST" ]; then + CURRENT_HOST="$FULL_HOSTNAME" + fi +fi + +log_info "Deploying for host: $CURRENT_HOST" + +# Validate host +if [ -z "${HOST_USER[$CURRENT_HOST]}" ]; then + log_error "Unknown host: $CURRENT_HOST" + log_error "Valid hosts: ci01.credo, cd01.credo, cd02.credo, cd03.credo" + exit 1 +fi + +# Get configuration for current host +DEPLOY_USER="${HOST_USER[$CURRENT_HOST]}" +TRAEFIK_PORT="${HOST_TRAEFIK_PORT[$CURRENT_HOST]}" +NGINX_PORT="${HOST_NGINX_PORT[$CURRENT_HOST]}" +FQDN="${HOST_FQDN[$CURRENT_HOST]}" +STACK_NAME="${CURRENT_HOST//./-}-stack" + +log_info "Configuration:" +log_info " User: $DEPLOY_USER" +log_info " Traefik Port: $TRAEFIK_PORT" +log_info " Nginx Port: $NGINX_PORT" +log_info " FQDN: $FQDN" +log_info " Stack Name: $STACK_NAME" + +# Function to generate random string for secrets +generate_secret() { + dd if=/dev/urandom bs=32 count=1 2>/dev/null | base64 | tr -d -- '\n' | tr -- '+/' '-_' ; echo +} + +# Check if running as correct user +CURRENT_USER=$(whoami) +if [ "$CURRENT_USER" != "$DEPLOY_USER" ] && [ "$CURRENT_USER" != "root" ]; then + log_warn "Current user ($CURRENT_USER) is not the deployment user ($DEPLOY_USER)" + log_warn "Consider running as: sudo -u $DEPLOY_USER $0 $CURRENT_HOST" +fi + +# Read or generate secrets +log_info "Setting up secrets..." + +# Try to read existing client secret from .env if it exists +if [ -f "$BASE_DIR/.env" ]; then + log_info "Reading existing .env file for secrets..." + source "$BASE_DIR/.env" 2>/dev/null || true +fi + +# If CLIENT_SECRET is not set, prompt for it +if [ -z "$CLIENT_SECRET" ]; then + log_warn "CLIENT_SECRET not found in existing .env" + while [ -z "$CLIENT_SECRET" ]; do + read -rsp "Enter Keycloak client secret for '$KEYCLOAK_CLIENT_ID': " CLIENT_SECRET + echo + if [ -z "$CLIENT_SECRET" ]; then + log_error "CLIENT_SECRET is required. Please enter a valid secret." + fi + done +fi + +# Generate other secrets if not present +if [ -z "$ENCRYPTION_KEY" ]; then + ENCRYPTION_KEY=$(generate_secret 32) +fi + +if [ -z "$SIGNING_SECRET" ]; then + SIGNING_SECRET=$(generate_secret 32) +fi + +if [ -z "$COOKIE_SECRET" ]; then + COOKIE_SECRET=$(generate_secret 32) +fi + +# Create .env file +log_info "Writing .env file..." +cat > "$BASE_DIR/.env" << EOF +# Keycloak OAuth2 Configuration +CLIENT_ID=$KEYCLOAK_CLIENT_ID +CLIENT_SECRET=$CLIENT_SECRET +PROVIDER_URI=$KEYCLOAK_URL + +# Security Secrets +ENCRYPTION_KEY=$ENCRYPTION_KEY +SIGNING_SECRET=$SIGNING_SECRET +COOKIE_SECRET=$COOKIE_SECRET + +# Stack Configuration +STACK_NAME=$STACK_NAME +HOST_FQDN=$FQDN + +# Port Configuration +TRAEFIK_PORT=$TRAEFIK_PORT +NGINX_PORT=$NGINX_PORT +EOF + +log_info ".env file created" + +# Create oauth2-proxy.cfg from template +log_info "Generating oauth2-proxy.cfg..." +cat > "$BASE_DIR/oauth2-proxy/oauth2-proxy.cfg" << EOF +#traefik +reverse_proxy="true" # are we running behind a reverse proxy + +# oauth2-proxy +http_address="0.0.0.0:4180" #listen on all IPv4 interfaces + +upstreams=["static://202"] +email_domains="*" + +# Keycloak provider +provider="keycloak-oidc" +provider_display_name="CReDO Keycloak" +client_secret="$CLIENT_SECRET" +client_id="$KEYCLOAK_CLIENT_ID" +oidc_issuer_url="$KEYCLOAK_URL" +redirect_url="$FQDN" +scope="openid email profile groups" +code_challenge_method="S256" +insecure_oidc_allow_unverified_email="true" + +# Cookies +cookie_secret="$COOKIE_SECRET" +cookie_secure="false" +cookie_samesite="lax" +whitelist_domains=["$FQDN","localhost:$TRAEFIK_PORT","127.0.0.1:$TRAEFIK_PORT"] +skip_jwt_bearer_tokens="true" +extra_jwt_issuers=["$KEYCLOAK_URL=$KEYCLOAK_CLIENT_ID"] + +# Logging +request_logging="true" +auth_logging="true" +standard_logging="true" +skip_auth_strip_headers="false" + +# Headers +set_xauthrequest="true" +set_authorization_header="true" +EOF + +log_info "oauth2-proxy.cfg created" + +# Update compose.yml with correct Traefik port +log_info "Updating compose.yml with Traefik port..." +sed -i.bak "s/\"[0-9]*:80\"/\"$TRAEFIK_PORT:80\"/" "$BASE_DIR/compose.yml" +log_info "compose.yml updated (Traefik port: $TRAEFIK_PORT:80)" + +# Update dynamic.yml with correct Nginx port +log_info "Updating dynamic.yml with Nginx port..." +sed -i.bak "s|http://host.containers.internal:[0-9]*|http://host.containers.internal:$NGINX_PORT|" "$BASE_DIR/traefik/dynamic.yml" +log_info "dynamic.yml updated (Nginx port: $NGINX_PORT)" + +# Set XDG_RUNTIME_DIR for podman if not set and fix podman socket +if [ -z "$XDG_RUNTIME_DIR" ]; then + export XDG_RUNTIME_DIR="/run/user/$(id -u)" + # hack to fix bad podman state + podman system migrate + # use a socket without systemd + podman system service --time 5 + +fi + +log_info "XDG_RUNTIME_DIR: $XDG_RUNTIME_DIR" + +# Check if podman-compose is available +if ! command -v podman-compose &> /dev/null; then + log_error "podman-compose not found. Please install it first." + exit 1 +fi + +# Stop existing containers if running +log_info "Stopping existing containers..." +cd "$BASE_DIR" +podman-compose down 2>/dev/null || log_warn "No existing containers to stop" + +# Start the stack +log_info "Starting auth stack..." +podman-compose up -d + +# Check if containers started successfully +sleep 3 +if podman ps | grep -q "standalone-traefik"; then + log_info "${GREEN}✓${NC} Traefik container is running" +else + log_error "Traefik container failed to start" +fi + +if podman ps | grep -q "oauth2-proxy"; then + log_info "${GREEN}✓${NC} OAuth2-Proxy container is running" +else + log_error "OAuth2-Proxy container failed to start" +fi + +# Display access information +log_info "" +log_info "==========================================" +log_info "Deployment Complete!" +log_info "==========================================" +log_info "Traefik Dashboard: http://localhost:8080" +log_info "Traefik Entry: http://localhost:$TRAEFIK_PORT" +log_info "Protected Service: http://localhost:$TRAEFIK_PORT/whoami" +log_info "Public Service: http://localhost:$TRAEFIK_PORT/whoami-public" +log_info "" +log_info "External Access: https://$FQDN" +log_info "" +log_info "Stack Name: $STACK_NAME" +log_info "==========================================" +log_info "" +log_info "To view logs:" +log_info " podman-compose logs -f" +log_info "" +log_info "To stop the stack:" +log_info " podman-compose down" diff --git a/forwardauth/auth-app/oauth2-proxy/oauth2-proxy.template.cfg b/forwardauth/auth-app/oauth2-proxy/oauth2-proxy.template.cfg new file mode 100644 index 000000000..34754b1a4 --- /dev/null +++ b/forwardauth/auth-app/oauth2-proxy/oauth2-proxy.template.cfg @@ -0,0 +1,38 @@ +#traefik +reverse_proxy="true" # are we running behind a reverse proxy + +# oauth2-proxy +http_address="0.0.0.0:4180" #listen on all IPv4 interfaces + +upstreams=["static://202"] +email_domains="*" + +# Keycloak provider +provider="keycloak-oidc" +provider_display_name="Keycloak" +client_secret="xxxxxxxxxx" +client_id="xxxx" +oidc_issuer_url="xxxxxxxxxxxx" +redirect_url="xxxxxxxxxxxxxx" +scope="openid email profile groups" +code_challenge_method="S256" +allowed_roles="protected" + +# Cookies +cookie_secret="xxxxxxxxxx" +cookie_secure="false" +cookie_samesite="lax" +whitelist_domains=["xxxx"] +skip_jwt_bearer_tokens="true" +extra_jwt_issuers=["https://dev.theworldavatar.io/realms/twa-test=bingus"] + + +# Logging +request_logging="true" +auth_logging="true" +standard_logging="true" +skip_auth_strip_headers="false" + +# Headers +set_xauthrequest="true" +set_authorization_header="true" diff --git a/forwardauth/auth-app/traefik/dynamic.test.yml b/forwardauth/auth-app/traefik/dynamic.test.yml new file mode 100644 index 000000000..75ffef9b7 --- /dev/null +++ b/forwardauth/auth-app/traefik/dynamic.test.yml @@ -0,0 +1,64 @@ +# Dynamic Traefik configuration for middlewares +# $schema: https://www.schemastore.org/traefik-v3-file-provider.json + +http: + routers: + services-oauth2-route: + rule: "PathPrefix(`/oauth2/`)" + middlewares: + - auth-headers + service: oauth-backend + + whoami-public: + rule: "PathPrefix(`/public`)" + service: whoami-backend + + whoami-private: + rule: "PathPrefix(`/protected`)" + middlewares: + - oauth-auth-wo-redirect + service: whoami-backend + + whoami-web: + rule: "PathPrefix(`/login`)" + middlewares: + - oauth-auth-redirect + service: whoami-backend + + services: + oauth-backend: + loadBalancer: + servers: + - url: http://oauth2-proxy:4180 + whoami-backend: + loadBalancer: + servers: + - url: http://whoami:80 + + middlewares: + auth-headers: + headers: + sslRedirect: false + stsSeconds: 315360000 + browserXssFilter: true + contentTypeNosniff: true + forceSTSHeader: true + stsIncludeSubdomains: true + stsPreload: true + frameDeny: true + oauth-auth-redirect: + forwardAuth: + address: http://oauth2-proxy:4180/ + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-Access-Token + - Authorization + authRequestHeaders: + - Cookie + oauth-auth-wo-redirect: + forwardAuth: + address: http://oauth2-proxy:4180/oauth2/auth + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-Access-Token + - Authorization diff --git a/forwardauth/auth-app/traefik/dynamic.yml b/forwardauth/auth-app/traefik/dynamic.yml new file mode 100644 index 000000000..75ffef9b7 --- /dev/null +++ b/forwardauth/auth-app/traefik/dynamic.yml @@ -0,0 +1,64 @@ +# Dynamic Traefik configuration for middlewares +# $schema: https://www.schemastore.org/traefik-v3-file-provider.json + +http: + routers: + services-oauth2-route: + rule: "PathPrefix(`/oauth2/`)" + middlewares: + - auth-headers + service: oauth-backend + + whoami-public: + rule: "PathPrefix(`/public`)" + service: whoami-backend + + whoami-private: + rule: "PathPrefix(`/protected`)" + middlewares: + - oauth-auth-wo-redirect + service: whoami-backend + + whoami-web: + rule: "PathPrefix(`/login`)" + middlewares: + - oauth-auth-redirect + service: whoami-backend + + services: + oauth-backend: + loadBalancer: + servers: + - url: http://oauth2-proxy:4180 + whoami-backend: + loadBalancer: + servers: + - url: http://whoami:80 + + middlewares: + auth-headers: + headers: + sslRedirect: false + stsSeconds: 315360000 + browserXssFilter: true + contentTypeNosniff: true + forceSTSHeader: true + stsIncludeSubdomains: true + stsPreload: true + frameDeny: true + oauth-auth-redirect: + forwardAuth: + address: http://oauth2-proxy:4180/ + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-Access-Token + - Authorization + authRequestHeaders: + - Cookie + oauth-auth-wo-redirect: + forwardAuth: + address: http://oauth2-proxy:4180/oauth2/auth + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-Access-Token + - Authorization diff --git a/forwardauth/auth-app/traefik/traefik.yml b/forwardauth/auth-app/traefik/traefik.yml new file mode 100644 index 000000000..b12032816 --- /dev/null +++ b/forwardauth/auth-app/traefik/traefik.yml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://json.schemastore.org/traefik-v3.json +api: + dashboard: true + insecure: true + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + traefik: + address: ":8080" + +providers: + # podman or non swarm docker, switch to 'swarm' if needed + # docker: + # endpoint: "unix:///var/run/docker.sock" + # exposedByDefault: false + # network: "" + file: + filename: "/etc/traefik/dynamic.yml" + watch: true + +log: + level: DEBUG diff --git a/forwardauth/test/authenticated_curl_through_traefik.sh b/forwardauth/test/authenticated_curl_through_traefik.sh new file mode 100755 index 000000000..e0c0d1a8c --- /dev/null +++ b/forwardauth/test/authenticated_curl_through_traefik.sh @@ -0,0 +1,5 @@ +#!/usr/bin/bash + +TOKEN=$(./curl_for_token.sh) + +curl -v -w '\n' -H "Authorization: Bearer $TOKEN" http://localhost:2025/protected \ No newline at end of file diff --git a/forwardauth/test/checklist.md b/forwardauth/test/checklist.md new file mode 100644 index 000000000..bab8c51fa --- /dev/null +++ b/forwardauth/test/checklist.md @@ -0,0 +1,57 @@ +# Pre-Production Security Checklist + +## Authentication & Authorization +- [ ] Unauthenticated users are redirected to Keycloak +- [ ] Valid tokens grant access to protected resources +- [ ] Invalid/expired tokens are rejected +- [ ] Public endpoints remain accessible without authentication +- [ ] Session cookies have appropriate security flags (Secure, HttpOnly, SameSite) +- [ ] PKCE (S256) is enabled for OAuth2 flow + +## Configuration Validation +- [ ] `COOKIE_SECURE=true` in production (HTTPS only) +- [ ] `COOKIE_DOMAIN` matches production domain +- [ ] `REDIRECT_URL` points to correct production URL +- [ ] `OIDC_ISSUER_URL` points to production Keycloak +- [ ] Client secret is stored securely (not in version control) +- [ ] Cookie secret is cryptographically random (32+ bytes) + +## Network & Routing +- [ ] Traefik middleware is applied to all protected services +- [ ] OAuth2 endpoints (`/oauth2/*`) are publicly accessible +- [ ] ForwardAuth endpoint (`/oauth2/auth`) returns 401 for unauthenticated +- [ ] No sensitive endpoints are accidentally exposed +- [ ] Rate limiting is configured on authentication endpoints + +## Error Handling +- [ ] 401/403 errors properly redirect to sign-in +- [ ] OAuth callback errors are logged +- [ ] Users see helpful error messages (not stack traces) +- [ ] Failed auth attempts are logged for monitoring + +## Monitoring & Logging +- [ ] Authentication failures are logged +- [ ] OAuth2-proxy logs are captured and monitored +- [ ] Traefik access logs show authentication status +- [ ] Alerts configured for authentication service downtime + +## Performance +- [ ] ForwardAuth requests complete in <100ms +- [ ] Sessions are cached appropriately +- [ ] No authentication loops detected +- [ ] Load testing completed with expected user count + +## Rollback Plan +- [ ] Documentation for disabling authentication +- [ ] Backup of working configuration +- [ ] Process to revert to previous state +- [ ] Communication plan for users during issues + +## Production Monitoring +Set up monitoring dashboards: + +Authentication success/failure rates +OAuth2-proxy response times +Keycloak availability +Session cookie lifetimes +User error rates (401/403) \ No newline at end of file diff --git a/forwardauth/test/curl_for_token.sh b/forwardauth/test/curl_for_token.sh new file mode 100755 index 000000000..b0233ed3e --- /dev/null +++ b/forwardauth/test/curl_for_token.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +# shellcheck source=.env +source .env + +curl_token_endpoint() { + curl -s -X POST "${OIDC_TOKEN_URL}" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'grant_type=password' \ + -d "client_id=${OIDC_CLIENT_ID}" \ + -d "client_secret=${OIDC_CLIENT_SECRET}" \ + -d "username=${USERNAME}" \ + -d "password=${PASSWORD}" \ + -d 'scope=openid' +} + +curl_token_endpoint | jq -r '.access_token' \ No newline at end of file diff --git a/forwardauth/test/test.sh b/forwardauth/test/test.sh new file mode 100644 index 000000000..f0dc14db8 --- /dev/null +++ b/forwardauth/test/test.sh @@ -0,0 +1,62 @@ +#!/bin/bash + +set -e + +echo "🔐 Testing Authentication Stack" +echo "================================" + +# Test 1: Unauthenticated request should redirect +echo -n "1. Testing unauthenticated redirect... " +REDIRECT=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/whoami") +if [ "$REDIRECT" = "302" ]; then + echo "✅ PASS (Got 302 redirect)" +else + echo "❌ FAIL (Expected 302, got $REDIRECT)" + exit 1 +fi + +# Test 2: Public endpoint should work without auth +echo -n "2. Testing public endpoint access... " +PUBLIC=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/whoami-public") +if [ "$PUBLIC" = "200" ]; then + echo "✅ PASS (Got 200 OK)" +else + echo "❌ FAIL (Expected 200, got $PUBLIC)" + exit 1 +fi + +# Test 3: Get valid token and test authenticated access +echo -n "3. Testing authenticated access... " + +ACCESS_TOKEN=$(./curl_for_token_dev.sh | jq -r '.access_token') + +if [ "$ACCESS_TOKEN" = "null" ] || [ -z "$ACCESS_TOKEN" ]; then + echo "❌ FAIL (Could not get access token)" + echo "Response: $TOKEN_RESPONSE" + exit 1 +fi + +# Simulate OAuth2 flow by setting cookie +AUTHED=$(curl -s -o /dev/null -w "%{http_code}" \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + "$BASE_URL/whoami") + +if [ "$AUTHED" = "200" ] || [ "$AUTHED" = "302" ]; then + echo "✅ PASS (Got $AUTHED)" +else + echo "❌ FAIL (Expected 200 or 302, got $AUTHED)" + exit 1 +fi + +# Test 4: OAuth2 endpoints are accessible +echo -n "4. Testing OAuth2 endpoints... " +OAUTH_AUTH=$(curl -s -o /dev/null -w "%{http_code}" "$BASE_URL/oauth2/auth") +if [ "$OAUTH_AUTH" = "401" ] || [ "$OAUTH_AUTH" = "403" ]; then + echo "✅ PASS (Got $OAUTH_AUTH - expected for unauthenticated)" +else + echo "⚠️ WARNING (Got $OAUTH_AUTH, expected 401/403)" +fi + +echo "" +echo "================================" +echo "✅ All tests passed!" \ No newline at end of file diff --git a/stack-clients/.vscode/settings.json b/stack-clients/.vscode/settings.json new file mode 100644 index 000000000..c7d20e1f8 --- /dev/null +++ b/stack-clients/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[java]": { + "editor.formatOnSave": true + } +} diff --git a/stack-clients/docker-compose.yml b/stack-clients/docker-compose.yml index 1596f5b16..5801c070a 100644 --- a/stack-clients/docker-compose.yml +++ b/stack-clients/docker-compose.yml @@ -1,6 +1,6 @@ services: stack-client: - image: ghcr.io/theworldavatar/stack-client${IMAGE_SUFFIX}:1.56.2 + image: ghcr.io/theworldavatar/stack-client${IMAGE_SUFFIX}:1.57.0-traefik-support-SNAPSHOT secrets: - blazegraph_password - postgis_password diff --git a/stack-clients/pom.xml b/stack-clients/pom.xml index a76fd8d7c..b1d14acb7 100644 --- a/stack-clients/pom.xml +++ b/stack-clients/pom.xml @@ -7,7 +7,7 @@ com.cmclinnovations stack-clients - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT Stack Clients https://theworldavatar.io @@ -15,7 +15,7 @@ uk.ac.cam.cares.jps jps-parent-pom - 2.3.2 + 2.4.0 diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java index 8350f8761..2c3d3c951 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/StackClient.java @@ -36,6 +36,7 @@ public final class StackClient { private static StackHost stackHost = new StackHost(); private static boolean isolated = false; + private static String reverseProxyName; static { String envVarStackName = System.getenv(StackClient.STACK_NAME_KEY); @@ -107,6 +108,14 @@ public static Path getAbsDataPath() { return getStackBaseDir().resolve("inputs").resolve("data"); } + public static void setReverseProxyName(String reverseProxyName) { + StackClient.reverseProxyName = reverseProxyName; + } + + public static String getReverseProxyName() { + return reverseProxyName; + } + /** * Get a RemoteRDBStoreClient for the named Postgres RDB running in this stack. * diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java index a9ca70f65..cb6deb73f 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/core/datasets/RML.java @@ -1,7 +1,6 @@ package com.cmclinnovations.stack.clients.core.datasets; import java.nio.file.Path; -import java.util.Map; import com.cmclinnovations.stack.clients.rml.RmlMapperClient; diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java index 85391b994..a8b5cde43 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/DockerClient.java @@ -53,7 +53,9 @@ import com.github.dockerjava.core.DefaultDockerClientConfig.Builder; import com.github.dockerjava.core.DockerClientBuilder; import com.github.dockerjava.core.DockerClientConfig; -import com.github.dockerjava.core.command.ExecStartResultCallback; +import com.github.dockerjava.api.async.ResultCallback; +import com.github.dockerjava.api.model.Frame; +import com.github.dockerjava.api.model.StreamType; import com.github.dockerjava.httpclient5.ApacheDockerHttpClient; import com.github.dockerjava.transport.DockerHttpClient; @@ -115,7 +117,6 @@ public String executeSimpleCommand(String containerId, String... cmd) { .withOutputStream(outputStream) .withErrorStream(outputStream) .exec(); - String output = outputStream.toString(); return execId; } @@ -231,11 +232,21 @@ public String exec() { execStartCmd.withStdIn(inputStream); } - // ExecStartResultCallback is marked deprecated but seems to do exactly what we - // want and without knowing why it is deprecated any issues with it can't be - // overcome anyway. - try (ExecStartResultCallback result = execStartCmd - .exec(new ExecStartResultCallback(outputStream, errorStream))) { + try (ResultCallback.Adapter result = execStartCmd + .exec(new ResultCallback.Adapter() { + @Override + public void onNext(Frame frame) { + try { + if (frame.getStreamType() == StreamType.STDOUT && outputStream != null) { + outputStream.write(frame.getPayload()); + } else if (frame.getStreamType() == StreamType.STDERR && errorStream != null) { + errorStream.write(frame.getPayload()); + } + } catch (IOException ex) { + throw new RuntimeException("Failed to write frame payload", ex); + } + } + })) { if (wait) { if (!result.awaitCompletion(evaluationTimeout, TimeUnit.SECONDS)) { LOGGER.warn("Docker exec command '{}' still running after the {} second execution timeout.", @@ -553,7 +564,7 @@ public boolean isContainerUp(String containerName) { public String getContainerId(String containerName) { return getContainer(containerName).map(Container::getId) - .orElseThrow(() -> new NoSuchElementException("Cannot get container "+containerName+".")); + .orElseThrow(() -> new NoSuchElementException("Cannot get container " + containerName + ".")); } private Map> convertToConfigFilterMap(String configName, Map labelMap) { diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java index 3ee826842..c0a87e018 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/docker/PodmanClient.java @@ -7,15 +7,16 @@ import com.cmclinnovations.stack.clients.core.StackClient; import com.cmclinnovations.stack.clients.utils.JsonHelper; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.dockerjava.api.model.Config; +import com.github.dockerjava.jaxrs.ApiClientExtension; + import io.theworldavatar.swagger.podman.ApiClient; import io.theworldavatar.swagger.podman.ApiException; import io.theworldavatar.swagger.podman.api.NetworksApi; import io.theworldavatar.swagger.podman.api.SecretsApi; import io.theworldavatar.swagger.podman.model.Network; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.github.dockerjava.api.model.Config; -import com.github.dockerjava.jaxrs.ApiClientExtension; public class PodmanClient extends DockerClient { diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java index d75c05282..6dd945853 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/clients/rdf4j/Rdf4jClient.java @@ -4,7 +4,6 @@ import org.eclipse.rdf4j.federated.repository.FedXRepositoryConfigBuilder; import org.eclipse.rdf4j.repository.config.RepositoryConfig; -import org.eclipse.rdf4j.repository.config.RepositoryImplConfig; import org.eclipse.rdf4j.repository.manager.RemoteRepositoryManager; import org.eclipse.rdf4j.repository.sail.config.SailRepositoryConfig; import org.eclipse.rdf4j.repository.sparql.config.SPARQLRepositoryConfig; diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java index b79ceb223..669a1bc86 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/DockerService.java @@ -9,6 +9,7 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -338,8 +339,14 @@ void removeService(String serviceName) { protected ServiceSpec configureServiceSpec(ContainerService service) { ServiceSpec serviceSpec = service.getServiceSpec() - .withName(service.getContainerName()) - .withLabels(StackClient.getStackNameLabelMap()); + .withName(service.getContainerName()); + // Merge existing labels with stack name labels + Map serviceLabels = new HashMap<>(); + if (serviceSpec.getLabels() != null) { + serviceLabels.putAll(serviceSpec.getLabels()); + } + serviceLabels.putAll(StackClient.getStackNameLabelMap()); + serviceSpec.withLabels(serviceLabels); TaskSpec taskTemplate = service.getTaskTemplate(); if (null == taskTemplate.getRestartPolicy()) { taskTemplate.withRestartPolicy(new ServiceRestartPolicy() @@ -350,8 +357,14 @@ protected ServiceSpec configureServiceSpec(ContainerService service) { .withTarget(network.getId()) .withAliases(List.of(service.getName())))); ContainerSpec containerSpec = service.getContainerSpec() - .withLabels(StackClient.getStackNameLabelMap()) .withHostname(service.getName()); + // Merge existing container labels with stack name labels + Map containerLabels = new HashMap<>(); + if (containerSpec.getLabels() != null) { + containerLabels.putAll(containerSpec.getLabels()); + } + containerLabels.putAll(StackClient.getStackNameLabelMap()); + containerSpec.withLabels(containerLabels); interpolateEnvironmentVariables(containerSpec); diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java index f8024fc54..cbdd2e628 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/NginxService.java @@ -14,8 +14,6 @@ import com.cmclinnovations.stack.exceptions.InvalidTemplateException; import com.cmclinnovations.stack.services.config.Connection; import com.cmclinnovations.stack.services.config.ServiceConfig; -import com.github.dockerjava.api.model.EndpointSpec; -import com.github.dockerjava.api.model.PortConfig; import com.github.odiszapc.nginxparser.NgxBlock; import com.github.odiszapc.nginxparser.NgxComment; import com.github.odiszapc.nginxparser.NgxConfig; @@ -27,8 +25,6 @@ public final class NginxService extends ContainerService implements ReverseProxyService { - private static final String EXTERNAL_PORT = "EXTERNAL_PORT"; - public static final String TYPE = "nginx"; private static final String TEMPLATE_TYPE = "Nginx config"; @@ -63,22 +59,7 @@ protected void doPreStartUpConfiguration() { } } - private void updateExternalPort(ServiceConfig config) { - String externalPort = System.getenv(EXTERNAL_PORT); - if (null != externalPort) { - EndpointSpec endpointSpec = config.getDockerServiceSpec().getEndpointSpec(); - if (null != endpointSpec) { - List ports = endpointSpec.getPorts(); - if (null != ports) { - ports.stream() - .filter(port -> port.getTargetPort() == 80) - .forEach(port -> port.withPublishedPort(Integer.parseInt(externalPort))); - } - } - } - } - - public void addService(ContainerService service) { + public void addStackServiceToReverseProxy(ContainerService service) { NgxConfig locationConfigOut = new NgxConfig(); @@ -170,9 +151,7 @@ private String getProxyPassValue(Connection connection, String hostname) { } private String getServerURL(Connection connection, String hostname) { - URL url = connection.getUrl(); - int port = url.getPort(); - return hostname + ":" + ((-1 == port) ? 80 : port); + return hostname + ":" + getPortOrDefault(connection.getUrl()); } private final class ConfigSender { diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java index cc0175f2e..7bcc65030 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ReverseProxyService.java @@ -1,6 +1,49 @@ package com.cmclinnovations.stack.services; +import java.net.URL; +import java.util.List; + +import com.cmclinnovations.stack.services.config.ServiceConfig; +import com.github.dockerjava.api.model.EndpointSpec; +import com.github.dockerjava.api.model.PortConfig; + public interface ReverseProxyService extends Service { - public void addService(ContainerService service); + public void addStackServiceToReverseProxy(ContainerService service); + + /** + * Updates the external port mapping for the reverse proxy service. + * This allows multiple stacks to run on the same host by exposing each stack's + * reverse proxy on a different external port. + * + * @param config The service configuration containing the endpoint + * specifications + */ + default void updateExternalPort(ServiceConfig config) { + String externalPort = System.getenv("EXTERNAL_PORT"); + if (null != externalPort) { + EndpointSpec endpointSpec = config.getDockerServiceSpec().getEndpointSpec(); + if (null != endpointSpec) { + List ports = endpointSpec.getPorts(); + if (null != ports) { + ports.stream() + .filter(port -> port.getTargetPort() == 80) + .forEach(port -> port.withPublishedPort(Integer.parseInt(externalPort))); + } + } + } + } + + /** + * Gets the port from a URL, defaulting to 80 if not specified. + * This is a common pattern when working with HTTP services that don't + * explicitly specify a port. + * + * @param url The URL to extract the port from + * @return The port number, or 80 if the URL doesn't specify a port (-1) + */ + default int getPortOrDefault(URL url) { + int port = url.getPort(); + return (port == -1) ? 80 : port; + } } diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java index 14a92f7e1..7615600e5 100644 --- a/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/ServiceManager.java @@ -167,15 +167,17 @@ public S initialiseService(String stackName, String serviceN DockerService dockerService = getOrInitialiseService(stackName, StackClient.getContainerEngineName()); dockerService.doPreStartUpConfiguration(newContainerService); + if (!StackClient.getReverseProxyName().equals(serviceName)) { + ReverseProxyService reverseProxyService = getOrInitialiseService(stackName, + StackClient.getReverseProxyName()); + reverseProxyService.addStackServiceToReverseProxy(newContainerService); + } + dockerService.writeEndpointConfigs(newContainerService); if (dockerService.startContainer(newContainerService)) { dockerService.doFirstTimePostStartUpConfiguration(newContainerService); } dockerService.doEveryTimePostStartUpConfiguration(newContainerService); - if (!NginxService.TYPE.equals(serviceName)) { - ReverseProxyService reverseProxyService = getOrInitialiseService(stackName, NginxService.TYPE); - reverseProxyService.addService(newContainerService); - } } services.put(serviceName, newService); diff --git a/stack-clients/src/main/java/com/cmclinnovations/stack/services/TraefikService.java b/stack-clients/src/main/java/com/cmclinnovations/stack/services/TraefikService.java new file mode 100644 index 000000000..75ee6ca6e --- /dev/null +++ b/stack-clients/src/main/java/com/cmclinnovations/stack/services/TraefikService.java @@ -0,0 +1,182 @@ +package com.cmclinnovations.stack.services; + +import java.io.BufferedInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import com.cmclinnovations.stack.clients.core.StackClient; +import com.cmclinnovations.stack.clients.docker.DockerClient; +import com.cmclinnovations.stack.clients.utils.FileUtils; +import com.cmclinnovations.stack.services.config.ServiceConfig; +import com.github.dockerjava.api.model.ContainerSpec; +import com.github.dockerjava.api.model.ContainerSpecConfig; +import com.github.dockerjava.api.model.ContainerSpecFile; +import com.github.dockerjava.api.model.ServiceSpec; + +public class TraefikService extends ContainerService implements ReverseProxyService { + + public static final String TYPE = "traefik"; + + private static final String TRAEFIK_CONFIG_NAME = "traefik_config"; + private static final String TRAEFIK_CONFIG_PATH = "/etc/traefik/traefik.yml"; + private static final String TRAEFIK_CONFIG_TEMPLATE = "traefik/configs/traefik.yml"; + + private static final String TRAEFIK_DYNAMIC_CONFIG_NAME = "traefik_dynamic_config"; + private static final String TRAEFIK_DYNAMIC_CONFIG_PATH = "/etc/traefik/dynamic.yml"; + private static final String TRAEFIK_DYNAMIC_CONFIG_TEMPLATE = "traefik/configs/dynamic.yml"; + + // Forward authentication middleware name (defined by the forwardauth service) + private static final String AUTH_ENABLED = "AUTH_ENABLED"; + private static final String AUTH_MIDDLEWARE_NAME = "oauth-auth-redirect"; + + public TraefikService(String stackName, ServiceConfig config) { + super(stackName, config); + updateExternalPort(config); + } + + @Override + protected void doPreStartUpConfiguration() { + DockerClient dockerClient = DockerClient.getInstance(); + ContainerSpec containerSpec = getContainerSpec(); + List configs = containerSpec.getConfigs(); + if (null == configs) { + configs = new ArrayList<>(); + containerSpec.withConfigs(configs); + } + + String stackName = getEnvironmentVariable(StackClient.STACK_NAME_KEY); + + // Create and mount static Traefik configuration + configureTraefikStaticConfig(dockerClient, configs, stackName); + + // Create and mount dynamic Traefik configuration (for middlewares / custom + // rules and routers etc) + configureTraefikDynamicConfig(dockerClient, configs); + } + + private void configureTraefikDynamicConfig(DockerClient dockerClient, List configs) { + if (isAuthEnabled()) { + try (InputStream inStream = new BufferedInputStream( + TraefikService.class.getResourceAsStream(TRAEFIK_DYNAMIC_CONFIG_TEMPLATE))) { + + String dynamicConfigContent = new String(inStream.readAllBytes(), StandardCharsets.UTF_8); + + if (!dockerClient.configExists(TRAEFIK_DYNAMIC_CONFIG_NAME)) { + dockerClient.addConfig(TRAEFIK_DYNAMIC_CONFIG_NAME, + dynamicConfigContent.getBytes(StandardCharsets.UTF_8)); + } + + ContainerSpecConfig dynamicConfig = new ContainerSpecConfig() + .withConfigName(TRAEFIK_DYNAMIC_CONFIG_NAME) + .withFile(new ContainerSpecFile() + .withName(TRAEFIK_DYNAMIC_CONFIG_PATH) + .withUid("0") + .withGid("0") + .withMode(0444L)); + configs.add(dynamicConfig); + + } catch (IOException ex) { + throw new RuntimeException("Failed to configure Traefik dynamic config", ex); + } + } + } + + private void configureTraefikStaticConfig(DockerClient dockerClient, List configs, + String stackName) { + try (InputStream inStream = new BufferedInputStream( + TraefikService.class.getResourceAsStream(TRAEFIK_CONFIG_TEMPLATE))) { + + String configContent = new String(inStream.readAllBytes(), StandardCharsets.UTF_8); + configContent = configContent.replace("${STACK_NAME}", stackName); + + if (!dockerClient.configExists(TRAEFIK_CONFIG_NAME)) { + dockerClient.addConfig(TRAEFIK_CONFIG_NAME, configContent.getBytes(StandardCharsets.UTF_8)); + } + + ContainerSpecConfig traefikConfig = new ContainerSpecConfig() + .withConfigName(TRAEFIK_CONFIG_NAME) + .withFile(new ContainerSpecFile() + .withName(TRAEFIK_CONFIG_PATH) + .withUid("0") + .withGid("0") + .withMode(0444L)); + configs.add(traefikConfig); + + } catch (IOException ex) { + throw new RuntimeException("Failed to configure Traefik static config", ex); + } + } + + @Override + public void addStackServiceToReverseProxy(ContainerService service) { + // Traefik's Swarm provider reads service-level labels, not container labels + ServiceSpec serviceSpec = service.getServiceSpec(); + Map existingLabels = serviceSpec.getLabels(); + final Map labels = (existingLabels != null) ? existingLabels : new HashMap<>(); + + // Check if authentication is enabled globally + // The middleware is defined in Traefik's dynamic config (file provider) + boolean authEnabled = isAuthEnabled(); + String authMiddleware = authEnabled ? AUTH_MIDDLEWARE_NAME + "@file" : null; + + // Track if any endpoints with external paths were found + final boolean[] hasExternalEndpoints = { false }; + + service.getConfig().getEndpoints().forEach((endpointName, connection) -> { + URI externalPath = connection.getExternalPath(); + if (null != externalPath) { + hasExternalEndpoints[0] = true; + + String serviceName = service.getContainerName(); + String routerName = serviceName + "_" + endpointName; + String pathPrefix = FileUtils.fixSlashes(externalPath.getPath(), true, false); + + // Configure router with path prefix rule + labels.put("traefik.http.routers." + routerName + ".rule", + "PathPrefix(`" + pathPrefix + "`)"); + labels.put("traefik.http.routers." + routerName + ".entrypoints", "web"); + + // Add authentication middleware if enabled + if (authMiddleware != null) { + labels.put("traefik.http.routers." + routerName + ".middlewares", authMiddleware); + } + + // Configure service with the internal port + int port = getPortOrDefault(connection.getUrl()); + labels.put("traefik.http.routers." + routerName + ".service", routerName); + labels.put("traefik.http.services." + routerName + ".loadbalancer.server.port", + String.valueOf(port)); + } + }); + + // Only enable Traefik for services that have external endpoints + if (hasExternalEndpoints[0]) { + labels.put("traefik.enable", "true"); + + // Note: The traefik-forward-auth middleware is defined by the forwardauth + // service + // Services that need authentication simply reference this middleware in their + // router config + } + + // Set labels on the service spec after they've been populated + serviceSpec.withLabels(labels); + } + + /** + * Checks if authentication is enabled via environment variable. + * When enabled, services will use the forwardauth middleware + * that is defined in Traefik's dynamic configuration (file provider). + */ + private boolean isAuthEnabled() { + String enabled = System.getenv(AUTH_ENABLED); + return "true".equalsIgnoreCase(enabled); + } + +} diff --git a/stack-clients/src/main/java/com/github/dockerjava/jaxrs/ApiClientExtension.java b/stack-clients/src/main/java/com/github/dockerjava/jaxrs/ApiClientExtension.java index c569c2359..042bdbee1 100644 --- a/stack-clients/src/main/java/com/github/dockerjava/jaxrs/ApiClientExtension.java +++ b/stack-clients/src/main/java/com/github/dockerjava/jaxrs/ApiClientExtension.java @@ -15,9 +15,10 @@ import org.glassfish.jersey.apache.connector.ApacheConnectorProvider; import org.glassfish.jersey.client.ClientConfig; -import io.theworldavatar.swagger.podman.ApiClient; import com.github.dockerjava.jaxrs.filter.ResponseStatusExceptionFilter; +import io.theworldavatar.swagger.podman.ApiClient; + final public class ApiClientExtension extends ApiClient { private final PoolingHttpClientConnectionManager connManager; diff --git a/stack-clients/src/main/resources/com/cmclinnovations/stack/services/built-ins/traefik.json b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/built-ins/traefik.json new file mode 100644 index 000000000..31e9f55d9 --- /dev/null +++ b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/built-ins/traefik.json @@ -0,0 +1,40 @@ +{ + "type": "traefik", + "ServiceSpec": { + "Name": "traefik", + "TaskTemplate": { + "ContainerSpec": { + "Image": "traefik:v3.6", + "Mounts": [ + { + "Type": "volume", + "Source": "traefik_config", + "Target": "/etc/traefik" + } + ] + } + }, + "EndpointSpec": { + "Ports": [ + { + "Name": "web", + "Protocol": "tcp", + "TargetPort": "80", + "PublishedPort": "3838" + }, + { + "Name": "websecure", + "Protocol": "tcp", + "TargetPort": "443", + "PublishedPort": "443" + }, + { + "Name": "dashboard", + "Protocol": "tcp", + "TargetPort": "8080", + "PublishedPort": "8080" + } + ] + } + } +} \ No newline at end of file diff --git a/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/dynamic.yml b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/dynamic.yml new file mode 100644 index 000000000..83ab63b98 --- /dev/null +++ b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/dynamic.yml @@ -0,0 +1,44 @@ +# Dynamic Traefik configuration for middlewares +http: + routers: + services-oauth2-route: + rule: "PathPrefix(`/oauth2/`)" + middlewares: + - auth-headers + service: oauth-backend + + services: + oauth-backend: + loadBalancer: + servers: + - url: http://oauth2-proxy:4180 + + middlewares: + auth-headers: + headers: + sslRedirect: false + stsSeconds: 315360000 + browserXssFilter: true + contentTypeNosniff: true + forceSTSHeader: true + stsIncludeSubdomains: true + stsPreload: true + frameDeny: true + oauth-auth-redirect: + forwardAuth: + address: http://oauth2-proxy:4180/ + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-User + - X-Auth-Request-Email + - X-Auth-Request-Access-Token + - Authorization + authRequestHeaders: + - Authorization + oauth-auth-wo-redirect: + forwardAuth: + address: http://oauth2-proxy:4180/oauth2/auth + trustForwardHeader: true + authResponseHeaders: + - X-Auth-Request-Access-Token + - Authorization diff --git a/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/traefik.yml b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/traefik.yml new file mode 100644 index 000000000..63c3921a8 --- /dev/null +++ b/stack-clients/src/main/resources/com/cmclinnovations/stack/services/traefik/configs/traefik.yml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://json.schemastore.org/traefik-v3.json +api: + dashboard: true + insecure: true + +entryPoints: + web: + address: ":80" + websecure: + address: ":443" + traefik: + address: ":8080" + +providers: + # this one will read labels from containers (podman probably), or services in the case of swarm mode + docker: # swarm + endpoint: "unix:///var/run/docker.sock" + exposedByDefault: false + network: ${STACK_NAME} + # this one reads the file specified, hot refreshes the config when it changes. More responsive than the containers which needs updating of services / containers to trigger a refresh. + file: + filename: "/etc/traefik/dynamic.yml" + watch: true +log: + level: INFO diff --git a/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java b/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java index 73f629980..d40d97ee2 100644 --- a/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java +++ b/stack-clients/src/test/java/com/cmclinnovations/stack/StackHostTest.java @@ -45,11 +45,12 @@ void testNameJson() { () -> Assertions.assertEquals("host", stackHost.getStringBuilder().withName().build())); } - @Test - void testEmptyStrings() { + @Test + void testEmptyStrings() { StackHost stackHostDefault = new StackHost(); StackHost stackHostJson = Assertions - .assertDoesNotThrow(() -> objectMapper.readValue("{\"proto\":\"\", \"name\":\"\",\"port\":\" \",\"path\":\" \"}", StackHost.class)); + .assertDoesNotThrow(() -> objectMapper + .readValue("{\"proto\":\"\", \"name\":\"\",\"port\":\" \",\"path\":\" \"}", StackHost.class)); Assertions.assertAll( () -> Assertions.assertEquals(stackHostDefault.getProto(), stackHostJson.getProto()), () -> Assertions.assertEquals(stackHostDefault.getName(), stackHostJson.getName()), diff --git a/stack-data-uploader/.vscode/settings.json b/stack-data-uploader/.vscode/settings.json new file mode 100644 index 000000000..c7d20e1f8 --- /dev/null +++ b/stack-data-uploader/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[java]": { + "editor.formatOnSave": true + } +} diff --git a/stack-data-uploader/docker-compose.yml b/stack-data-uploader/docker-compose.yml index 2492d196a..c110fea32 100644 --- a/stack-data-uploader/docker-compose.yml +++ b/stack-data-uploader/docker-compose.yml @@ -1,6 +1,6 @@ services: stack-data-uploader: - image: ghcr.io/theworldavatar/stack-data-uploader${IMAGE_SUFFIX}:1.56.2 + image: ghcr.io/theworldavatar/stack-data-uploader${IMAGE_SUFFIX}:1.57.0-traefik-support-SNAPSHOT secrets: - blazegraph_password - postgis_password diff --git a/stack-data-uploader/pom.xml b/stack-data-uploader/pom.xml index 9a621abf5..4f6025efe 100644 --- a/stack-data-uploader/pom.xml +++ b/stack-data-uploader/pom.xml @@ -7,7 +7,7 @@ com.cmclinnovations stack-data-uploader - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT Stack Data Uploader https://theworldavatar.io @@ -38,7 +38,7 @@ com.cmclinnovations stack-clients - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT diff --git a/stack-manager/.vscode/settings.json b/stack-manager/.vscode/settings.json new file mode 100644 index 000000000..c7d20e1f8 --- /dev/null +++ b/stack-manager/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "editor.formatOnSave": true, + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit" + }, + "[java]": { + "editor.formatOnSave": true + } +} diff --git a/stack-manager/.vscode/tasks.json b/stack-manager/.vscode/tasks.json index af41b2f38..06455fe29 100644 --- a/stack-manager/.vscode/tasks.json +++ b/stack-manager/.vscode/tasks.json @@ -33,7 +33,10 @@ ], "options": { "shell": { - "executable": "bash" + "executable": "bash", + "args": [ + "-c" + ] } } }, diff --git a/stack-manager/docker-compose-stack.yml b/stack-manager/docker-compose-stack.yml index 83d31e25f..ce09e9c4e 100644 --- a/stack-manager/docker-compose-stack.yml +++ b/stack-manager/docker-compose-stack.yml @@ -9,6 +9,7 @@ services: - "STACK_NAME=${STACK_NAME}" - "EXECUTABLE=${EXECUTABLE}" - "API_SOCK=${API_SOCK}" + - "AUTH_ENABLED=${AUTH_ENABLED}" security_opt: - label=disable volumes: diff --git a/stack-manager/docker-compose.yml b/stack-manager/docker-compose.yml index 83e67e621..a0447a01a 100644 --- a/stack-manager/docker-compose.yml +++ b/stack-manager/docker-compose.yml @@ -1,9 +1,10 @@ services: stack-manager: - image: ghcr.io/theworldavatar/stack-manager${IMAGE_SUFFIX}:1.56.2 + image: ghcr.io/theworldavatar/stack-manager${IMAGE_SUFFIX}:1.57.0-traefik-support-SNAPSHOT environment: EXTERNAL_PORT: "${EXTERNAL_PORT-3838}" STACK_BASE_DIR: "${STACK_BASE_DIR}" + AUTH_ENABLED: "${AUTH_ENABLED-false}" volumes: - jdbc_drivers:/jdbc - ./inputs/data:/inputs/data diff --git a/stack-manager/pom.xml b/stack-manager/pom.xml index ab2fc7ba4..d8984e8a7 100644 --- a/stack-manager/pom.xml +++ b/stack-manager/pom.xml @@ -7,7 +7,7 @@ com.cmclinnovations stack-manager - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT Stack Manager https://theworldavatar.io @@ -38,7 +38,7 @@ com.cmclinnovations stack-clients - 1.56.2 + 1.57.0-traefik-support-SNAPSHOT diff --git a/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java b/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java index 692cdc6e4..14136c076 100644 --- a/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java +++ b/stack-manager/src/main/java/com/cmclinnovations/stack/Stack.java @@ -83,6 +83,7 @@ private Stack(String name, ServiceManager manager, StackConfig config) { if (null != config) { StackClient.setStackHost(config.getHost()); StackClient.setIsolated(config.isIsolated()); + StackClient.setReverseProxyName(config.getReverseProxyName()); } } diff --git a/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java b/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java index 95b2d62ad..5fbe81271 100644 --- a/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java +++ b/stack-manager/src/main/java/com/cmclinnovations/stack/StackConfig.java @@ -7,6 +7,7 @@ import java.util.Map; import com.cmclinnovations.stack.clients.core.StackHost; +import com.cmclinnovations.stack.services.NginxService; import com.fasterxml.jackson.annotation.JsonProperty; public class StackConfig { @@ -30,6 +31,9 @@ private enum Selector { @JsonProperty private final Boolean isolated = false; + @JsonProperty("reverseProxy") + private String reverseProxy; + @JsonProperty("hostName") private void setHostName(String hostName) { host = new StackHost(hostName); @@ -54,4 +58,8 @@ Map getVolumes() { public boolean isIsolated() { return isolated; } + + public String getReverseProxyName() { + return reverseProxy != null ? reverseProxy : NginxService.TYPE; + } } diff --git a/stack-manager/stack.sh b/stack-manager/stack.sh index e00c5eba8..45ee1ec43 100755 --- a/stack-manager/stack.sh +++ b/stack-manager/stack.sh @@ -3,6 +3,9 @@ # This fixes issues with WSL not mounting the Windows directories in a stable way. cd . +# Export Keycloak authentication configuration before running commands +export AUTH_ENABLED=true + COMMAND=$1 shift