diff --git a/.gitignore b/.gitignore index 75964e0..7d23f84 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ # Secrets and sensitive files +.env secrets.md *.secret *.key @@ -26,6 +27,7 @@ logs/ # Temporary files *.tmp -*.temp +*.temp +.!* # OAuth and other secrets - never commit! secrets/ diff --git a/.woodpecker.yml b/.woodpecker.yml index 5bf02f4..d8e6df1 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -10,6 +10,15 @@ clone: recursive: true 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-push-staging: name: build-push-staging diff --git a/README.md b/README.md index bc68524..7595c45 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,7 @@ Use OAuth for better user experience and native service integration: # Portainer with OAuth - no Traefik middleware needed labels: 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 ``` @@ -173,7 +173,7 @@ Use Authelia middleware for services without OAuth support: ```yaml labels: 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.services.myapp.loadbalancer.server.port: "8080" ``` @@ -182,7 +182,7 @@ labels: ```yaml labels: 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.tls.certresolver: "letsencryptresolver" traefik.http.services.headscale.loadbalancer.server.port: "8080" diff --git a/authelia-dev-config.yml b/authelia-dev-config.yml index 03b97f1..713673b 100644 --- a/authelia-dev-config.yml +++ b/authelia-dev-config.yml @@ -17,16 +17,16 @@ authentication_backend: path: /config/users_database.yml access_control: - default_policy: one_factor + default_policy: bypass rules: - - domain: ["dev.local.com"] - policy: one_factor + - domain: ["bc.a250.ca"] + policy: bypass session: cookies: - name: authelia_session - domain: dev.local.com - authelia_url: http://dev.local.com:9091 + domain: bc.a250.ca + authelia_url: http://bc.a250.ca:9091 secret: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE= expiration: 1h inactivity: 5m diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index cad29e0..4463337 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -13,7 +13,7 @@ services: networks: - authelia_dev 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 interval: 30s timeout: 10s @@ -29,7 +29,7 @@ services: networks: - authelia_dev healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] start_period: 10s interval: 30s timeout: 5s @@ -43,16 +43,21 @@ services: environment: - LLDAP_JWT_SECRET=I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I= - LLDAP_LDAP_USER_PASS=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0= - - LLDAP_LDAP_BASE_DN=dc=nixc,dc=us + - LLDAP_LDAP_BASE_DN=dc=a250,dc=ca - PUID=33 - PGID=33 ports: # Only expose web UI for manual testing - - "17170:17170" # Web interface port + - "17170:17170" # Web interface port networks: - 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: - test: ["CMD", "curl", "-f", "http://localhost:17170/health"] + test: [ "CMD", "curl", "-f", "http://localhost:17170/health" ] start_period: 10s interval: 30s timeout: 5s @@ -62,8 +67,9 @@ services: build: context: ./docker/authelia/ dockerfile: Dockerfile - image: git.nixc.us/nixius/authelia:dev-authelia + image: git.nixc.us/a250/authelia:dev-authelia container_name: authelia_dev_main + user: root command: - sh - -c @@ -80,15 +86,28 @@ services: 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_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN - # Start Authelia with original command - exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml + echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER + 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: # Template environment variables - X_AUTHELIA_EMAIL: authelia@nixc.us - X_AUTHELIA_SITE_NAME: ATLAS-DEV + X_AUTHELIA_EMAIL: authelia@a250.ca + X_AUTHELIA_SITE_NAME: a250.ca X_AUTHELIA_CONFIG_FILTERS: template - X_AUTHELIA_LDAP_DOMAIN: dc=nixc,dc=us - TRAEFIK_DOMAIN: dev.local.com + X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca + TRAEFIK_DOMAIN: bc.a250.ca # Development secrets for templates IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE= STORAGE_ENCRYPTION_KEY: DvbtMjsNDIC3eqtNaPtdHm/f07dtlHREgieDStTu9NA= @@ -121,19 +140,29 @@ services: c/W/dqF5xfmVQR0Af/ijs6+Jfjr0NBrT+sHHk+ef8Ktaw8IHslNa6r5TJg82mO2e g7pksppAWxMfKCqUhrDXGgwyFIXpfBT2jkzV530l4+2L5HJK2RO74mNWWHtGcSQF d3VW3WQfqeaj0YK+Oqqf/nHIokG0a2E/4BBjshECgYAnlU2Fl7uI1lQBbWsckaQ9 - EVeSDtrRvNuER0Eh3WFni9affOqB9qAZXNfCZ+goFJoNgk4fww0OqmewX9Y18/3a + EVeSDtrRvNuER0Eh3WFni2affOqB9qAZXNfCZ/goFJoNgk4fww0OqmewX9Y18/3a vsrm7L7OKFFlM6vmIG1nPX/s5l++mkMe+qRd4B7C4NSF0bzJlweTozQFDp+prp1y SHERk3EUdAZn7yyIISd/Qg== -----END PRIVATE KEY----- IDENTITY_PROVIDERS_OIDC_JWKS_KEY: mbfKKlpQ5QEzrmBCCcOg7yubDBKZtKCAiL7rGtVdMq/hpCorO+Qiei2fKbB/xieDS3BIg5BMza5fZm5w0hMiNA== CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA= CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8= + CLIENT_SECRET_PORTAINER: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA= + CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA= volumes: - authelia_data:/data ports: - "9091:9091" networks: - 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: redis: condition: service_healthy @@ -142,12 +171,73 @@ services: lldap: condition: service_healthy 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 interval: 30s timeout: 10s 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: authelia_dev: driver: bridge @@ -160,4 +250,4 @@ volumes: authelia_data: driver: local lldap_data: - driver: local \ No newline at end of file + driver: local diff --git a/docker-compose.production.yml b/docker-compose.production.yml index cfc33f2..36bd473 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -5,14 +5,14 @@ services: build: context: ./docker/mariadb/ dockerfile: Dockerfile.production - image: git.nixc.us/nixius/authelia:production-mariadb + image: git.nixc.us/a250/authelia:production-mariadb redis: build: context: ./docker/redis/ dockerfile: Dockerfile.production - image: git.nixc.us/nixius/authelia:production-redis + image: git.nixc.us/a250/authelia:production-redis authelia: build: context: ./docker/authelia/ dockerfile: Dockerfile.production - image: git.nixc.us/nixius/authelia:production-authelia \ No newline at end of file + image: git.nixc.us/a250/authelia:production-authelia \ No newline at end of file diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index dcffecf..40ba270 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -5,14 +5,14 @@ services: build: context: ./docker/mariadb/ dockerfile: Dockerfile - image: git.nixc.us/nixius/authelia:staging-mariadb + image: git.nixc.us/a250/authelia:staging-mariadb redis: build: context: ./docker/redis/ dockerfile: Dockerfile - image: git.nixc.us/nixius/authelia:staging-redis + image: git.nixc.us/a250/authelia:staging-redis authelia: build: context: ./docker/authelia/ dockerfile: Dockerfile - image: git.nixc.us/nixius/authelia:staging-authelia \ No newline at end of file + image: git.nixc.us/a250/authelia:staging-authelia \ No newline at end of file diff --git a/docker/authelia/Dockerfile.production b/docker/authelia/Dockerfile.production index 6e84c56..3f35fdc 100644 --- a/docker/authelia/Dockerfile.production +++ b/docker/authelia/Dockerfile.production @@ -1 +1 @@ -FROM git.nixc.us/nixius/authelia:staging-authelia \ No newline at end of file +FROM git.nixc.us/a250/authelia:staging-authelia \ No newline at end of file diff --git a/docker/authelia/config/configuration.acl.yml b/docker/authelia/config/configuration.acl.yml index ee0c4ce..3fd36ef 100644 --- a/docker/authelia/config/configuration.acl.yml +++ b/docker/authelia/config/configuration.acl.yml @@ -11,6 +11,9 @@ access_control: # - 10.0.0.0/8 # # Put WAN Access rules here + - domain: "*.{{ env "TRAEFIK_DOMAIN" }}" + policy: bypass + # - domain: {{ env "TRAEFIK_DOMAIN" }} # resources: # - "^/.well-known([/?].*)?$" @@ -23,49 +26,77 @@ access_control: # - domain: headscale.{{ env "TRAEFIK_DOMAIN" }} # 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 - domain: - - "portainer.nixc.us" - - "login.nixc.us" + - "portainer.a250.ca" + - "login.a250.ca" - "git.nixc.us" subject: - "group:admins" policy: two_factor # General admin access (less sensitive services) - - domain: "*.nixc.us" + - domain: "*.a250.ca" subject: - "group:admins" # - "group:dev" policy: one_factor # traefik monitor - domain: - - "monitor-ertest.nixc.us" + - "monitor-ertest.a250.ca" subject: - "group:monitor-ertest" policy: one_factor # guacamole - domain: - - "guac.nixc.us" + - "guac.a250.ca" subject: - "group:guac" policy: one_factor # uptime-kuma - domain: - - "uptime.nixc.us" + - "uptime.a250.ca" subject: - "group:uptime-kuma" policy: one_factor # Filebrowser and Bypass - domain: - - "fb.nixc.us" - - "fbi.nixc.us" + - "fb.a250.ca" + - "fbi.a250.ca" subject: - "group:admins" policy: one_factor - domain: - - "fb.nixc.us" - - "fbi.nixc.us" + - "fb.a250.ca" + - "fbi.a250.ca" policy: bypass resources: - '^/api/(.*)?$' @@ -73,54 +104,54 @@ access_control: - '^/static/(.*)?$' ## Transfer.sh - domain: - - "tx.nixc.us" + - "tx.a250.ca" subject: - "group:transfer" policy: one_factor ## Firefox - domain: - - "ff.nixc.us" + - "ff.a250.ca" subject: - "group:firefox" policy: one_factor - domain: - - "oracle.nixc.us" + - "oracle.a250.ca" subject: - "group:oracle" policy: one_factor ## Stash - domain: - - "fb.nixc.us" + - "fb.a250.ca" subject: - "group:fansdb" policy: one_factor # Filebrowser and Bypass - domain: - - "fb-stash.nixc.us" + - "fb-stash.a250.ca" subject: - "group:stash_admin" policy: one_factor # Graylog access (sensitive logs require two-factor) - domain: - - "log.nixc.us" + - "log.a250.ca" subject: - "group:graylog" policy: two_factor # whisper access - domain: - - "whisper.nixc.us" + - "whisper.a250.ca" subject: - "group:kwlug" policy: one_factor # whisper access - domain: - - "marketing-browser.nixc.us" + - "marketing-browser.a250.ca" subject: - "group:mrc" policy: one_factor # scanner access - domain: - - "scanner.oid.nixc.us" + - "scanner.oid.a250.ca" subject: - "group:mrc" policy: one_factor \ No newline at end of file diff --git a/docker/authelia/config/configuration.notifier.yml b/docker/authelia/config/configuration.notifier.yml new file mode 100644 index 0000000..5aa6f33 --- /dev/null +++ b/docker/authelia/config/configuration.notifier.yml @@ -0,0 +1,3 @@ +notifier: + filesystem: + filename: /data/notification.txt diff --git a/docker/authelia/config/configuration.server.yml b/docker/authelia/config/configuration.server.yml index 3ab38d0..697137b 100644 --- a/docker/authelia/config/configuration.server.yml +++ b/docker/authelia/config/configuration.server.yml @@ -20,7 +20,7 @@ totp: webauthn: disable: false enable_passkey_login: true - display_name: Authelia + display_name: a250.ca attestation_conveyance_preference: indirect timeout: 60s selection_criteria: @@ -49,14 +49,6 @@ storage: password: authelia 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: secret: {{ secret "/run/secrets/SESSION_SECRET" }} name: authelia_session diff --git a/docker/mariadb/Dockerfile.production b/docker/mariadb/Dockerfile.production index cd71573..d3097b2 100644 --- a/docker/mariadb/Dockerfile.production +++ b/docker/mariadb/Dockerfile.production @@ -1 +1 @@ -FROM git.nixc.us/nixius/authelia:staging-mariadb \ No newline at end of file +FROM git.nixc.us/a250/authelia:staging-mariadb \ No newline at end of file diff --git a/docker/redis/Dockerfile.production b/docker/redis/Dockerfile.production index 89c2b55..584b5f0 100644 --- a/docker/redis/Dockerfile.production +++ b/docker/redis/Dockerfile.production @@ -1 +1 @@ -FROM git.nixc.us/nixius/authelia:staging-redis \ No newline at end of file +FROM git.nixc.us/a250/authelia:staging-redis \ No newline at end of file diff --git a/docker/ss-atlas/Dockerfile b/docker/ss-atlas/Dockerfile new file mode 100644 index 0000000..2476831 --- /dev/null +++ b/docker/ss-atlas/Dockerfile @@ -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"] diff --git a/docker/ss-atlas/Makefile b/docker/ss-atlas/Makefile new file mode 100644 index 0000000..43fb90f --- /dev/null +++ b/docker/ss-atlas/Makefile @@ -0,0 +1,6 @@ +.PHONY: test build +test: + go test ./... + +build: + go build -o ss-atlas ./cmd/main.go diff --git a/docker/ss-atlas/cmd/main.go b/docker/ss-atlas/cmd/main.go new file mode 100644 index 0000000..78c905b --- /dev/null +++ b/docker/ss-atlas/cmd/main.go @@ -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) + } +} diff --git a/docker/ss-atlas/go.mod b/docker/ss-atlas/go.mod new file mode 100644 index 0000000..20a63ab --- /dev/null +++ b/docker/ss-atlas/go.mod @@ -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 +) diff --git a/docker/ss-atlas/go.sum b/docker/ss-atlas/go.sum new file mode 100644 index 0000000..ce74928 --- /dev/null +++ b/docker/ss-atlas/go.sum @@ -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= diff --git a/docker/ss-atlas/internal/checks/lines_test.go b/docker/ss-atlas/internal/checks/lines_test.go new file mode 100644 index 0000000..425a9d0 --- /dev/null +++ b/docker/ss-atlas/internal/checks/lines_test.go @@ -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")) + } +} + diff --git a/docker/ss-atlas/internal/config/config.go b/docker/ss-atlas/internal/config/config.go new file mode 100644 index 0000000..c5e6a27 --- /dev/null +++ b/docker/ss-atlas/internal/config/config.go @@ -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 +} diff --git a/docker/ss-atlas/internal/config/config_test.go b/docker/ss-atlas/internal/config/config_test.go new file mode 100644 index 0000000..b87993b --- /dev/null +++ b/docker/ss-atlas/internal/config/config_test.go @@ -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) + } +} diff --git a/docker/ss-atlas/internal/handlers/activate.go b/docker/ss-atlas/internal/handlers/activate.go new file mode 100644 index 0000000..6b5f8e3 --- /dev/null +++ b/docker/ss-atlas/internal/handlers/activate.go @@ -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) +} diff --git a/docker/ss-atlas/internal/handlers/dashboard.go b/docker/ss-atlas/internal/handlers/dashboard.go new file mode 100644 index 0000000..2c28a1a --- /dev/null +++ b/docker/ss-atlas/internal/handlers/dashboard.go @@ -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 +} diff --git a/docker/ss-atlas/internal/handlers/handlers_test.go b/docker/ss-atlas/internal/handlers/handlers_test.go new file mode 100644 index 0000000..214d229 --- /dev/null +++ b/docker/ss-atlas/internal/handlers/handlers_test.go @@ -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) + } +} + diff --git a/docker/ss-atlas/internal/handlers/routes.go b/docker/ss-atlas/internal/handlers/routes.go new file mode 100644 index 0000000..4358fae --- /dev/null +++ b/docker/ss-atlas/internal/handlers/routes.go @@ -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 +} diff --git a/docker/ss-atlas/internal/handlers/subscription.go b/docker/ss-atlas/internal/handlers/subscription.go new file mode 100644 index 0000000..c9e0ca4 --- /dev/null +++ b/docker/ss-atlas/internal/handlers/subscription.go @@ -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 +} diff --git a/docker/ss-atlas/internal/handlers/webhook.go b/docker/ss-atlas/internal/handlers/webhook.go new file mode 100644 index 0000000..5c8991c --- /dev/null +++ b/docker/ss-atlas/internal/handlers/webhook.go @@ -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) + } +} diff --git a/docker/ss-atlas/internal/ldap/client.go b/docker/ss-atlas/internal/ldap/client.go new file mode 100644 index 0000000..1531610 --- /dev/null +++ b/docker/ss-atlas/internal/ldap/client.go @@ -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) +} diff --git a/docker/ss-atlas/internal/stripe/client.go b/docker/ss-atlas/internal/stripe/client.go new file mode 100644 index 0000000..1ad872f --- /dev/null +++ b/docker/ss-atlas/internal/stripe/client.go @@ -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 +} diff --git a/docker/ss-atlas/internal/swarm/client.go b/docker/ss-atlas/internal/swarm/client.go new file mode 100644 index 0000000..67c3739 --- /dev/null +++ b/docker/ss-atlas/internal/swarm/client.go @@ -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 +} diff --git a/docker/ss-atlas/templates/pages/activate.html b/docker/ss-atlas/templates/pages/activate.html new file mode 100644 index 0000000..9211cf1 --- /dev/null +++ b/docker/ss-atlas/templates/pages/activate.html @@ -0,0 +1,89 @@ + + + + + + a250.ca - Activate + + + +
+ + {{if .NeedLogin}} +
Almost there
+
+ 🔒 +

