forked from Nixius/authelia
1
0
Fork 0

Add ss-atlas service, config updates, ignore IDE cruft

Made-with: Cursor
This commit is contained in:
Leopere 2026-03-03 11:21:03 -05:00
parent 9bbec9a8d2
commit ac24f6d1dc
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
42 changed files with 2039 additions and 109 deletions

4
.gitignore vendored
View File

@ -1,4 +1,5 @@
# Secrets and sensitive files # Secrets and sensitive files
.env
secrets.md secrets.md
*.secret *.secret
*.key *.key
@ -26,6 +27,7 @@ logs/
# Temporary files # Temporary files
*.tmp *.tmp
*.temp *.temp
.!*
# OAuth and other secrets - never commit! # OAuth and other secrets - never commit!
secrets/ secrets/

View File

@ -10,6 +10,15 @@ clone:
recursive: true recursive: true
steps: steps:
# ss-atlas unit tests (runs on every push)
ss-atlas-test:
name: ss-atlas-test
image: golang:1.23-alpine
commands:
- cd docker/ss-atlas && go test ./...
when:
event: push
# Build and Push for Staging # Build and Push for Staging
build-push-staging: build-push-staging:
name: build-push-staging name: build-push-staging

View File

@ -164,7 +164,7 @@ Use OAuth for better user experience and native service integration:
# Portainer with OAuth - no Traefik middleware needed # Portainer with OAuth - no Traefik middleware needed
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.http.routers.portainer.rule: "Host(`portainer.nixc.us`)" traefik.http.routers.portainer.rule: "Host(`portainer.a250.ca`)"
# OAuth configured in Portainer admin panel # OAuth configured in Portainer admin panel
``` ```
@ -173,7 +173,7 @@ Use Authelia middleware for services without OAuth support:
```yaml ```yaml
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.http.routers.myapp.rule: "Host(`myapp.nixc.us`)" traefik.http.routers.myapp.rule: "Host(`myapp.a250.ca`)"
traefik.http.routers.myapp.middlewares: "authelia_authelia@docker" traefik.http.routers.myapp.middlewares: "authelia_authelia@docker"
traefik.http.services.myapp.loadbalancer.server.port: "8080" traefik.http.services.myapp.loadbalancer.server.port: "8080"
``` ```
@ -182,7 +182,7 @@ labels:
```yaml ```yaml
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.http.routers.headscale.rule: "Host(`headscale.nixc.us`)" traefik.http.routers.headscale.rule: "Host(`headscale.a250.ca`)"
traefik.http.routers.headscale.entrypoints: "websecure" traefik.http.routers.headscale.entrypoints: "websecure"
traefik.http.routers.headscale.tls.certresolver: "letsencryptresolver" traefik.http.routers.headscale.tls.certresolver: "letsencryptresolver"
traefik.http.services.headscale.loadbalancer.server.port: "8080" traefik.http.services.headscale.loadbalancer.server.port: "8080"

View File

@ -17,16 +17,16 @@ authentication_backend:
path: /config/users_database.yml path: /config/users_database.yml
access_control: access_control:
default_policy: one_factor default_policy: bypass
rules: rules:
- domain: ["dev.local.com"] - domain: ["bc.a250.ca"]
policy: one_factor policy: bypass
session: session:
cookies: cookies:
- name: authelia_session - name: authelia_session
domain: dev.local.com domain: bc.a250.ca
authelia_url: http://dev.local.com:9091 authelia_url: http://bc.a250.ca:9091
secret: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE= secret: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
expiration: 1h expiration: 1h
inactivity: 5m inactivity: 5m

View File

@ -13,7 +13,7 @@ services:
networks: networks:
- authelia_dev - authelia_dev
healthcheck: healthcheck:
test: ["CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized"] test: [ "CMD", "/usr/local/bin/healthcheck.sh", "--su-mysql", "--connect", "--innodb_initialized" ]
start_period: 30s start_period: 30s
interval: 30s interval: 30s
timeout: 10s timeout: 10s
@ -29,7 +29,7 @@ services:
networks: networks:
- authelia_dev - authelia_dev
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: [ "CMD", "redis-cli", "ping" ]
start_period: 10s start_period: 10s
interval: 30s interval: 30s
timeout: 5s timeout: 5s
@ -43,16 +43,21 @@ services:
environment: environment:
- LLDAP_JWT_SECRET=I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I= - LLDAP_JWT_SECRET=I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I=
- LLDAP_LDAP_USER_PASS=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0= - LLDAP_LDAP_USER_PASS=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_LDAP_BASE_DN=dc=nixc,dc=us - LLDAP_LDAP_BASE_DN=dc=a250,dc=ca
- PUID=33 - PUID=33
- PGID=33 - PGID=33
ports: ports:
# Only expose web UI for manual testing # Only expose web UI for manual testing
- "17170:17170" # Web interface port - "17170:17170" # Web interface port
networks: networks:
- authelia_dev - authelia_dev
labels:
- "traefik.enable=true"
- "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)"
- "traefik.http.routers.lldap.entrypoints=web"
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:17170/health"] test: [ "CMD", "curl", "-f", "http://localhost:17170/health" ]
start_period: 10s start_period: 10s
interval: 30s interval: 30s
timeout: 5s timeout: 5s
@ -62,8 +67,9 @@ services:
build: build:
context: ./docker/authelia/ context: ./docker/authelia/
dockerfile: Dockerfile dockerfile: Dockerfile
image: git.nixc.us/nixius/authelia:dev-authelia image: git.nixc.us/a250/authelia:dev-authelia
container_name: authelia_dev_main container_name: authelia_dev_main
user: root
command: command:
- sh - sh
- -c - -c
@ -80,15 +86,28 @@ services:
echo "$${IDENTITY_PROVIDERS_OIDC_JWKS_KEY}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_JWKS_KEY echo "$${IDENTITY_PROVIDERS_OIDC_JWKS_KEY}" > /run/secrets/IDENTITY_PROVIDERS_OIDC_JWKS_KEY
echo "$${CLIENT_SECRET_HEADSCALE}" > /run/secrets/CLIENT_SECRET_HEADSCALE echo "$${CLIENT_SECRET_HEADSCALE}" > /run/secrets/CLIENT_SECRET_HEADSCALE
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
# Start Authelia with original command echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
# Override configuration for local dev
printf "notifier:\n filesystem:\n filename: /data/notification.txt\n" > /config/configuration.notifier.yml
printf "access_control:\n default_policy: bypass\n rules:\n - domain: [\"*.bc.a250.ca\", \"bc.a250.ca\"]\n policy: bypass\n" > /config/configuration.acl.yml
# Start Authelia with dev overrides
exec authelia \
--config=/config/configuration.server.yml \
--config=/config/configuration.ldap.yml \
--config=/config/configuration.acl.yml \
--config=/config/configuration.notifier.yml \
--config=/config/configuration.identity.providers.yml \
--config=/config/configuration.oidc.clients.yml
environment: environment:
# Template environment variables # Template environment variables
X_AUTHELIA_EMAIL: authelia@nixc.us X_AUTHELIA_EMAIL: authelia@a250.ca
X_AUTHELIA_SITE_NAME: ATLAS-DEV X_AUTHELIA_SITE_NAME: a250.ca
X_AUTHELIA_CONFIG_FILTERS: template X_AUTHELIA_CONFIG_FILTERS: template
X_AUTHELIA_LDAP_DOMAIN: dc=nixc,dc=us X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: dev.local.com TRAEFIK_DOMAIN: bc.a250.ca
# Development secrets for templates # Development secrets for templates
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE= IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
STORAGE_ENCRYPTION_KEY: DvbtMjsNDIC3eqtNaPtdHm/f07dtlHREgieDStTu9NA= STORAGE_ENCRYPTION_KEY: DvbtMjsNDIC3eqtNaPtdHm/f07dtlHREgieDStTu9NA=
@ -121,19 +140,29 @@ services:
c/W/dqF5xfmVQR0Af/ijs6+Jfjr0NBrT+sHHk+ef8Ktaw8IHslNa6r5TJg82mO2e c/W/dqF5xfmVQR0Af/ijs6+Jfjr0NBrT+sHHk+ef8Ktaw8IHslNa6r5TJg82mO2e
g7pksppAWxMfKCqUhrDXGgwyFIXpfBT2jkzV530l4+2L5HJK2RO74mNWWHtGcSQF g7pksppAWxMfKCqUhrDXGgwyFIXpfBT2jkzV530l4+2L5HJK2RO74mNWWHtGcSQF
d3VW3WQfqeaj0YK+Oqqf/nHIokG0a2E/4BBjshECgYAnlU2Fl7uI1lQBbWsckaQ9 d3VW3WQfqeaj0YK+Oqqf/nHIokG0a2E/4BBjshECgYAnlU2Fl7uI1lQBbWsckaQ9
EVeSDtrRvNuER0Eh3WFni9affOqB9qAZXNfCZ+goFJoNgk4fww0OqmewX9Y18/3a EVeSDtrRvNuER0Eh3WFni2affOqB9qAZXNfCZ/goFJoNgk4fww0OqmewX9Y18/3a
vsrm7L7OKFFlM6vmIG1nPX/s5l++mkMe+qRd4B7C4NSF0bzJlweTozQFDp+prp1y vsrm7L7OKFFlM6vmIG1nPX/s5l++mkMe+qRd4B7C4NSF0bzJlweTozQFDp+prp1y
SHERk3EUdAZn7yyIISd/Qg== SHERk3EUdAZn7yyIISd/Qg==
-----END PRIVATE KEY----- -----END PRIVATE KEY-----
IDENTITY_PROVIDERS_OIDC_JWKS_KEY: mbfKKlpQ5QEzrmBCCcOg7yubDBKZtKCAiL7rGtVdMq/hpCorO+Qiei2fKbB/xieDS3BIg5BMza5fZm5w0hMiNA== IDENTITY_PROVIDERS_OIDC_JWKS_KEY: mbfKKlpQ5QEzrmBCCcOg7yubDBKZtKCAiL7rGtVdMq/hpCorO+Qiei2fKbB/xieDS3BIg5BMza5fZm5w0hMiNA==
CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA= CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8= CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8=
CLIENT_SECRET_PORTAINER: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
volumes: volumes:
- authelia_data:/data - authelia_data:/data
ports: ports:
- "9091:9091" - "9091:9091"
networks: networks:
- authelia_dev - authelia_dev
labels:
- "traefik.enable=true"
- "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)"
- "traefik.http.routers.authelia.entrypoints=web"
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia_dev_main:9091/api/verify?rd=http://login.bc.a250.ca/"
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
@ -142,12 +171,73 @@ services:
lldap: lldap:
condition: service_healthy condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9091/api/health"] test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ]
start_period: 15s start_period: 15s
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
traefik:
image: traefik:v3.1
container_name: authelia_traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
- "8080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
networks:
- authelia_dev
ss-atlas:
build:
context: ./docker/ss-atlas/
dockerfile: Dockerfile
container_name: atlas_ss_app
environment:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder}
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-price_placeholder}
- LLDAP_URL=ldap://lldap_lldap:3890
- LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca
- LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_BASE_DN=dc=a250,dc=ca
- DOCKER_HOST=unix:///var/run/docker.sock
- APP_URL=http://app.bc.a250.ca
- AUTHELIA_URL=http://login.bc.a250.ca
- TRAEFIK_DOMAIN=bc.a250.ca
- TRAEFIK_NETWORK=authelia_dev
- TEMPLATE_PATH=/app/templates
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- authelia_dev
labels:
- "traefik.enable=true"
- "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)"
- "traefik.http.routers.ss-atlas.entrypoints=web"
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
depends_on:
lldap:
condition: service_healthy
authelia:
condition: service_healthy
whoami:
image: traefik/whoami
container_name: authelia_whoami
networks:
- authelia_dev
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)"
- "traefik.http.routers.whoami.entrypoints=web"
- "traefik.http.routers.whoami.middlewares=authelia-auth@docker"
networks: networks:
authelia_dev: authelia_dev:
driver: bridge driver: bridge
@ -160,4 +250,4 @@ volumes:
authelia_data: authelia_data:
driver: local driver: local
lldap_data: lldap_data:
driver: local driver: local

