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/forwardauth/test/token_from_cookie.sh b/forwardauth/test/token_from_cookie.sh new file mode 100755 index 000000000..0cb20cd80 --- /dev/null +++ b/forwardauth/test/token_from_cookie.sh @@ -0,0 +1,19 @@ +#!/usr/bin/bash + +curl 'https://app.credo.stfc.ac.uk/scenario' \ + -H 'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7' \ + -H 'Accept-Language: en-IE,en-GB;q=0.9,en;q=0.8' \ + -H 'Cache-Control: no-cache' \ + -H 'Connection: keep-alive' \ + -b '_ga=GA1.3.598299642.1762335315; _ga_K0P4YKJS14=GS2.3.s1768488059$o13$g0$t1768488059$j60$l0$h0; session=QgDJ7gjBfitxhWNHEA-JMw|1771847363|srY2XLFv7qOGbXuQYK0ibEadPeAkiBHHanF1dgIYxdR-4XJAIzRaR27xequZKwqJX5TyIZM2oJ1CwnjXigEYHLL10wHiVemvaRHbh2oGCEZVLaF8MzB12dCijvZXJSDNPeM1sE4N8Bo87P9IiGlh0JTR-o8-TJONyxeaBN1A4w9sb5oww6FIOgcA7YXiuptkUERKeJ_HIO8K5EakIX-BNKoGolu99U2t_StWHfTtJMU5Vb81mTeDBUxuD6eNHZqmmU2Z_iStum2COyCvCo5w2yjKE9OFQ224cQzVAsCa_u8DzXuTtMmx8hKlUdmgYoewI65vs9akfAcqgjJlexm578ylpM2jFgSQ8CXYdF-3kgAJwQ_IIhceSXNbt8Ckj_FfpJNijuJNWDyZu86rGld_WtD_hmahSh6FpO0TtMxz91oYHNxJC-EbQh6qHedmMh704KsFFX_-P82AFaWK2u_TkPx3uG2P-4Fy5RvvWMWOpuP_zSdUpf4JDlIbTuTJfRHuDxvHlMLOlHJNYSaU2iA1BP4TO_aDHd2M33QkAiiW5V4uNr1_h_bTVib90QYvW4VVnuZ23s1pTiCfovtQFzzsTNWysaI8rm_dV7Zi9H8OY9Cjx3pWGgiKsAhnuuYYlxI_ccTmagGUaXg8SM6j0sWiMe_Lh1cLrPXGN9G_L9Uh2N4qg-rO5Q4-rrsdZi_vGxMj_ktRUpIVvoUQ8zMgoaQ8fHD8sMQSxEfTFFlSCWVxoUXGPBG1ukCXW5x9B3xSLYfJeb-0QaZ-UW8Vdi9YdDb1htAFDZKhnHf87_iteSS1jwgL7FsSzqDYYP8u04OFHjc7SvE2GzmVz8GkztMXb_gNJKmwYLsaZ3BV6fU2_uOnV9hXzajxUWJ080805TreRAgXI123XKkXmHdYIcd01_522mjSMiF-T3OMBM1t3gDumzdybVPKG3-cN-p3MIf0BQSLbqQzKclneHwYEG1TyXtupxx_4A0ePE7mDhIGlTe5PGnY3W71VUeXNGJ5Idh80QoIbsIBK8uy-5QOQWEc47qOjnoubEYwSMAV0bVvs8ORjgGU6tpyk6Rv8R5vbwA1oerprANm78s_XOIEypqFQzNRoCtj5uX9gaqU28xROWIOUIZS__kPXNAAZpVeFwON66Aruamep4TcpdAnazpCEhOkjXIfI__el4lJqFnkzwvnsOrKirOClzA4h3CiXG6x7jv0A2_pKBuD3K5cQNE2BHo7o-TfYyxgIJ_FEAlFqgUDU3JpdSWTrtHDAGeDcb5tayxDgxLy0L4VtnJgbdkos2L5QLAE-_RY4c_p7vYoVbH1_JmmR2_9u9yZLFkvZFFc6hSsv7JaKPIxs_ImfZAtloIXEhmjUaP1Z2U_sXWObBpcNATq7eoX3xkpC4vrHaqWbj9AOWlqfgxExcPJ_mMB7PNK3vOle1AsP3RkL7V6SKjFpTP1rpcWymbzYoTse7l2_xcFWxK-ODONscvNTjdN3VDwU6x8w_kTEYKlOxNZtI9dyrybCcvdojzIu7ILvj291FLikRDyJznmqdVNlWwJfTaCfPxoNfzhMMvsosuyj171cnqQSJYfB4875u6GCdWfQNSZ9xlOL7mrD2G5KsKgGoBTiANZG76Be0yQRWwH3Ign3Aa_dbx3gJngeggkqiWEn_aK0Nln_JdaSFQ6bR3Zv-0e_YmJHa8kiVGgvA95tEIrkOKEmsG0lpYoiS5wzSGgot5jEK6KKUkc0q8-TJlz65F9qgm8eWorY2qkF72fC2aTmq_McP6CI0TVksbjR_JepNB3ksvmP5hHNNfGpMaulWO1_4X9KEaeTG3wUnU0nKPEDtrkp0PQCI_f_jy7nyBma73IZvn6Xv6uOKzyvXSF9M0_vilyWT-LT3CTolV7w6Gt_KR21sVCkZFgsLi4S8Oi388dfWrhAGEDS9Q6PVAkBXNc--T_3CnHjlLcprgAkQ9-UZlO1efEPwtAzEjZMb7nwatRQHV5X32jLXjUPi4pw6zF5LepQuBMmjSs_CuqamE3HUR1SwAldoDcHTe6e8qYX2XQXuZXtCXPDPi3Xn3AGJ3oAt-0NdqNrwVu2SsNTy5V7AA7Umi7j0dcZi1QuqVZXHh9_YmWnrtl-QXgzBHOaTxiVBmFlHeS9L5wWFLNGdKOVmnAafl2vGfFEh-3QnF8DjLMzKA5EggwxPvy1YIMPQeedfRwBlPxJT9hgVvsLg-jhFgxTdyz_8pG6XybRZClpXm8_gf6KY6x8xi34mhIe0W6d03HuRnw3w_ous9T8GSLeJxctWpxvYpd58O_owjvzDoCYt1yoR15Ybmyo4SBqJ0Papb5uXllIK-tC9-HUe2xzwUx7lLeGkqxsD0FBYRio_YQowMQGYoIjJCYviuy5ZD5P_JjTg83I6SUhlRydMBVKAp4Qhf_bPjWBri3HGRAwNszo0k2MeL4sis96ZcykMhyVmZOaLXY0kD4gAumf93jIx3TOgF0TRRS33EbvJyxyJR4l8ZZC65jQj7xOsZwXdEE7qDbaABVEsbm2a_w0gp7pjMbNRbcacisgkRCsBsdfzfpOPrhdDUTXzIs1Tbf_I_fxmEIuuAMPKNA2QO9CuioQOoUN94ziVVT1Z1DPFHzYhsUPpTDf1l_Xva4CWC63cqqrJIHYjjBPfna-ZZhWzRkLF97KXGFMXWBtcti5X0iPXBYxxgOAkGaLc5IC5NPD5n0lSlcLS8CJaTMg5M13oWaBI7k4nZuPkQ7J-h5hJ-HRETT1RPgkgsq_zU4-vTncsMQRbv9uitT9Ej8c-a1Sj8KTjBQ0es4D03zcj-eirZQtPxA8pdVFKro0IzW3ZDU9_9WiMzfpQThpQeocekoB8MfDFgfzqOr2mcbCb8aM-Qt3b9C1TQBlvu-HooFrEveP0ZDsGj43o3jDxOMLeZMqiDvUu4yXp1Ls7o-vfnxRA_jVgy6K8c6URLPUOS7TOTA33QFYFsFZ2s3NzdKDvugO4PSgIoc7WTmINzwt8G_-MEKfPROUSWJ-OnLOC14CnUZhaQxmgAc2HPJbTBO-YMNZUsBkO1I-4pDED8PWUVwMwCpYPy3fwj61GI0sPu4SCCZxzc_pvoyacIN5LrCz0PbeY2gq7p3BKY-3I33PigpSLaWAI3-uCsizFsTDOAFIHWDdOWihYGLSVaanMULqX-xnkJIc3zB5NUBo55piKOK-ODPlhXXegb9q_l7G34vUJbdpzLi20hLLPnQNiXjXMT6Z_zCgWHzjKRVWXEP86L6IzGUU2w-GflTY3ymOQXbl77sCXjP-3RpIZx1FBJmnBnL_Y7L4L-kmQW94z9k7tEzKelpSlgodzr9vUaYtjPTc9RqR8QY_5XHWE6X49BMuA2VHU54HhZtoEPWeMd3pGYg4f7qMVOGAf63x8AIFLOCX5WMu6aTPfF3LiDWja2i2wfwEJ0dV5e6YfrTtLSx5TNm5x6OlBMclide0IDqEZZUFppWi31tequ-BlW6NNIuZfMr5EBWwZdK7uvlfb8-BXicQbazSrMjNeSTgyJ4rPNTFJd2Xo0WVWFhFMHSPYAj-83I4P5JrduiaWGOnK_qy-9AOuNsUdQkJjBTDGP7qRSYHCwf3ywOKUQ5ParUDGRehinTuFdBJOmcRJFCcNmiU90R9WaZ5b5IFEcU7Cj8gxc-nIiq6t92380Xf-GECeJIUeblaYkn4sGy2UgJZhZ56iXJEY_BLt7SJVa4g4niZ4d9yDJbTwVUWpEg_pC8tsjPaBGfC6UHR2gmu9tb6O0zi1XcUUWIUwFykV3b9WG-8e_iJch05Stww5jf5ghqQeiJP3NJYARdx-lVKBrGpAIIFypxmHErzS0KcxgLB40JMKieRs2Wncl8N62GFXaeceblTYrGFml1SHCJWuI_Ke79ZukspMKrFiFVltapuEAU3cJcBggqqRE9XWLa2ZtgnGYVmNEltozRK-nNplkHPv7jty_YZp1NDA0; session_2=uDrh7ecMH7UzYfJtMRRkXizFBpiBfufJiy3INr4psbjxdcIM4ec0PVB9wl1LP2OJzD4RcezH_x4vPPdebP8Mgqr_W9Ak9CeWPov26kOgdRO01eQTPicCJYaLXDVqkYuqsPOCNp7KuDRbey7SvivkYQeimvvSLu2T_ynCbXiZEZLYmqR3uWVetST-v-khA9cHU5Jf48lKGd5Zgc0veGguehqrCjhVwc8OVqzS81V0_bHzAEuo7sNvJqTwe91xVGvu6hAY6X_HB66aKzxomwn0q0STIQQODJGZmHZuisisA7CdjZ5tMxEHRsUzUjtFuBax8dHLV7ZOZCt5v-rQAokHH0hVj_ALsYNmFVxv5Lh3l2urg98dP4wfgW_9xy8X8XXGK0MG-q9pwHenk72TBSCpPifAz74kyGNkKf-8vUpNMM56UvhMh_VIQXZjchTT3j2GAKc_xwLtnfykWm_RoJGaOphXET1m5a3HubEMdBkqLkfWmwTowiOacxxTdKD8Mn7mWeKcod3248SVSN8VehjZ2prssOFtPuk56O-f-gP3JemFldYbdQjXn5T5DSjNABLT__gQ0UjMuSHnbnOyrPKB3MvJKjYZVb_9-5flm_OGDbb4Y_uduMD6jTAEAZpIt81oikSPi_VhUzO7WtWvATFumQ1Xzo4kB-pWSX8eNkJ2W-YOg9z15DI01TGkBz26quiKlEy_J8WXeVElP9Z8GIcru7sZwi9MuHIu88-8ln_eO4qzxhKTqyQfiHw2G-lgd2tRuGgGa5QYk-eBBWpUWYOYVbYJjO-Zxbq84xxssNHOZGf1ENmBFNNwt1J8uwk_3m7m-kOJBdXXee-J9sw_CW6aozsRxNOU6BeZmA4uG9IkZ95v0ADWfnotVxlbK2KEzcUQhAlzrXsOitDEADFwmI_7btPwy6h5j2ohss0r5zlFThMX_hsmwSUf83Gy8_7QnraLcuhlD1TrAJCTRYnXDRW1ad5lqFy2KEEJl_h4JxMiU5qF0Ssh_TI8Omi5ynozZjUrsBv1ldAUOJ0R8ZdS680HFKJN9U5ggiIWr7pi-j1ArjH2kixJaOZez_qKXCM4PuUS1h3KaIVLngYSViP-lwVpq-26la24uReyaU7ODXMmytlNevUrJgNc9845akFxR_FJhkTIrpqI1E_bvsxTF0zNhkPQH6aRx7lr6PzncS7xfcnOHSjZy7soX_BpxB0XZMObhOh0g0m8NF2LobLu7RpCzstfYftBY50LarYd5C8zV-wVNomLB66pjkuv7wa1orB4UQATcTs-ozrCaJQUOrX3xLN89LAI5cGb0qcQ6Vb4aeVWfV-vQ_0_M1BUDa0ySjBLNoAI25-flSqCEW_TBz8g1koAwIFh3IetjNX9-R24rJd-kRtCp_txHe3MWnU04NPqkFsMVQdIMTD5KC4MqrBhNN3-5koezRy0zOsKMJLO-WVvdkr3GuP-wFkMQbpuyc9GW0Oe-nxf8MuiTghH1CfDc4EbJuvwu7F7ejP5YentaJVFR2yuuA7MyrCnQGjgbbC2r3FZIKecbNoxIArT0IoyqfBpYWuCl3DSvGj9qPUcukPItsxSRQIhNAmAQqsUX67oEh7iklg1J63NbtsUtDfP38sSqoXObofBLkp-702aSYSyMqR9yZLQxmpn1leHhQWNqvhoH0CYVpchkEz00RCratYcTZOMB1n2iYDGrjNpOnC9x2hMiRZecceD9_HNQi918ASL_56jmyQ1aVJEQ0_out7zQ9hPjX9PrRiKI_sjyavbKt_JcM05xLwKUQ0IgF8wIFrDtru1PMzeuKFMzo1bjXcdEpHGVu4DZkvxPfP-Kra7hV12ieTdtkSEErArwl1UxZLxKFZGPdcOgCxnuuQXzODIDvj2GQEEE2E27tQRXy3rTkNco88ElUkyVPtTRJBfGwoZnukiNuSjYKN56hjtj3NHIiziJkPK4Iw4vmr865EjQ55AyGxoygzw-3dtVaFWmabM_X4jYNLNEyoX6ySCGJ8r9vIVAu4CIpcqNPnpnu-R_IKQCG2brgUXbxMbXJNgZx|LEeJKlUCWQZh08SMLDYTlfLnD_I' \ + -H 'DNT: 1' \ + -H 'Pragma: no-cache' \ + -H 'Sec-Fetch-Dest: document' \ + -H 'Sec-Fetch-Mode: navigate' \ + -H 'Sec-Fetch-Site: cross-site' \ + -H 'Sec-Fetch-User: ?1' \ + -H 'Upgrade-Insecure-Requests: 1' \ + -H 'User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36 Edg/145.0.0.0' \ + -H 'sec-ch-ua: "Not:A-Brand";v="99", "Microsoft Edge";v="145", "Chromium";v="145"' \ + -H 'sec-ch-ua-mobile: ?0' \ + -H 'sec-ch-ua-platform: "Windows"' \ 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