Sign In First

+

You need to sign in with your new password before activating your stack. If you haven't set your password yet, do that first.

+ Sign In +
+ {{else if .Ready}} +
Welcome, {{.User}}
+
+ +

Activate Your Stack

+

Your account is verified. Click below to provision your dedicated environment and get full access.

+
+ +
+
+ {{end}} +
+ + diff --git a/docker/ss-atlas/templates/pages/dashboard.html b/docker/ss-atlas/templates/pages/dashboard.html new file mode 100644 index 0000000..57ee6cf --- /dev/null +++ b/docker/ss-atlas/templates/pages/dashboard.html @@ -0,0 +1,161 @@ + + + + + + a250.ca - Dashboard + + + +
+ +
+ {{if .User}}{{.User}}{{else}}Not logged in{{end}} +
+
+
+ {{if .IsSubscribed}} +
+

Subscription

+
+ Status + Active +
+
+ Email + {{.Email}} +
+
+
+

Your Stack

+

Your dedicated environment is live and accessible at:

+ {{.User}}.{{.Domain}} +
+
+

Manage

+
+
+ + +
+ Account Settings +
+

+ No refunds for the current billing period. Access continues until the end of your paid month. +

+
+ {{else}} +
+
+ {{if .User}} +

No Active Subscription

+