View File

@ -5,14 +5,14 @@ services:
build: build:
context: ./docker/mariadb/ context: ./docker/mariadb/
dockerfile: Dockerfile.production dockerfile: Dockerfile.production
image: git.nixc.us/nixius/authelia:production-mariadb image: git.nixc.us/a250/authelia:production-mariadb
redis: redis:
build: build:
context: ./docker/redis/ context: ./docker/redis/
dockerfile: Dockerfile.production dockerfile: Dockerfile.production
image: git.nixc.us/nixius/authelia:production-redis image: git.nixc.us/a250/authelia:production-redis
authelia: authelia:
build: build:
context: ./docker/authelia/ context: ./docker/authelia/
dockerfile: Dockerfile.production dockerfile: Dockerfile.production
image: git.nixc.us/nixius/authelia:production-authelia image: git.nixc.us/a250/authelia:production-authelia

View File

@ -5,14 +5,14 @@ services:
build: build:
context: ./docker/mariadb/ context: ./docker/mariadb/
dockerfile: Dockerfile dockerfile: Dockerfile
image: git.nixc.us/nixius/authelia:staging-mariadb image: git.nixc.us/a250/authelia:staging-mariadb
redis: redis:
build: build:
context: ./docker/redis/ context: ./docker/redis/
dockerfile: Dockerfile dockerfile: Dockerfile
image: git.nixc.us/nixius/authelia:staging-redis image: git.nixc.us/a250/authelia:staging-redis
authelia: authelia:
build: build:
context: ./docker/authelia/ context: ./docker/authelia/
dockerfile: Dockerfile dockerfile: Dockerfile
image: git.nixc.us/nixius/authelia:staging-authelia image: git.nixc.us/a250/authelia:staging-authelia

View File

@ -1 +1 @@
FROM git.nixc.us/nixius/authelia:staging-authelia FROM git.nixc.us/a250/authelia:staging-authelia

View File

@ -11,6 +11,9 @@ access_control:
# - 10.0.0.0/8 # - 10.0.0.0/8
# # Put WAN Access rules here # # Put WAN Access rules here
- domain: "*.{{ env "TRAEFIK_DOMAIN" }}"
policy: bypass
# - domain: {{ env "TRAEFIK_DOMAIN" }} # - domain: {{ env "TRAEFIK_DOMAIN" }}
# resources: # resources:
# - "^/.well-known([/?].*)?$" # - "^/.well-known([/?].*)?$"
@ -23,49 +26,77 @@ access_control:
# - domain: headscale.{{ env "TRAEFIK_DOMAIN" }} # - domain: headscale.{{ env "TRAEFIK_DOMAIN" }}
# policy: bypass # policy: bypass
# Customer stacks: authenticated subscribers with one_factor
- domain_regex: '^[a-z0-9-]+\.{{ env "TRAEFIK_DOMAIN" }}$'
subject:
- "group:customers"
policy: one_factor
# ss-atlas app public routes (landing, webhook)
- domain: 'app.{{ env "TRAEFIK_DOMAIN" }}'
policy: bypass
resources:
- '^/$'
- '^/subscribe$'
- '^/success(\?.*)?$'
- '^/webhook/stripe$'
- '^/health$'
# ss-atlas activate requires any authenticated user (not yet in customers group)
- domain: 'app.{{ env "TRAEFIK_DOMAIN" }}'
resources:
- '^/activate$'
policy: one_factor
# ss-atlas dashboard requires auth
- domain: 'app.{{ env "TRAEFIK_DOMAIN" }}'
subject:
- "group:customers"
policy: one_factor
# Admin services require two-factor authentication # Admin services require two-factor authentication
- domain: - domain:
- "portainer.nixc.us" - "portainer.a250.ca"
- "login.nixc.us" - "login.a250.ca"
- "git.nixc.us" - "git.nixc.us"
subject: subject:
- "group:admins" - "group:admins"
policy: two_factor policy: two_factor
# General admin access (less sensitive services) # General admin access (less sensitive services)
- domain: "*.nixc.us" - domain: "*.a250.ca"
subject: subject:
- "group:admins" - "group:admins"
# - "group:dev" # - "group:dev"
policy: one_factor policy: one_factor
# traefik monitor # traefik monitor
- domain: - domain:
- "monitor-ertest.nixc.us" - "monitor-ertest.a250.ca"
subject: subject:
- "group:monitor-ertest" - "group:monitor-ertest"
policy: one_factor policy: one_factor
# guacamole # guacamole
- domain: - domain:
- "guac.nixc.us" - "guac.a250.ca"
subject: subject:
- "group:guac" - "group:guac"
policy: one_factor policy: one_factor
# uptime-kuma # uptime-kuma
- domain: - domain:
- "uptime.nixc.us" - "uptime.a250.ca"
subject: subject:
- "group:uptime-kuma" - "group:uptime-kuma"
policy: one_factor policy: one_factor
# Filebrowser and Bypass # Filebrowser and Bypass
- domain: - domain:
- "fb.nixc.us" - "fb.a250.ca"
- "fbi.nixc.us" - "fbi.a250.ca"
subject: subject:
- "group:admins" - "group:admins"
policy: one_factor policy: one_factor
- domain: - domain:
- "fb.nixc.us" - "fb.a250.ca"
- "fbi.nixc.us" - "fbi.a250.ca"
policy: bypass policy: bypass
resources: resources:
- '^/api/(.*)?$' - '^/api/(.*)?$'
@ -73,54 +104,54 @@ access_control:
- '^/static/(.*)?$' - '^/static/(.*)?$'
## Transfer.sh ## Transfer.sh
- domain: - domain:
- "tx.nixc.us" - "tx.a250.ca"
subject: subject:
- "group:transfer" - "group:transfer"
policy: one_factor policy: one_factor
## Firefox ## Firefox
- domain: - domain:
- "ff.nixc.us" - "ff.a250.ca"
subject: subject:
- "group:firefox" - "group:firefox"
policy: one_factor policy: one_factor
- domain: - domain:
- "oracle.nixc.us" - "oracle.a250.ca"
subject: subject:
- "group:oracle" - "group:oracle"
policy: one_factor policy: one_factor
## Stash ## Stash
- domain: - domain:
- "fb.nixc.us" - "fb.a250.ca"
subject: subject:
- "group:fansdb" - "group:fansdb"
policy: one_factor policy: one_factor
# Filebrowser and Bypass # Filebrowser and Bypass
- domain: - domain:
- "fb-stash.nixc.us" - "fb-stash.a250.ca"
subject: subject:
- "group:stash_admin" - "group:stash_admin"
policy: one_factor policy: one_factor
# Graylog access (sensitive logs require two-factor) # Graylog access (sensitive logs require two-factor)
- domain: - domain:
- "log.nixc.us" - "log.a250.ca"
subject: subject:
- "group:graylog" - "group:graylog"
policy: two_factor policy: two_factor
# whisper access # whisper access
- domain: - domain:
- "whisper.nixc.us" - "whisper.a250.ca"
subject: subject:
- "group:kwlug" - "group:kwlug"
policy: one_factor policy: one_factor
# whisper access # whisper access
- domain: - domain:
- "marketing-browser.nixc.us" - "marketing-browser.a250.ca"
subject: subject:
- "group:mrc" - "group:mrc"
policy: one_factor policy: one_factor
# scanner access # scanner access
- domain: - domain:
- "scanner.oid.nixc.us" - "scanner.oid.a250.ca"
subject: subject:
- "group:mrc" - "group:mrc"
policy: one_factor policy: one_factor

View File

@ -0,0 +1,3 @@
notifier:
filesystem:
filename: /data/notification.txt

View File

@ -20,7 +20,7 @@ totp:
webauthn: webauthn:
disable: false disable: false
enable_passkey_login: true enable_passkey_login: true
display_name: Authelia display_name: a250.ca
attestation_conveyance_preference: indirect attestation_conveyance_preference: indirect
timeout: 60s timeout: 60s
selection_criteria: selection_criteria:
@ -49,14 +49,6 @@ storage:
password: authelia password: authelia
timeout: 5s timeout: 5s
notifier:
smtp:
address: submissions://box.p.nixc.us
username: {{ env "X_AUTHELIA_EMAIL" }}
password: {{ secret "/run/secrets/NOTIFIER_SMTP_PASSWORD" }}
sender: "{{ env "X_AUTHELIA_SITE_NAME" }} <{{ env "X_AUTHELIA_EMAIL" }}>"
subject: "[Authelia] {title}"
session: session:
secret: {{ secret "/run/secrets/SESSION_SECRET" }} secret: {{ secret "/run/secrets/SESSION_SECRET" }}
name: authelia_session name: authelia_session

View File

@ -1 +1 @@
FROM git.nixc.us/nixius/authelia:staging-mariadb FROM git.nixc.us/a250/authelia:staging-mariadb

View File

@ -1 +1 @@
FROM git.nixc.us/nixius/authelia:staging-redis FROM git.nixc.us/a250/authelia:staging-redis

View File

@ -0,0 +1,17 @@
FROM golang:1.23-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /ss-atlas ./cmd/
FROM alpine:3.21
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /ss-atlas /app/ss-atlas
COPY templates/ /app/templates/
EXPOSE 8080
ENTRYPOINT ["/app/ss-atlas"]

6
docker/ss-atlas/Makefile Normal file
View File

@ -0,0 +1,6 @@
.PHONY: test build
test:
go test ./...
build:
go build -o ss-atlas ./cmd/main.go

View File

@ -0,0 +1,53 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"git.nixc.us/a250/ss-atlas/internal/config"
"git.nixc.us/a250/ss-atlas/internal/handlers"
"git.nixc.us/a250/ss-atlas/internal/ldap"
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/swarm"
)
func main() {
cfg := config.Load()
stripeClient := ssstripe.New(cfg)
ldapClient := ldap.New(cfg)
swarmClient := swarm.New(cfg)
router := handlers.NewRouter(cfg, stripeClient, ldapClient, swarmClient)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: router,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("ss-atlas listening on :%s", cfg.Port)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("forced shutdown: %v", err)
}
}

16
docker/ss-atlas/go.mod Normal file
View File

@ -0,0 +1,16 @@
module git.nixc.us/a250/ss-atlas
go 1.23
require (
github.com/go-chi/chi/v5 v5.2.1
github.com/go-ldap/ldap/v3 v3.4.10
github.com/stripe/stripe-go/v84 v84.4.0
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/google/uuid v1.6.0 // indirect
golang.org/x/crypto v0.31.0 // indirect
)

119
docker/ss-atlas/go.sum Normal file
View File

@ -0,0 +1,119 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stripe/stripe-go/v84 v84.4.0 h1:JMQMqb+mhW6tns+eYA3G5SZiaoD2ULwN0lZ+kNjWAsY=
github.com/stripe/stripe-go/v84 v84.4.0/go.mod h1:Z4gcKw1zl4geDG2+cjpSaJES9jaohGX6n7FP8/kHIqw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -0,0 +1,61 @@
package checks
import (
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
const maxLines = 400
// TestNoFileExceedsMaxLines ensures no project file exceeds the line limit.
// This keeps the codebase modular and maintainable.
func TestNoFileExceedsMaxLines(t *testing.T) {
_, thisFile, _, _ := runtime.Caller(0)
root := filepath.Join(filepath.Dir(thisFile), "..", "..")
exts := map[string]bool{
".go": true, ".yml": true, ".yaml": true, ".html": true,
".sh": true, ".md": true,
}
var over []string
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
name := info.Name()
if name == "vendor" || name == "node_modules" || name == ".git" {
return filepath.SkipDir
}
return nil
}
if !exts[strings.ToLower(filepath.Ext(path))] {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
lines := strings.Count(string(data), "\n")
if strings.HasSuffix(string(data), "\n") {
// last line has newline
} else if len(data) > 0 {
lines++
}
if lines > maxLines {
rel, _ := filepath.Rel(root, path)
over = append(over, rel+":"+strconv.Itoa(lines))
}
return nil
})
if len(over) > 0 {
t.Errorf("files exceed %d lines:\n%s", maxLines, strings.Join(over, "\n"))
}
}

View File

@ -0,0 +1,46 @@
package config
import "os"
type Config struct {
Port string
AppURL string
AutheliaURL string
StripeSecretKey string
StripeWebhookSecret string
StripePriceID string
LDAPUrl string
LDAPAdminDN string
LDAPAdminPassword string
LDAPBaseDN string
DockerHost string
TraefikDomain string
TraefikNetwork string
TemplatePath string
}
func Load() *Config {
return &Config{
Port: envOrDefault("PORT", "8080"),
AppURL: envOrDefault("APP_URL", "http://app.bc.a250.ca"),
AutheliaURL: envOrDefault("AUTHELIA_URL", "http://login.bc.a250.ca"),
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
LDAPUrl: envOrDefault("LLDAP_URL", "ldap://lldap_lldap:3890"),
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"),
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"),
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"),
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
}
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,93 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestEnvOrDefault(t *testing.T) {
key := "SSATLAS_TEST_ENV_VAR"
defer os.Unsetenv(key)
if got := envOrDefault(key, "default"); got != "default" {
t.Errorf("unset env: got %q, want default", got)
}
os.Setenv(key, "custom")
if got := envOrDefault(key, "default"); got != "custom" {
t.Errorf("set env: got %q, want custom", got)
}
os.Setenv(key, "")
if got := envOrDefault(key, "default"); got != "default" {
t.Errorf("empty env: got %q, want default", got)
}
}
func TestLoadDefaults(t *testing.T) {
// Clear env vars that Load uses
envKeys := []string{
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "LLDAP_URL",
"LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN",
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH",
}
for _, k := range envKeys {
os.Unsetenv(k)
}
defer func() {
for _, k := range envKeys {
os.Unsetenv(k)
}
}()
cfg := Load()
tests := []struct {
field string
got string
want string
}{
{"Port", cfg.Port, "8080"},
{"AppURL", cfg.AppURL, "http://app.bc.a250.ca"},
{"AutheliaURL", cfg.AutheliaURL, "http://login.bc.a250.ca"},
{"LDAPUrl", cfg.LDAPUrl, "ldap://lldap_lldap:3890"},
{"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"},
{"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"},
{"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"},
{"TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"},
{"TemplatePath", cfg.TemplatePath, "/app/templates"},
}
for _, tt := range tests {
if tt.got != tt.want {
t.Errorf("Load().%s = %q, want %q", tt.field, tt.got, tt.want)
}
}
}
func TestLoadFromEnv(t *testing.T) {
os.Setenv("PORT", "9000")
os.Setenv("TEMPLATE_PATH", "/custom/templates")
defer os.Unsetenv("PORT")
defer os.Unsetenv("TEMPLATE_PATH")
cfg := Load()
if cfg.Port != "9000" {
t.Errorf("Port = %q, want 9000", cfg.Port)
}
if cfg.TemplatePath != "/custom/templates" {
t.Errorf("TemplatePath = %q, want /custom/templates", cfg.TemplatePath)
}
}
func TestLoadTemplatePathRelative(t *testing.T) {
wd, _ := os.Getwd()
relPath := filepath.Join(wd, "..", "..", "templates")
os.Setenv("TEMPLATE_PATH", relPath)
defer os.Unsetenv("TEMPLATE_PATH")
cfg := Load()
if cfg.TemplatePath != relPath {
t.Errorf("TemplatePath = %q, want %q", cfg.TemplatePath, relPath)
}
}

View File

@ -0,0 +1,64 @@
package handlers
import (
"fmt"
"log"
"net/http"
)
func (a *App) handleActivateGet(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User")
if remoteUser == "" {
data := map[string]any{
"AutheliaURL": a.cfg.AutheliaURL,
"AppURL": a.cfg.AppURL,
"NeedLogin": true,
}
a.tmpl.ExecuteTemplate(w, "activate.html", data)
return
}
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
if inGroup {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
data := map[string]any{
"User": remoteUser,
"AppURL": a.cfg.AppURL,
"Ready": true,
}
if err := a.tmpl.ExecuteTemplate(w, "activate.html", data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User")
if remoteUser == "" {
http.Error(w, "not authenticated", http.StatusUnauthorized)
return
}
inGroup, _ := a.ldap.IsInGroup(remoteUser, "customers")
if inGroup {
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
return
}
if err := a.ldap.AddToGroup(remoteUser, "customers"); err != nil {
log.Printf("activate: group add failed for %s: %v", remoteUser, err)
http.Error(w, "activation failed, contact support", http.StatusInternalServerError)
return
}
stackName := fmt.Sprintf("customer-%s", remoteUser)
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
log.Printf("activate: stack deploy failed for %s: %v", remoteUser, err)
}
log.Printf("activated user %s: group=customers stack=%s", remoteUser, stackName)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}

View File

@ -0,0 +1,40 @@
package handlers
import (
"log"
"net/http"
)
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User")
remoteEmail := r.Header.Get("Remote-Email")
remoteGroups := r.Header.Get("Remote-Groups")
data := map[string]any{
"AppURL": a.cfg.AppURL,
"AutheliaURL": a.cfg.AutheliaURL,
"User": remoteUser,
"Email": remoteEmail,
"Groups": remoteGroups,
"Domain": a.cfg.TraefikDomain,
"IsSubscribed": remoteGroups != "" && contains(remoteGroups, "customers"),
}
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}

View File

@ -0,0 +1,96 @@
package handlers
import (
"net/http"
"net/http/httptest"
"path/filepath"
"runtime"
"testing"
"git.nixc.us/a250/ss-atlas/internal/config"
)
func TestContains(t *testing.T) {
tests := []struct {
s string
sub string
want bool
}{
{"customers,admins", "customers", true},
{"admins,customers", "customers", true},
{"customers", "customers", true},
{"admins,users", "customers", false},
{"", "customers", false},
{"cus", "customers", false},
{"customersx", "customers", true},
}
for _, tt := range tests {
if got := contains(tt.s, tt.sub); got != tt.want {
t.Errorf("contains(%q, %q) = %v, want %v", tt.s, tt.sub, got, tt.want)
}
}
}
func TestSearchString(t *testing.T) {
tests := []struct {
s string
sub string
want bool
}{
{"customers,admins", "customers", true},
{"admins", "admins", true},
{"a", "a", true},
{"abc", "b", true},
{"abc", "d", false},
{"", "a", false},
}
for _, tt := range tests {
if got := searchString(tt.s, tt.sub); got != tt.want {
t.Errorf("searchString(%q, %q) = %v, want %v", tt.s, tt.sub, got, tt.want)
}
}
}
func TestSanitizeUsername(t *testing.T) {
tests := []struct {
email string
want string
}{
{"user@example.com", "user"},
{"User.Name@domain.com", "user-name"},
{"user_name@domain.com", "user_name"},
{"user123@domain.com", "user123"},
{"UPPER@domain.com", "upper"},
{"a.b.c@x.com", "a-b-c"},
{"spécial@x.com", "sp-cial"},
}
for _, tt := range tests {
if got := sanitizeUsername(tt.email); got != tt.want {
t.Errorf("sanitizeUsername(%q) = %q, want %q", tt.email, got, tt.want)
}
}
}
func TestHealthRoute(t *testing.T) {
_, filename, _, _ := runtime.Caller(0)
rootDir := filepath.Join(filepath.Dir(filename), "..", "..")
templatePath := filepath.Join(rootDir, "templates")
cfg := &config.Config{
Port: "8080",
TemplatePath: templatePath,
}
router := NewRouter(cfg, nil, nil, nil)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("GET /health status = %d, want 200", rec.Code)
}
if body := rec.Body.String(); body != "ok" {
t.Errorf("GET /health body = %q, want ok", body)
}
}