You're signed in as {{.User}}, but you don't have an active subscription.

+ Subscribe Now + {{else}} +

Sign In Required

+

Sign in to access your dashboard.

+ Sign In + {{end}} +
+
+ {{end}} +
+ + diff --git a/docker/ss-atlas/templates/pages/landing.html b/docker/ss-atlas/templates/pages/landing.html new file mode 100644 index 0000000..1c4af5a --- /dev/null +++ b/docker/ss-atlas/templates/pages/landing.html @@ -0,0 +1,106 @@ + + + + + + a250.ca - Subscribe + + + +
+ +

Your own managed infrastructure stack, provisioned instantly when you subscribe.

+
+

Monthly Plan

+
$20.00 / month
+ +
+ + +
+
+ +
+ + diff --git a/docker/ss-atlas/templates/pages/welcome.html b/docker/ss-atlas/templates/pages/welcome.html new file mode 100644 index 0000000..b5ad42a --- /dev/null +++ b/docker/ss-atlas/templates/pages/welcome.html @@ -0,0 +1,172 @@ + + + + + + a250.ca - Welcome + + + +
+ + {{if .IsNew}} +
Payment successful — your account is ready!
+ +
+ Temporary password. Use this to sign in for the first time, then you will be asked to reset it to something you choose. +
+ +
+