View File

@ -0,0 +1,55 @@
package handlers
import (
"html/template"
"net/http"
"path/filepath"
"git.nixc.us/a250/ss-atlas/internal/config"
"git.nixc.us/a250/ss-atlas/internal/ldap"
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/swarm"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type App struct {
cfg *config.Config
stripe *ssstripe.Client
ldap *ldap.Client
swarm *swarm.Client
tmpl *template.Template
}
func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swarm.Client) http.Handler {
tmplPattern := filepath.Join(cfg.TemplatePath, "pages", "*.html")
tmpl := template.Must(template.ParseGlob(tmplPattern))
app := &App{
cfg: cfg,
stripe: sc,
ldap: lc,
swarm: sw,
tmpl: tmpl,
}
r := chi.NewRouter()
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.RealIP)
r.Get("/", app.handleLanding)
r.Get("/success", app.handleSuccess)
r.Get("/activate", app.handleActivateGet)
r.Post("/activate", app.handleActivatePost)
r.Get("/dashboard", app.handleDashboard)
r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/portal", app.handlePortal)
r.Post("/webhook/stripe", app.handleWebhook)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
return r
}

View File

@ -0,0 +1,112 @@
package handlers
import (
"log"
"net/http"
"strings"
)
func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
data := map[string]any{
"AppURL": a.cfg.AppURL,
}
if err := a.tmpl.ExecuteTemplate(w, "landing.html", data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
func (a *App) handleCreateCheckout(w http.ResponseWriter, r *http.Request) {
email := r.FormValue("email")
if email == "" {
http.Error(w, "email required", http.StatusBadRequest)
return
}
sess, err := a.stripe.CreateCheckoutSession(email)
if err != nil {
log.Printf("stripe checkout error: %v", err)
http.Error(w, "failed to create checkout", http.StatusInternalServerError)
return
}
http.Redirect(w, r, sess.URL, http.StatusSeeOther)
}
func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
sessionID := r.URL.Query().Get("session_id")
if sessionID == "" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
sess, err := a.stripe.GetCheckoutSession(sessionID)
if err != nil {
log.Printf("stripe get session error: %v", err)
http.Error(w, "could not verify payment", http.StatusInternalServerError)
return
}
if sess.PaymentStatus != "paid" {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
email := sess.CustomerDetails.Email
customerID := sess.Customer.ID
username := sanitizeUsername(email)
// Create the LLDAP user but do NOT add to group or deploy stack yet.
// That happens on /activate after the user has set their own password.
result, err := a.ldap.ProvisionUser(username, email, customerID)
if err != nil {
log.Printf("ldap provision failed for %s: %v", email, err)
http.Error(w, "account creation failed, contact support", http.StatusInternalServerError)
return
}
data := map[string]any{
"Username": result.Username,
"Password": result.Password,
"IsNew": result.IsNew,
"Email": email,
"LoginURL": a.cfg.AutheliaURL,
"ResetURL": a.cfg.AutheliaURL + "/#/reset-password/step1",
"ActivateURL": a.cfg.AppURL + "/activate",
"DashboardURL": a.cfg.AppURL + "/dashboard",
}
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
}
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
customerID := r.FormValue("customer_id")
if customerID == "" {
http.Error(w, "customer_id required", http.StatusBadRequest)
return
}
sess, err := a.stripe.CreatePortalSession(customerID)
if err != nil {
log.Printf("stripe portal error: %v", err)
http.Error(w, "failed to create portal session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, sess.URL, http.StatusSeeOther)
}
func sanitizeUsername(email string) string {
parts := strings.SplitN(email, "@", 2)
name := strings.ToLower(parts[0])
name = strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '-'
}, name)
return name
}

View File

@ -0,0 +1,102 @@
package handlers
import (
"encoding/json"
"io"
"log"
"net/http"
stripego "github.com/stripe/stripe-go/v84"
"github.com/stripe/stripe-go/v84/webhook"
)
const maxWebhookPayload = 65536
func (a *App) handleWebhook(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, maxWebhookPayload))
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
sig := r.Header.Get("Stripe-Signature")
event, err := webhook.ConstructEvent(body, sig, a.stripe.WebhookSecret())
if err != nil {
log.Printf("webhook signature verification failed: %v", err)
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
switch event.Type {
case "checkout.session.completed":
a.onCheckoutCompleted(event)
case "customer.subscription.deleted":
a.onSubscriptionDeleted(event)
case "customer.subscription.updated":
a.onSubscriptionUpdated(event)
default:
log.Printf("unhandled webhook event: %s", event.Type)
}
w.WriteHeader(http.StatusOK)
}
// Reconciliation: ensures LLDAP user exists. Group + stack are handled by /activate.
func (a *App) onCheckoutCompleted(event stripego.Event) {
var sess stripego.CheckoutSession
if err := json.Unmarshal(event.Data.Raw, &sess); err != nil {
log.Printf("checkout unmarshal error: %v", err)
return
}
email := sess.CustomerDetails.Email
customerID := sess.Customer.ID
username := sanitizeUsername(email)
log.Printf("webhook: checkout completed email=%s customer=%s", email, customerID)
if err := a.ldap.EnsureUser(username, email, customerID); err != nil {
log.Printf("webhook: ldap ensure user failed: %v", err)
}
}
func (a *App) onSubscriptionDeleted(event stripego.Event) {
var sub stripego.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
log.Printf("subscription unmarshal error: %v", err)
return
}
customerID := sub.Customer.ID
log.Printf("subscription deleted for customer %s", customerID)
username, err := a.ldap.FindUserByDescription(customerID)
if err != nil {
log.Printf("could not find user for customer %s: %v", customerID, err)
return
}
if err := a.ldap.RemoveFromGroup(username, "customers"); err != nil {
log.Printf("ldap group remove failed: %v", err)
}
stackName := "customer-" + username
if err := a.swarm.RemoveStack(stackName); err != nil {
log.Printf("stack remove failed for %s: %v", stackName, err)
}
log.Printf("deprovisioned stack for customer %s (%s)", customerID, username)
}
func (a *App) onSubscriptionUpdated(event stripego.Event) {
var sub stripego.Subscription
if err := json.Unmarshal(event.Data.Raw, &sub); err != nil {
log.Printf("subscription update unmarshal error: %v", err)
return
}
if sub.Status == stripego.SubscriptionStatusCanceled ||
sub.Status == stripego.SubscriptionStatusUnpaid {
log.Printf("subscription %s status=%s, will be cleaned up on deletion", sub.ID, sub.Status)
}
}

View File

@ -0,0 +1,198 @@
package ldap
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"git.nixc.us/a250/ss-atlas/internal/config"
goldap "github.com/go-ldap/ldap/v3"
)
type Client struct {
cfg *config.Config
}
type ProvisionResult struct {
Username string
Password string
IsNew bool
}
func New(cfg *config.Config) *Client {
return &Client{cfg: cfg}
}
func (c *Client) connect() (*goldap.Conn, error) {
conn, err := goldap.DialURL(c.cfg.LDAPUrl)
if err != nil {
return nil, fmt.Errorf("ldap dial: %w", err)
}
if err := conn.Bind(c.cfg.LDAPAdminDN, c.cfg.LDAPAdminPassword); err != nil {
conn.Close()
return nil, fmt.Errorf("ldap bind: %w", err)
}
return conn, nil
}
func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*ProvisionResult, error) {
conn, err := c.connect()
if err != nil {
return nil, err
}
defer conn.Close()
exists, err := c.userExists(conn, username)
if err != nil {
return nil, err
}
if exists {
log.Printf("ldap user %s already exists", username)
return &ProvisionResult{Username: username, IsNew: false}, nil
}
password := generatePassword()
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
addReq := goldap.NewAddRequest(userDN, nil)
addReq.Attribute("objectClass", []string{"inetOrgPerson"})
addReq.Attribute("cn", []string{username})
addReq.Attribute("sn", []string{username})
addReq.Attribute("uid", []string{username})
addReq.Attribute("mail", []string{email})
addReq.Attribute("userPassword", []string{password})
addReq.Attribute("description", []string{stripeCustomerID})
if err := conn.Add(addReq); err != nil {
return nil, fmt.Errorf("ldap add user %s: %w", username, err)
}
log.Printf("created ldap user %s (%s)", username, email)
return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil
}
func (c *Client) EnsureUser(username, email, stripeCustomerID string) error {
_, err := c.ProvisionUser(username, email, stripeCustomerID)
return err
}
func (c *Client) AddToGroup(username, groupName string) error {
conn, err := c.connect()
if err != nil {
return err
}
defer conn.Close()
groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupName, c.cfg.LDAPBaseDN)
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
modReq := goldap.NewModifyRequest(groupDN, nil)
modReq.Add("member", []string{userDN})
if err := conn.Modify(modReq); err != nil {
return fmt.Errorf("ldap add %s to group %s: %w", username, groupName, err)
}
log.Printf("added %s to group %s", username, groupName)
return nil
}
func (c *Client) RemoveFromGroup(username, groupName string) error {
conn, err := c.connect()
if err != nil {
return err
}
defer conn.Close()
groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupName, c.cfg.LDAPBaseDN)
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
modReq := goldap.NewModifyRequest(groupDN, nil)
modReq.Delete("member", []string{userDN})
if err := conn.Modify(modReq); err != nil {
return fmt.Errorf("ldap remove %s from group %s: %w", username, groupName, err)
}
log.Printf("removed %s from group %s", username, groupName)
return nil
}
func (c *Client) IsInGroup(username, groupName string) (bool, error) {
conn, err := c.connect()
if err != nil {
return false, err
}
defer conn.Close()
groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupName, c.cfg.LDAPBaseDN)
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
searchReq := goldap.NewSearchRequest(
groupDN,
goldap.ScopeBaseObject, goldap.NeverDerefAliases, 1, 0, false,
fmt.Sprintf("(member=%s)", goldap.EscapeFilter(userDN)),
[]string{"cn"},
nil,
)
result, err := conn.Search(searchReq)
if err != nil {
return false, nil
}
return len(result.Entries) > 0, nil
}
func (c *Client) FindUserByDescription(stripeCustomerID string) (string, error) {
conn, err := c.connect()
if err != nil {
return "", err
}
defer conn.Close()
searchReq := goldap.NewSearchRequest(
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),
goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 1, 0, false,
fmt.Sprintf("(description=%s)", goldap.EscapeFilter(stripeCustomerID)),
[]string{"uid"},
nil,
)
result, err := conn.Search(searchReq)
if err != nil {
return "", fmt.Errorf("ldap search by description: %w", err)
}
if len(result.Entries) == 0 {
return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID)
}
return result.Entries[0].GetAttributeValue("uid"), nil
}
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
searchReq := goldap.NewSearchRequest(
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),
goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 1, 0, false,
fmt.Sprintf("(uid=%s)", goldap.EscapeFilter(username)),
[]string{"uid"},
nil,
)
result, err := conn.Search(searchReq)
if err != nil {
return false, fmt.Errorf("ldap search: %w", err)
}
return len(result.Entries) > 0, nil
}
func generatePassword() string {
b := make([]byte, 18)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return base64.URLEncoding.EncodeToString(b)
}

View File

@ -0,0 +1,57 @@
package stripe
import (
"git.nixc.us/a250/ss-atlas/internal/config"
stripego "github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
"github.com/stripe/stripe-go/v84/subscription"
)
type Client struct {
cfg *config.Config
}
func New(cfg *config.Config) *Client {
stripego.Key = cfg.StripeSecretKey
return &Client{cfg: cfg}
}
func (c *Client) CreateCheckoutSession(email string) (*stripego.CheckoutSession, error) {
params := &stripego.CheckoutSessionParams{
Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)),
LineItems: []*stripego.CheckoutSessionLineItemParams{
{
Price: stripego.String(c.cfg.StripePriceID),
Quantity: stripego.Int64(1),
},
},
CustomerEmail: stripego.String(email),
SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripego.String(c.cfg.AppURL + "/"),
}
return checkoutsession.New(params)
}
func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
params := &stripego.BillingPortalSessionParams{
Customer: stripego.String(customerID),
ReturnURL: stripego.String(c.cfg.AppURL + "/dashboard"),
}
return portalsession.New(params)
}
func (c *Client) GetCheckoutSession(sessionID string) (*stripego.CheckoutSession, error) {
params := &stripego.CheckoutSessionParams{}
params.AddExpand("customer")
params.AddExpand("subscription")
return checkoutsession.Get(sessionID, params)
}
func (c *Client) GetSubscription(subID string) (*stripego.Subscription, error) {
return subscription.Get(subID, nil)
}
func (c *Client) WebhookSecret() string {
return c.cfg.StripeWebhookSecret
}

View File

@ -0,0 +1,97 @@
package swarm
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"text/template"
"git.nixc.us/a250/ss-atlas/internal/config"
)
type Client struct {
cfg *config.Config
}
func New(cfg *config.Config) *Client {
return &Client{cfg: cfg}
}
func (c *Client) DeployStack(stackName, username, domain string) error {
tmplPath := filepath.Join(c.cfg.TemplatePath, "stack-template.yml")
tmplBytes, err := os.ReadFile(tmplPath)
if err != nil {
return fmt.Errorf("read stack template: %w", err)
}
t, err := template.New("stack").Parse(string(tmplBytes))
if err != nil {
return fmt.Errorf("parse stack template: %w", err)
}
data := map[string]string{
"ID": username,
"Subdomain": username,
"Domain": domain,
"TraefikNetwork": c.cfg.TraefikNetwork,
}
var rendered bytes.Buffer
if err := t.Execute(&rendered, data); err != nil {
return fmt.Errorf("render stack template: %w", err)
}
tmpFile := filepath.Join(os.TempDir(), stackName+".yml")
if err := os.WriteFile(tmpFile, rendered.Bytes(), 0600); err != nil {
return fmt.Errorf("write temp stack file: %w", err)
}
defer os.Remove(tmpFile)
cmd := exec.Command("docker", "stack", "deploy",
"-c", tmpFile,
stackName,
)
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker stack deploy: %s: %w", strings.TrimSpace(string(output)), err)
}
log.Printf("deployed stack %s: %s", stackName, strings.TrimSpace(string(output)))
return nil
}
func (c *Client) RemoveStack(stackName string) error {
cmd := exec.Command("docker", "stack", "rm", stackName)
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("docker stack rm: %s: %w", strings.TrimSpace(string(output)), err)
}
log.Printf("removed stack %s: %s", stackName, strings.TrimSpace(string(output)))
return nil
}
func (c *Client) StackExists(stackName string) (bool, error) {
cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}")
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
output, err := cmd.CombinedOutput()
if err != nil {
return false, fmt.Errorf("docker stack ls: %w", err)
}
for _, line := range strings.Split(string(output), "\n") {
if strings.TrimSpace(line) == stackName {
return true, nil
}
}
return false, nil
}

View File