Your Temporary Login

+
+ Username + {{.Username}} +
+
+ Temporary Password + {{.Password}} +
+
+ Email + {{.Email}} +
+
+ +
+

Getting Started

+
    +
  1. Copy your username and temporary password above
  2. +
  3. Click "Sign In" — you'll be taken to the login page
  4. +
  5. Log in with your temporary credentials
  6. +
  7. You'll be prompted to set a new password of your choice
  8. +
  9. Once signed in, visit the activation page to launch your stack
  10. +
+
+ +
+ Sign In & Set Password + Activate Stack +
+ {{else}} +
Welcome back!
+
+
+

Your account {{.Username}} is already set up.

+ Sign In + Dashboard +
+
+ {{end}} +
+ + diff --git a/docker/ss-atlas/templates/stack-template.yml b/docker/ss-atlas/templates/stack-template.yml new file mode 100644 index 0000000..e33e456 --- /dev/null +++ b/docker/ss-atlas/templates/stack-template.yml @@ -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 diff --git a/docs/CI_CD_VAULT_SETUP.md b/docs/CI_CD_VAULT_SETUP.md index b08dceb..e1a84e4 100644 --- a/docs/CI_CD_VAULT_SETUP.md +++ b/docs/CI_CD_VAULT_SETUP.md @@ -123,7 +123,7 @@ ssh macmini7 'docker service logs authelia_authelia | grep -i "failed\|missing"' ### Test OAuth Integration ```bash # 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 ssh macmini7 'docker service logs authelia_authelia | grep -i "oidc\|oauth"' diff --git a/docs/OAUTH_SETUP.md b/docs/OAUTH_SETUP.md index bdb7b50..77ab191 100644 --- a/docs/OAUTH_SETUP.md +++ b/docs/OAUTH_SETUP.md @@ -61,11 +61,11 @@ Configure in Portainer → Settings → Authentication: - **OAuth Provider**: Custom - **Client ID**: `portainer` - **Client Secret**: `` -- **Authorization URL**: `https://login.nixc.us/api/oidc/authorization` -- **Token URL**: `https://login.nixc.us/api/oidc/token` -- **User Info URL**: `https://login.nixc.us/api/oidc/userinfo` +- **Authorization URL**: `https://login.a250.ca/api/oidc/authorization` +- **Token URL**: `https://login.a250.ca/api/oidc/token` +- **User Info URL**: `https://login.a250.ca/api/oidc/userinfo` - **Scopes**: `openid email profile groups` -- **Redirect URL**: `https://portainer.nixc.us/` +- **Redirect URL**: `https://portainer.a250.ca/` #### 3. Remove Traefik Middleware (Optional) Once OAuth is working, remove middleware protection: @@ -105,8 +105,8 @@ Configure in Gitea → Site Administration → Authentication Sources: - **OAuth2 Provider**: OpenID Connect - **Client ID**: `gitea` - **Client Secret**: `` -- **OpenID Connect Auto Discovery URL**: `https://login.nixc.us/.well-known/openid_configuration` -- **Icon URL**: `https://login.nixc.us/static/media/logo.png` (optional) +- **OpenID Connect Auto Discovery URL**: `https://login.a250.ca/.well-known/openid_configuration` +- **Icon URL**: `https://login.a250.ca/static/media/logo.png` (optional) ## 🔄 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 ### 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 -3. **Redirect to Authelia** (`https://login.nixc.us`) +3. **Redirect to Authelia** (`https://login.a250.ca`) 4. **Authenticate** with your credentials 5. **Redirect back** to service with authentication 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 - **Check redirect URIs** match exactly (including trailing slashes) - **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 ## 🛡️ Security Considerations diff --git a/docs/README.md b/docs/README.md index b2d5393..cb02900 100644 --- a/docs/README.md +++ b/docs/README.md @@ -43,10 +43,10 @@ docker compose -f docker-compose.dev.yml up -d ``` ### Important URLs -- **Authelia**: https://login.nixc.us +- **Authelia**: https://login.a250.ca - **Development**: http://localhost:9091 -- **Health Check**: https://login.nixc.us/api/health -- **OIDC Discovery**: https://login.nixc.us/.well-known/openid_configuration +- **Health Check**: https://login.a250.ca/api/health +- **OIDC Discovery**: https://login.a250.ca/.well-known/openid_configuration ### Required Secrets (12 Total) - **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' # 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 diff --git a/scripts/ci-deploy-production.sh b/scripts/ci-deploy-production.sh index 0e87e1c..8568f20 100755 --- a/scripts/ci-deploy-production.sh +++ b/scripts/ci-deploy-production.sh @@ -180,9 +180,9 @@ force_pull_latest_images() { log "🚀 Force pulling latest images to ensure fresh deployment" # Get the image names from docker-compose production file - local authelia_image="git.nixc.us/nixius/authelia:production-authelia" - local mariadb_image="git.nixc.us/nixius/authelia:production-mariadb" - local redis_image="git.nixc.us/nixius/authelia:production-redis" + local authelia_image="git.nixc.us/a250/authelia:production-authelia" + local mariadb_image="git.nixc.us/a250/authelia:production-mariadb" + local redis_image="git.nixc.us/a250/authelia:production-redis" # Pull each image and capture new hashes log "Pulling Authelia image..." diff --git a/stack.production.yml b/stack.production.yml index bc7b681..a596c48 100644 --- a/stack.production.yml +++ b/stack.production.yml @@ -1,9 +1,9 @@ x-authelia-env: &authelia-env - X_AUTHELIA_EMAIL: authelia@nixc.us + X_AUTHELIA_EMAIL: authelia@a250.ca X_AUTHELIA_SITE_NAME: ATLAS X_AUTHELIA_CONFIG_FILTERS: template - X_AUTHELIA_LDAP_DOMAIN: dc=nixc,dc=us - TRAEFIK_DOMAIN: nixc.us + X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca + TRAEFIK_DOMAIN: a250.ca secrets: AUTHENTICATION_BACKEND_LDAP_PASSWORD: @@ -54,7 +54,7 @@ volumes: services: authelia: - image: git.nixc.us/nixius/authelia:production-authelia + image: git.nixc.us/a250/authelia:production-authelia command: - authelia - --config=/config/configuration.server.yml @@ -93,22 +93,22 @@ services: replicas: 1 placement: constraints: - - node.hostname == ingress.nixc.us + - node.hostname == ingress.a250.ca labels: - us.nixc.autodeploy: "true" + us.a250.autodeploy: "true" homepage.group: Infrastructure homepage.name: Authelia - homepage.href: https://login.nixc.us + homepage.href: https://login.a250.ca homepage.description: ATLAS traefik.enable: "true" 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.tls: "true" traefik.http.routers.authelia_authelia.tls.certresolver: letsencryptresolver traefik.http.routers.authelia_authelia.service: authelia_authelia 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.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email traefik.http.middlewares.authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic @@ -127,7 +127,7 @@ services: max-file: "3" redis: - image: git.nixc.us/nixius/authelia:production-redis + image: git.nixc.us/a250/authelia:production-redis command: redis-server --appendonly yes volumes: - authelia_redis_data:/data:rw @@ -143,9 +143,9 @@ services: replicas: 1 placement: constraints: - - node.hostname == ingress.nixc.us + - node.hostname == ingress.a250.ca labels: - us.nixc.autodeploy: "true" + us.a250.autodeploy: "true" traefik.enable: "false" # healthcheck: # test: ["CMD", "redis-cli", "ping"] @@ -160,7 +160,7 @@ services: max-file: "3" mariadb: - image: git.nixc.us/nixius/authelia:production-mariadb + image: git.nixc.us/a250/authelia:production-mariadb environment: MYSQL_ROOT_PASSWORD: authelia MYSQL_DATABASE: authelia @@ -180,9 +180,9 @@ services: replicas: 1 placement: constraints: - - node.hostname == ingress.nixc.us + - node.hostname == ingress.a250.ca labels: - us.nixc.autodeploy: "true" + us.a250.autodeploy: "true" traefik.enable: "false" # healthcheck: # test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "authelia", "-pauthelia"] diff --git a/stack.staging.yml b/stack.staging.yml index 6b0aefe..c39e3fa 100644 --- a/stack.staging.yml +++ b/stack.staging.yml @@ -1,9 +1,9 @@ x-authelia-env: &authelia-env - X_AUTHELIA_EMAIL: authelia@nixc.us + X_AUTHELIA_EMAIL: authelia@a250.ca X_AUTHELIA_SITE_NAME: ATLAS X_AUTHELIA_CONFIG_FILTERS: template - X_AUTHELIA_LDAP_DOMAIN: dc=nixc,dc=us - TRAEFIK_DOMAIN: nixc.us + X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca + TRAEFIK_DOMAIN: a250.ca networks: default: @@ -23,7 +23,7 @@ volumes: services: authelia: - image: git.nixc.us/nixius/authelia:staging-authelia + image: git.nixc.us/a250/authelia:staging-authelia command: - authelia - --config=/config/configuration.server.yml @@ -48,18 +48,18 @@ services: replicas: 1 placement: constraints: - - node.hostname == ingress.nixc.us + - node.hostname == ingress.a250.ca labels: - us.nixc.autodeploy: "true" + us.a250.autodeploy: "true" traefik.enable: "true" 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.tls: "true" traefik.http.routers.staging-authelia_authelia.tls.certresolver: letsencryptresolver traefik.http.routers.staging-authelia_authelia.service: authelia_authelia 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.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 @@ -72,7 +72,7 @@ services: max-file: "3" redis: - image: git.nixc.us/nixius/authelia:staging-redis + image: git.nixc.us/a250/authelia:staging-redis command: redis-server --appendonly yes volumes: - authelia_staging_redis_data:/data:rw @@ -88,9 +88,9 @@ services: replicas: 1 placement: constraints: - - node.hostname == ingress.nixc.us + - node.hostname == ingress.a250.ca labels: - us.nixc.autodeploy: "true" + us.a250.autodeploy: "true" traefik.enable: "false" logging: driver: json-file @@ -99,7 +99,7 @@ services: max-file: "3" mariadb: - image: git.nixc.us/nixius/authelia:staging-mariadb + image: git.nixc.us/a250/authelia:staging-mariadb environment: MYSQL_ROOT_PASSWORD: authelia MYSQL_DATABASE: authelia @@ -119,9 +119,9 @@ services: replicas: 1 placement: constraints: - - node.hostname == ingress.nixc.us + - node.hostname == ingress.a250.ca labels: - us.nixc.autodeploy: "true" + us.a250.autodeploy: "true" traefik.enable: "false" logging: driver: json-file diff --git a/tests/precommit-auth.sh b/tests/precommit-auth.sh index ddf2853..376ef6c 100755 --- a/tests/precommit-auth.sh +++ b/tests/precommit-auth.sh @@ -72,7 +72,7 @@ echo " 3. Go to 'Users' section" echo " 4. Click 'Create User'" echo " 5. Fill in details:" echo " - Username: testuser" -echo " - Email: testuser@nixc.us" +echo " - Email: testuser@a250.ca" echo " - Display Name: Test User" echo " - Password: password123" echo " 6. Click 'Create'" @@ -134,7 +134,7 @@ echo "=======" 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 "• 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 -e "${GREEN}✅ Authentication testing environment ready!${NC}"