@ -0,0 +1,89 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>a250.ca - Activate</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--muted: #a1a1aa;
--accent: #6366f1;
--accent-hover: #818cf8;
--green: #22c55e;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container { max-width: 520px; width: 100%; }
.logo { font-size: 2rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
.subtitle { font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; }
.subtitle-green { color: var(--green); }
.subtitle-muted { color: var(--muted); }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
text-align: center;
}
.card h2 { font-size: 1.15rem; font-weight: 600; margin-bottom: 0.75rem; }
.card p { color: var(--muted); font-size: 0.95rem; line-height: 1.6; margin-bottom: 1.5rem; }
.btn {
display: inline-block;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem 2rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.btn:hover { background: var(--accent-hover); }
.icon {
font-size: 3rem;
margin-bottom: 1rem;
display: block;
}
</style>
</head>
<body>
<div class="container">
<div class="logo">a250.ca</div>
{{if .NeedLogin}}
<div class="subtitle subtitle-muted">Almost there</div>
<div class="card">
<span class="icon">&#128274;</span>
<h2>Sign In First</h2>
<p>You need to sign in with your new password before activating your stack. If you haven't set your password yet, do that first.</p>
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
</div>
{{else if .Ready}}
<div class="subtitle subtitle-green">Welcome, {{.User}}</div>
<div class="card">
<span class="icon">&#9889;</span>
<h2>Activate Your Stack</h2>
<p>Your account is verified. Click below to provision your dedicated environment and get full access.</p>
<form method="POST" action="/activate">
<button type="submit" class="btn">Activate Now</button>
</form>
</div>
{{end}}
</div>
</body>
</html>

View File

@ -0,0 +1,161 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>a250.ca - Dashboard</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--muted: #a1a1aa;
--accent: #6366f1;
--accent-hover: #818cf8;
--green: #22c55e;
--red: #ef4444;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
padding: 2rem;
}
.header {
max-width: 720px;
margin: 0 auto 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.header .logo { font-size: 1.5rem; font-weight: 800; letter-spacing: -0.04em; }
.header .user-info {
display: flex;
align-items: center;
gap: 1rem;
color: var(--muted);
font-size: 0.9rem;
}
.container { max-width: 720px; margin: 0 auto; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem 2rem;
margin-bottom: 1.5rem;
}
.card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
.status-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
}
.status-label { color: var(--muted); font-size: 0.9rem; }
.status-value { font-weight: 600; font-size: 0.9rem; }
.badge {
display: inline-block;
padding: 0.2rem 0.7rem;
border-radius: 100px;
font-size: 0.8rem;
font-weight: 600;
}
.badge-active { background: rgba(34,197,94,0.15); color: var(--green); }
.badge-inactive { background: rgba(239,68,68,0.15); color: var(--red); }
.stack-link {
display: block;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
color: var(--accent);
text-decoration: none;
font-weight: 500;
margin-top: 0.75rem;
transition: border-color 0.2s;
}
.stack-link:hover { border-color: var(--accent); }
.btn {
display: inline-block;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.btn:hover { background: var(--accent-hover); }
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: transparent; }
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted); }
.empty-state p { margin-bottom: 1.5rem; }
</style>
</head>
<body>
<div class="header">
<div class="logo">a250.ca</div>
<div class="user-info">
{{if .User}}{{.User}}{{else}}Not logged in{{end}}
</div>
</div>
<div class="container">
{{if .IsSubscribed}}
<div class="card">
<h2>Subscription</h2>
<div class="status-row">
<span class="status-label">Status</span>
<span class="badge badge-active">Active</span>
</div>
<div class="status-row">
<span class="status-label">Email</span>
<span class="status-value">{{.Email}}</span>
</div>
</div>
<div class="card">
<h2>Your Stack</h2>
<p style="color: var(--muted); font-size: 0.9rem;">Your dedicated environment is live and accessible at:</p>
<a class="stack-link" href="http://{{.User}}.{{.Domain}}">{{.User}}.{{.Domain}}</a>
</div>
<div class="card">
<h2>Manage</h2>
<div class="actions">
<form method="POST" action="/portal" style="margin:0">
<input type="hidden" name="customer_id" value="">
<button type="submit" class="btn">Manage Subscription</button>
</form>
<a href="{{.AutheliaURL}}" class="btn btn-outline">Account Settings</a>
</div>
<p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;">
No refunds for the current billing period. Access continues until the end of your paid month.
</p>
</div>
{{else}}
<div class="card">
<div class="empty-state">
{{if .User}}
<h2>No Active Subscription</h2>
<p>You're signed in as <strong>{{.User}}</strong>, but you don't have an active subscription.</p>
<a href="/" class="btn">Subscribe Now</a>
{{else}}
<h2>Sign In Required</h2>
<p>Sign in to access your dashboard.</p>
<a href="{{.AutheliaURL}}" class="btn">Sign In</a>
{{end}}
</div>
</div>
{{end}}
</div>
</body>
</html>

View File

@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>a250.ca - Subscribe</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--muted: #a1a1aa;
--accent: #6366f1;
--accent-hover: #818cf8;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
}
.container { max-width: 480px; width: 100%; }
.logo {
font-size: 2.5rem;
font-weight: 800;
letter-spacing: -0.04em;
margin-bottom: 0.5rem;
}
.tagline {
color: var(--muted);
font-size: 1.1rem;
margin-bottom: 2.5rem;
line-height: 1.5;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 2rem;
}
.card h2 { font-size: 1.25rem; font-weight: 600; margin-bottom: 0.5rem; }
.price { font-size: 2rem; font-weight: 700; margin-bottom: 1.5rem; }
.price span { font-size: 0.9rem; font-weight: 400; color: var(--muted); }
.features { list-style: none; margin-bottom: 2rem; }
.features li { padding: 0.4rem 0; color: var(--muted); font-size: 0.95rem; }
.features li::before { content: "\2713"; color: var(--accent); font-weight: 700; margin-right: 0.75rem; }
form { display: flex; flex-direction: column; gap: 0.75rem; }
input[type="email"] {
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 1rem;
color: var(--text);
outline: none;
transition: border-color 0.2s;
}
input[type="email"]:focus { border-color: var(--accent); }
input[type="email"]::placeholder { color: var(--muted); }
button {
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.75rem 1rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: var(--accent-hover); }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
.footer a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<div class="container">
<div class="logo">a250.ca</div>
<p class="tagline">Your own managed infrastructure stack, provisioned instantly when you subscribe.</p>
<div class="card">
<h2>Monthly Plan</h2>
<div class="price">$20.00 <span>/ month</span></div>
<ul class="features">
<li>Dedicated Docker stack</li>
<li>Secure single sign-on</li>
<li>Automatic provisioning</li>
<li>Manage subscription anytime</li>
</ul>
<form method="POST" action="/subscribe">
<input type="email" name="email" placeholder="you@example.com" required>
<button type="submit">Subscribe Now</button>
</form>
</div>
<div class="footer">
Already subscribed? <a href="/dashboard">Go to Dashboard</a>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,172 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>a250.ca - Welcome</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2d3a;
--text: #e4e4e7;
--muted: #a1a1aa;
--accent: #6366f1;
--accent-hover: #818cf8;
--green: #22c55e;
--yellow: #eab308;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 2rem;
}
.container { max-width: 560px; width: 100%; }
.logo { font-size: 2rem; font-weight: 800; letter-spacing: -0.04em; margin-bottom: 0.5rem; }
.subtitle { color: var(--green); font-size: 1.1rem; font-weight: 600; margin-bottom: 2rem; }
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.5rem 2rem;
margin-bottom: 1.5rem;
}
.card h2 { font-size: 1.1rem; font-weight: 600; margin-bottom: 1rem; }
.cred-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.6rem 0;
border-bottom: 1px solid var(--border);
}
.cred-row:last-child { border-bottom: none; }
.cred-label { color: var(--muted); font-size: 0.9rem; }
.cred-value {
font-family: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace;
font-size: 0.9rem;
font-weight: 600;
background: var(--bg);
padding: 0.3rem 0.7rem;
border-radius: 6px;
border: 1px solid var(--border);
user-select: all;
}
.warning {
background: rgba(234, 179, 8, 0.1);
border: 1px solid rgba(234, 179, 8, 0.3);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
color: var(--yellow);
font-size: 0.9rem;
line-height: 1.5;
}
.warning strong { color: var(--yellow); }
.steps { list-style: none; counter-reset: step; }
.steps li {
counter-increment: step;
padding: 0.6rem 0;
color: var(--muted);
font-size: 0.95rem;
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.steps li::before {
content: counter(step);
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.6rem;
height: 1.6rem;
background: var(--accent);
color: #fff;
border-radius: 50%;
font-size: 0.8rem;
font-weight: 700;
}
.btn {
display: inline-block;
background: var(--accent);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.7rem 1.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
text-decoration: none;
transition: background 0.2s;
}
.btn:hover { background: var(--accent-hover); }
.btn-outline {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
}
.btn-outline:hover { border-color: var(--accent); color: var(--accent); background: transparent; }
.actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
.returning { text-align: center; padding: 2rem; color: var(--muted); }
.returning p { margin-bottom: 1rem; }
</style>
</head>
<body>
<div class="container">
<div class="logo">a250.ca</div>
{{if .IsNew}}
<div class="subtitle">Payment successful — your account is ready!</div>
<div class="warning">
<strong>Temporary password.</strong> Use this to sign in for the first time, then you will be asked to reset it to something you choose.
</div>
<div class="card">
<h2>Your Temporary Login</h2>
<div class="cred-row">
<span class="cred-label">Username</span>
<span class="cred-value">{{.Username}}</span>
</div>
<div class="cred-row">
<span class="cred-label">Temporary Password</span>
<span class="cred-value">{{.Password}}</span>
</div>
<div class="cred-row">
<span class="cred-label">Email</span>
<span class="cred-value">{{.Email}}</span>
</div>
</div>
<div class="card">
<h2>Getting Started</h2>
<ol class="steps">
<li>Copy your username and temporary password above</li>
<li>Click "Sign In" — you'll be taken to the login page</li>
<li>Log in with your temporary credentials</li>
<li>You'll be prompted to set a new password of your choice</li>
<li>Once signed in, visit the activation page to launch your stack</li>
</ol>
</div>
<div class="actions">
<a href="{{.ResetURL}}" class="btn">Sign In &amp; Set Password</a>
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
</div>
{{else}}
<div class="subtitle">Welcome back!</div>
<div class="card">
<div class="returning">
<p>Your account <strong>{{.Username}}</strong> is already set up.</p>
<a href="{{.LoginURL}}" class="btn">Sign In</a>
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
</div>
</div>
{{end}}
</div>
</body>
</html>

View File

@ -0,0 +1,43 @@
version: "3.8"
services:
web:
image: traefik/whoami:latest
environment:
WHOAMI_NAME: "{{.Subdomain}}"
networks:
- traefik_net
deploy:
replicas: 1
labels:
traefik.enable: "true"
traefik.docker.network: "{{.TraefikNetwork}}"
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Subdomain}}.{{.Domain}}`)"
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "web"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@docker"
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "80"
restart_policy:
condition: on-failure
redis:
image: redis:7-alpine
command: redis-server --appendonly yes
volumes:
- redis_data:/data
networks:
- backend
deploy:
replicas: 1
restart_policy:
condition: on-failure
networks:
traefik_net:
external: true
name: "{{.TraefikNetwork}}"
backend:
driver: overlay
volumes:
redis_data:
driver: local

View File

@ -123,7 +123,7 @@ ssh macmini7 'docker service logs authelia_authelia | grep -i "failed\|missing"'
### Test OAuth Integration ### Test OAuth Integration
```bash ```bash
# Test OAuth endpoint accessibility # Test OAuth endpoint accessibility
curl -s https://login.nixc.us/.well-known/openid_configuration | jq . curl -s https://login.a250.ca/.well-known/openid_configuration | jq .
# Verify client configurations # Verify client configurations
ssh macmini7 'docker service logs authelia_authelia | grep -i "oidc\|oauth"' ssh macmini7 'docker service logs authelia_authelia | grep -i "oidc\|oauth"'

View File

@ -61,11 +61,11 @@ Configure in Portainer → Settings → Authentication:
- **OAuth Provider**: Custom - **OAuth Provider**: Custom
- **Client ID**: `portainer` - **Client ID**: `portainer`
- **Client Secret**: `<from CI vault>` - **Client Secret**: `<from CI vault>`
- **Authorization URL**: `https://login.nixc.us/api/oidc/authorization` - **Authorization URL**: `https://login.a250.ca/api/oidc/authorization`
- **Token URL**: `https://login.nixc.us/api/oidc/token` - **Token URL**: `https://login.a250.ca/api/oidc/token`
- **User Info URL**: `https://login.nixc.us/api/oidc/userinfo` - **User Info URL**: `https://login.a250.ca/api/oidc/userinfo`
- **Scopes**: `openid email profile groups` - **Scopes**: `openid email profile groups`
- **Redirect URL**: `https://portainer.nixc.us/` - **Redirect URL**: `https://portainer.a250.ca/`
#### 3. Remove Traefik Middleware (Optional) #### 3. Remove Traefik Middleware (Optional)
Once OAuth is working, remove middleware protection: Once OAuth is working, remove middleware protection:
@ -105,8 +105,8 @@ Configure in Gitea → Site Administration → Authentication Sources:
- **OAuth2 Provider**: OpenID Connect - **OAuth2 Provider**: OpenID Connect
- **Client ID**: `gitea` - **Client ID**: `gitea`
- **Client Secret**: `<from CI vault>` - **Client Secret**: `<from CI vault>`
- **OpenID Connect Auto Discovery URL**: `https://login.nixc.us/.well-known/openid_configuration` - **OpenID Connect Auto Discovery URL**: `https://login.a250.ca/.well-known/openid_configuration`
- **Icon URL**: `https://login.nixc.us/static/media/logo.png` (optional) - **Icon URL**: `https://login.a250.ca/static/media/logo.png` (optional)
## 🔄 Deployment Process ## 🔄 Deployment Process
@ -129,9 +129,9 @@ Set up OAuth in each service's admin interface using the URLs and client IDs abo
## 🔍 Testing OAuth Flow ## 🔍 Testing OAuth Flow
### Test Authentication Flow ### Test Authentication Flow
1. **Visit protected service** (e.g., `https://portainer.nixc.us`) 1. **Visit protected service** (e.g., `https://portainer.a250.ca`)
2. **Click OAuth login** button 2. **Click OAuth login** button
3. **Redirect to Authelia** (`https://login.nixc.us`) 3. **Redirect to Authelia** (`https://login.a250.ca`)
4. **Authenticate** with your credentials 4. **Authenticate** with your credentials
5. **Redirect back** to service with authentication 5. **Redirect back** to service with authentication
6. **Access granted** with user information 6. **Access granted** with user information
@ -139,7 +139,7 @@ Set up OAuth in each service's admin interface using the URLs and client IDs abo
### Troubleshooting ### Troubleshooting
- **Check redirect URIs** match exactly (including trailing slashes) - **Check redirect URIs** match exactly (including trailing slashes)
- **Verify client secrets** in CI vault match generated values - **Verify client secrets** in CI vault match generated values
- **Confirm Authelia** is accessible at `https://login.nixc.us` - **Confirm Authelia** is accessible at `https://login.a250.ca`
- **Check service logs** for OAuth-specific error messages - **Check service logs** for OAuth-specific error messages
## 🛡️ Security Considerations ## 🛡️ Security Considerations

View File

@ -43,10 +43,10 @@ docker compose -f docker-compose.dev.yml up -d
``` ```
### Important URLs ### Important URLs
- **Authelia**: https://login.nixc.us - **Authelia**: https://login.a250.ca
- **Development**: http://localhost:9091 - **Development**: http://localhost:9091
- **Health Check**: https://login.nixc.us/api/health - **Health Check**: https://login.a250.ca/api/health
- **OIDC Discovery**: https://login.nixc.us/.well-known/openid_configuration - **OIDC Discovery**: https://login.a250.ca/.well-known/openid_configuration
### Required Secrets (12 Total) ### Required Secrets (12 Total)
- **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP - **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP
@ -70,7 +70,7 @@ ssh macmini7 'docker service logs authelia_authelia --follow'
ssh macmini7 'docker service logs authelia_authelia | grep -i secret' ssh macmini7 'docker service logs authelia_authelia | grep -i secret'
# Test OAuth endpoints # Test OAuth endpoints
curl -s https://login.nixc.us/.well-known/openid_configuration | jq . curl -s https://login.a250.ca/.well-known/openid_configuration | jq .
``` ```
## 📞 Support ## 📞 Support

View File

@ -180,9 +180,9 @@ force_pull_latest_images() {
log "🚀 Force pulling latest images to ensure fresh deployment" log "🚀 Force pulling latest images to ensure fresh deployment"
# Get the image names from docker-compose production file # Get the image names from docker-compose production file
local authelia_image="git.nixc.us/nixius/authelia:production-authelia" local authelia_image="git.nixc.us/a250/authelia:production-authelia"
local mariadb_image="git.nixc.us/nixius/authelia:production-mariadb" local mariadb_image="git.nixc.us/a250/authelia:production-mariadb"
local redis_image="git.nixc.us/nixius/authelia:production-redis" local redis_image="git.nixc.us/a250/authelia:production-redis"
# Pull each image and capture new hashes # Pull each image and capture new hashes
log "Pulling Authelia image..." log "Pulling Authelia image..."

View File

@ -1,9 +1,9 @@
x-authelia-env: &authelia-env x-authelia-env: &authelia-env
X_AUTHELIA_EMAIL: authelia@nixc.us X_AUTHELIA_EMAIL: authelia@a250.ca
X_AUTHELIA_SITE_NAME: ATLAS X_AUTHELIA_SITE_NAME: ATLAS
X_AUTHELIA_CONFIG_FILTERS: template X_AUTHELIA_CONFIG_FILTERS: template
X_AUTHELIA_LDAP_DOMAIN: dc=nixc,dc=us X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: nixc.us TRAEFIK_DOMAIN: a250.ca
secrets: secrets:
AUTHENTICATION_BACKEND_LDAP_PASSWORD: AUTHENTICATION_BACKEND_LDAP_PASSWORD:
@ -54,7 +54,7 @@ volumes:
services: services:
authelia: authelia:
image: git.nixc.us/nixius/authelia:production-authelia image: git.nixc.us/a250/authelia:production-authelia
command: command:
- authelia - authelia
- --config=/config/configuration.server.yml - --config=/config/configuration.server.yml
@ -93,22 +93,22 @@ services:
replicas: 1 replicas: 1
placement: placement:
constraints: constraints:
- node.hostname == ingress.nixc.us - node.hostname == ingress.a250.ca
labels: labels:
us.nixc.autodeploy: "true" us.a250.autodeploy: "true"
homepage.group: Infrastructure homepage.group: Infrastructure
homepage.name: Authelia homepage.name: Authelia
homepage.href: https://login.nixc.us homepage.href: https://login.a250.ca
homepage.description: ATLAS homepage.description: ATLAS
traefik.enable: "true" traefik.enable: "true"
traefik.docker.network: traefik traefik.docker.network: traefik
traefik.http.routers.authelia_authelia.rule: Host(`login.nixc.us`) traefik.http.routers.authelia_authelia.rule: Host(`login.a250.ca`)
traefik.http.routers.authelia_authelia.entrypoints: websecure traefik.http.routers.authelia_authelia.entrypoints: websecure
traefik.http.routers.authelia_authelia.tls: "true" traefik.http.routers.authelia_authelia.tls: "true"
traefik.http.routers.authelia_authelia.tls.certresolver: letsencryptresolver traefik.http.routers.authelia_authelia.tls.certresolver: letsencryptresolver
traefik.http.routers.authelia_authelia.service: authelia_authelia traefik.http.routers.authelia_authelia.service: authelia_authelia
traefik.http.services.authelia_authelia.loadbalancer.server.port: 9091 traefik.http.services.authelia_authelia.loadbalancer.server.port: 9091
traefik.http.middlewares.authelia_authelia.forwardauth.address: http://authelia_authelia:9091/api/verify?rd=https://login.nixc.us/ traefik.http.middlewares.authelia_authelia.forwardauth.address: http://authelia_authelia:9091/api/verify?rd=https://login.a250.ca/
traefik.http.middlewares.authelia_authelia.forwardauth.trustForwardHeader: "true" traefik.http.middlewares.authelia_authelia.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.authelia_authelia.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email traefik.http.middlewares.authelia_authelia.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
traefik.http.middlewares.authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic traefik.http.middlewares.authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic
@ -127,7 +127,7 @@ services:
max-file: "3" max-file: "3"
redis: redis:
image: git.nixc.us/nixius/authelia:production-redis image: git.nixc.us/a250/authelia:production-redis
command: redis-server --appendonly yes command: redis-server --appendonly yes
volumes: volumes:
- authelia_redis_data:/data:rw - authelia_redis_data:/data:rw
@ -143,9 +143,9 @@ services:
replicas: 1 replicas: 1
placement: placement:
constraints: constraints:
- node.hostname == ingress.nixc.us - node.hostname == ingress.a250.ca
labels: labels:
us.nixc.autodeploy: "true" us.a250.autodeploy: "true"
traefik.enable: "false" traefik.enable: "false"
# healthcheck: # healthcheck:
# test: ["CMD", "redis-cli", "ping"] # test: ["CMD", "redis-cli", "ping"]
@ -160,7 +160,7 @@ services:
max-file: "3" max-file: "3"
mariadb: mariadb:
image: git.nixc.us/nixius/authelia:production-mariadb image: git.nixc.us/a250/authelia:production-mariadb
environment: environment:
MYSQL_ROOT_PASSWORD: authelia MYSQL_ROOT_PASSWORD: authelia
MYSQL_DATABASE: authelia MYSQL_DATABASE: authelia
@ -180,9 +180,9 @@ services:
replicas: 1 replicas: 1
placement: placement:
constraints: constraints:
- node.hostname == ingress.nixc.us - node.hostname == ingress.a250.ca
labels: labels:
us.nixc.autodeploy: "true" us.a250.autodeploy: "true"
traefik.enable: "false" traefik.enable: "false"
# healthcheck: # healthcheck:
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "authelia", "-pauthelia"] # test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "authelia", "-pauthelia"]

View File

@ -1,9 +1,9 @@
x-authelia-env: &authelia-env x-authelia-env: &authelia-env
X_AUTHELIA_EMAIL: authelia@nixc.us X_AUTHELIA_EMAIL: authelia@a250.ca
X_AUTHELIA_SITE_NAME: ATLAS X_AUTHELIA_SITE_NAME: ATLAS
X_AUTHELIA_CONFIG_FILTERS: template X_AUTHELIA_CONFIG_FILTERS: template
X_AUTHELIA_LDAP_DOMAIN: dc=nixc,dc=us X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: nixc.us TRAEFIK_DOMAIN: a250.ca
networks: networks:
default: default:
@ -23,7 +23,7 @@ volumes:
services: services:
authelia: authelia:
image: git.nixc.us/nixius/authelia:staging-authelia image: git.nixc.us/a250/authelia:staging-authelia
command: command:
- authelia - authelia
- --config=/config/configuration.server.yml - --config=/config/configuration.server.yml
@ -48,18 +48,18 @@ services:
replicas: 1 replicas: 1
placement: placement:
constraints: constraints:
- node.hostname == ingress.nixc.us - node.hostname == ingress.a250.ca
labels: labels:
us.nixc.autodeploy: "true" us.a250.autodeploy: "true"
traefik.enable: "true" traefik.enable: "true"
traefik.docker.network: traefik traefik.docker.network: traefik
traefik.http.routers.staging-authelia_authelia.rule: Host(`staging.login.nixc.us`) traefik.http.routers.staging-authelia_authelia.rule: Host(`staging.login.a250.ca`)
traefik.http.routers.staging-authelia_authelia.entrypoints: websecure traefik.http.routers.staging-authelia_authelia.entrypoints: websecure
traefik.http.routers.staging-authelia_authelia.tls: "true" traefik.http.routers.staging-authelia_authelia.tls: "true"
traefik.http.routers.staging-authelia_authelia.tls.certresolver: letsencryptresolver traefik.http.routers.staging-authelia_authelia.tls.certresolver: letsencryptresolver
traefik.http.routers.staging-authelia_authelia.service: authelia_authelia traefik.http.routers.staging-authelia_authelia.service: authelia_authelia
traefik.http.services.staging-authelia_authelia.loadbalancer.server.port: 9091 traefik.http.services.staging-authelia_authelia.loadbalancer.server.port: 9091
traefik.http.middlewares.staging-authelia_authelia.forwardauth.address: http://authelia_authelia:9091/api/verify?rd=https://login.nixc.us/ traefik.http.middlewares.staging-authelia_authelia.forwardauth.address: http://authelia_authelia:9091/api/verify?rd=https://login.a250.ca/
traefik.http.middlewares.staging-authelia_authelia.forwardauth.trustForwardHeader: "true" traefik.http.middlewares.staging-authelia_authelia.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.staging-authelia_authelia.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email traefik.http.middlewares.staging-authelia_authelia.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
traefik.http.middlewares.staging-authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic traefik.http.middlewares.staging-authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic
@ -72,7 +72,7 @@ services:
max-file: "3" max-file: "3"
redis: redis:
image: git.nixc.us/nixius/authelia:staging-redis image: git.nixc.us/a250/authelia:staging-redis
command: redis-server --appendonly yes command: redis-server --appendonly yes
volumes: volumes:
- authelia_staging_redis_data:/data:rw - authelia_staging_redis_data:/data:rw
@ -88,9 +88,9 @@ services:
replicas: 1 replicas: 1
placement: placement:
constraints: constraints:
- node.hostname == ingress.nixc.us - node.hostname == ingress.a250.ca
labels: labels:
us.nixc.autodeploy: "true" us.a250.autodeploy: "true"
traefik.enable: "false" traefik.enable: "false"
logging: logging:
driver: json-file driver: json-file
@ -99,7 +99,7 @@ services:
max-file: "3" max-file: "3"
mariadb: mariadb:
image: git.nixc.us/nixius/authelia:staging-mariadb image: git.nixc.us/a250/authelia:staging-mariadb
environment: environment:
MYSQL_ROOT_PASSWORD: authelia MYSQL_ROOT_PASSWORD: authelia
MYSQL_DATABASE: authelia MYSQL_DATABASE: authelia
@ -119,9 +119,9 @@ services:
replicas: 1 replicas: 1
placement: placement:
constraints: constraints:
- node.hostname == ingress.nixc.us - node.hostname == ingress.a250.ca
labels: labels:
us.nixc.autodeploy: "true" us.a250.autodeploy: "true"
traefik.enable: "false" traefik.enable: "false"
logging: logging:
driver: json-file driver: json-file

View File

@ -72,7 +72,7 @@ echo " 3. Go to 'Users' section"
echo " 4. Click 'Create User'" echo " 4. Click 'Create User'"
echo " 5. Fill in details:" echo " 5. Fill in details:"
echo " - Username: testuser" echo " - Username: testuser"
echo " - Email: testuser@nixc.us" echo " - Email: testuser@a250.ca"
echo " - Display Name: Test User" echo " - Display Name: Test User"
echo " - Password: password123" echo " - Password: password123"
echo " 6. Click 'Create'" echo " 6. Click 'Create'"
@ -134,7 +134,7 @@ echo "======="
echo "• If login fails, check LLDAP user exists and password is correct" echo "• If login fails, check LLDAP user exists and password is correct"
echo "• Check Authelia ACL rules in docker/authelia/config/configuration.acl.yml" echo "• Check Authelia ACL rules in docker/authelia/config/configuration.acl.yml"
echo "• Use 'docker-compose -f docker-compose.dev.yml logs authelia' for debugging" echo "• Use 'docker-compose -f docker-compose.dev.yml logs authelia' for debugging"
echo "• LLDAP users need to be in the correct Base DN: dc=nixc,dc=us" echo "• LLDAP users need to be in the correct Base DN: dc=a250,dc=ca"
echo "" echo ""
echo -e "${GREEN}✅ Authentication testing environment ready!${NC}" echo -e "${GREEN}✅ Authentication testing environment ready!${NC}"