forked from Nixius/authelia
1
0
Fork 0

Compare commits

...

5 Commits

Author SHA1 Message Date
Leopere a92cbe9b72
Add header to stack-template, force-update images on deploy
- stack-template.yml: prominent comment explaining this is the product
  being sold and how to swap in the real application image
- deploy-stack-dev.sh: force-update locally-built images after stack
  deploy so swarm always runs the freshly built container

Made-with: Cursor
2026-03-03 16:48:03 -05:00
Leopere 74a24ffe2a
Fix subscription status not showing Expiring for cancel_at_period_end subs
- Also check sub.CancelAt > 0 (handles explicit cancel_at date, not just period-end)
- Fall back to item CurrentPeriodEnd for the display date since current_period_end
  moved off the top-level Subscription object in stripe-go v84

Made-with: Cursor
2026-03-03 16:00:24 -05:00
Leopere 159a0b4455
Remove Rebuild button from dashboard UI
Made-with: Cursor
2026-03-03 15:55:21 -05:00
Leopere b66dfa053e
Force auth on all customer stacks, migrate to swarm stack.yml
- Remove public/private toggle — all customer stacks now always deploy
  behind authelia-auth middleware, no exceptions
- Remove ALLOW_CUSTOMER_STACK_AUTH_TOGGLE and CUSTOMER_STACK_REQUIRE_AUTH_DEFAULT
  config, env vars, routes, and UI
- Replace docker-compose.dev.yml + docker-compose.swarm-dev.yml with
  unified stack.yml for swarm deployment
- Various handler, ldap, stripe, swarm, and template additions from
  prior work sessions

Made-with: Cursor
2026-03-03 15:51:25 -05:00
Leopere 6fcdd1262d
Bake git commit into Go binary for version traceability
- Add internal/version package with ldflags-injected Commit/BuildTime
- Dockerfile accepts BUILD_COMMIT/BUILD_TIME args, passes via -ldflags
- Log version on startup, expose GET /version endpoint
- Show commit hash badge in bottom-right of landing + dashboard pages
- Deploy scripts gate on clean git tree and pass commit to build
- Remove staging files, misc config updates

Made-with: Cursor
2026-03-03 12:51:12 -05:00
36 changed files with 1155 additions and 492 deletions

5
.cursorignore Normal file
View File

@ -0,0 +1,5 @@
# Authelia stable/done; keep out of context for ss-atlas and other work
docker/authelia/
authelia-dev-config.yml
docker/mariadb/
docker/redis/

View File

@ -19,109 +19,6 @@ steps:
when: when:
event: push event: push
# Build and Push for Staging
build-push-staging:
name: build-push-staging
image: woodpeckerci/plugin-docker-buildx
environment:
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASSWORD:
from_secret: REGISTRY_PASSWORD
DOCKER_REGISTRY_USER:
from_secret: DOCKER_REGISTRY_USER
DOCKER_REGISTRY_PASSWORD:
from_secret: DOCKER_REGISTRY_PASSWORD
# Authelia Core Secrets
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
from_secret: AUTHENTICATION_BACKEND_LDAP_PASSWORD
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
from_secret: IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
STORAGE_ENCRYPTION_KEY:
from_secret: STORAGE_ENCRYPTION_KEY
SESSION_SECRET:
from_secret: SESSION_SECRET
NOTIFIER_SMTP_PASSWORD:
from_secret: NOTIFIER_SMTP_PASSWORD
# OIDC Secrets
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
from_secret: IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_JWKS_KEY
# OAuth Client Secrets removed - handled by Docker Swarm secrets at runtime
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- echo "Logging into registries"
- echo "$${DOCKER_REGISTRY_PASSWORD}" | docker login -u "$${DOCKER_REGISTRY_USER}" --password-stdin
- echo "$${REGISTRY_PASSWORD}" | docker login -u "$${REGISTRY_USER}" --password-stdin git.nixc.us
- echo "Building and pushing application for staging"
- docker compose -f docker-compose.staging.yml build --no-cache
- docker compose -f docker-compose.staging.yml push
when:
branch: main
event: push
# Deploy Staging
deploy-staging:
name: deploy-staging
image: woodpeckerci/plugin-docker-buildx
environment:
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASSWORD:
from_secret: REGISTRY_PASSWORD
# Authelia Core Secrets
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
from_secret: AUTHENTICATION_BACKEND_LDAP_PASSWORD
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
from_secret: IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
STORAGE_ENCRYPTION_KEY:
from_secret: STORAGE_ENCRYPTION_KEY
SESSION_SECRET:
from_secret: SESSION_SECRET
NOTIFIER_SMTP_PASSWORD:
from_secret: NOTIFIER_SMTP_PASSWORD
# OIDC Secrets
IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
from_secret: IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
from_secret: IDENTITY_PROVIDERS_OIDC_JWKS_KEY
# OAuth Client Secrets removed - handled by Docker Swarm secrets at runtime
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- echo "Deploying to staging environment"
- echo "$${REGISTRY_PASSWORD}" | docker login -u "$${REGISTRY_USER}" --password-stdin git.nixc.us
- docker stack deploy --with-registry-auth -c ./stack.staging.yml $${CI_REPO_NAME}-staging
when:
branch: main
event: push
# Cleanup Staging
cleanup-staging:
name: cleanup-staging
image: woodpeckerci/plugin-docker-buildx
environment:
REGISTRY_USER:
from_secret: REGISTRY_USER
REGISTRY_PASSWORD:
from_secret: REGISTRY_PASSWORD
volumes:
- /var/run/docker.sock:/var/run/docker.sock
commands:
- echo "Cleaning up staging environment"
- for i in {1..5}; do docker stack rm ${CI_REPO_NAME}-staging && break || sleep 10; done
- docker compose -f docker-compose.staging.yml down
- docker compose -f docker-compose.staging.yml rm -f
when:
branch: main
event: push
# Build and Push for Production # Build and Push for Production
build-push-production: build-push-production:
name: build-push-production name: build-push-production

View File

@ -1,18 +0,0 @@
version: '3.8'
services:
mariadb:
build:
context: ./docker/mariadb/
dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:staging-mariadb
redis:
build:
context: ./docker/redis/
dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:staging-redis
authelia:
build:
context: ./docker/authelia/
dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:staging-authelia

15
docker-compose.yml Normal file
View File

@ -0,0 +1,15 @@
services:
authelia:
build:
context: ./docker/authelia/
dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:dev-authelia
ss-atlas:
build:
context: ./docker/ss-atlas/
dockerfile: Dockerfile
args:
BUILD_COMMIT: ${BUILD_COMMIT:-unknown}
BUILD_TIME: ${BUILD_TIME:-unknown}
image: atlas-ss-atlas:latest

View File

@ -1 +1,11 @@
FROM git.nixc.us/a250/authelia:staging-authelia FROM authelia/authelia:4
COPY config/ /config/
RUN mkdir -p /config/assets
CMD ["authelia", \
"--config=/config/configuration.server.yml", \
"--config=/config/configuration.ldap.yml", \
"--config=/config/configuration.acl.yml", \
"--config=/config/configuration.notifier.yml"]

View File

@ -32,6 +32,12 @@ access_control:
- "group:customers" - "group:customers"
policy: one_factor policy: one_factor
# Customer demo subdomains (e.g. clientname.app.a250.ca)
- domain_regex: '^[a-z0-9-]+\.app\.a250\.ca$'
subject:
- "group:customers"
policy: one_factor
# ss-atlas app public routes (landing, webhook) # ss-atlas app public routes (landing, webhook)
- domain: 'app.{{ env "TRAEFIK_DOMAIN" }}' - domain: 'app.{{ env "TRAEFIK_DOMAIN" }}'
policy: bypass policy: bypass

View File

@ -4,7 +4,7 @@ authentication_backend:
refresh_interval: 5m refresh_interval: 5m
ldap: ldap:
implementation: custom implementation: custom
address: ldap://lldap_lldap:3890 address: ldap://lldap:3890
timeout: 5s timeout: 5s
start_tls: false start_tls: false
tls: tls:

View File

@ -1,3 +1,9 @@
notifier: notifier:
filesystem: smtp:
filename: /data/notification.txt address: 'submission://box.p.nixc.us:587'
username: 'auth@a250.ca'
password: 'u3TBhzc73u4X5qJzzQ6xkwwdmJLnVxTLXbOi8o090kC8pw5wQYplqeivBclQlYAS'
sender: 'a250.ca <auth@a250.ca>'
subject: '[a250.ca] {title}'
disable_require_tls: false
disable_html_emails: false

View File

@ -42,7 +42,7 @@ storage:
# local: # local:
# path: /config/db.sqlite3 # path: /config/db.sqlite3
mysql: mysql:
address: 'tcp://authelia_mariadb:3306' address: 'tcp://mariadb:3306'
database: authelia database: authelia
username: authelia username: authelia
## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html ## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
@ -59,6 +59,7 @@ session:
cookies: cookies:
- domain: {{ env "TRAEFIK_DOMAIN" }} - domain: {{ env "TRAEFIK_DOMAIN" }}
authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}' authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}'
default_redirection_url: 'https://app.{{ env "TRAEFIK_DOMAIN" }}/dashboard'
name: 'authelia_session' name: 'authelia_session'
same_site: 'lax' same_site: 'lax'
inactivity: '5m' inactivity: '5m'
@ -66,7 +67,7 @@ session:
remember_me: '1d' remember_me: '1d'
redis: redis:
host: 'authelia_redis' host: 'redis'
port: 6379 port: 6379
database_index: 0 database_index: 0
maximum_active_connections: 8 maximum_active_connections: 8

View File

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

View File

@ -1 +1,3 @@
FROM git.nixc.us/a250/authelia:staging-redis FROM redis:latest
CMD ["redis-server", "--appendonly", "yes"]

View File

@ -1,14 +1,19 @@
FROM golang:1.23-alpine AS builder FROM golang:1.23-alpine AS builder
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
WORKDIR /build WORKDIR /build
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /ss-atlas ./cmd/ RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags "-X git.nixc.us/a250/ss-atlas/internal/version.Commit=${BUILD_COMMIT} -X git.nixc.us/a250/ss-atlas/internal/version.BuildTime=${BUILD_TIME}" \
-o /ss-atlas ./cmd/
FROM alpine:3.21 FROM alpine:3.21
RUN apk add --no-cache ca-certificates RUN apk add --no-cache ca-certificates docker-cli
WORKDIR /app WORKDIR /app
COPY --from=builder /ss-atlas /app/ss-atlas COPY --from=builder /ss-atlas /app/ss-atlas
COPY templates/ /app/templates/ COPY templates/ /app/templates/

View File

@ -14,9 +14,11 @@ import (
"git.nixc.us/a250/ss-atlas/internal/ldap" "git.nixc.us/a250/ss-atlas/internal/ldap"
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe" ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/swarm" "git.nixc.us/a250/ss-atlas/internal/swarm"
"git.nixc.us/a250/ss-atlas/internal/version"
) )
func main() { func main() {
log.Printf("ss-atlas %s", version.String())
cfg := config.Load() cfg := config.Load()
stripeClient := ssstripe.New(cfg) stripeClient := ssstripe.New(cfg)

View File

@ -6,6 +6,7 @@ type Config struct {
Port string Port string
AppURL string AppURL string
AutheliaURL string AutheliaURL string
AutheliaInternalURL string
StripeSecretKey string StripeSecretKey string
StripeWebhookSecret string StripeWebhookSecret string
StripePriceID string StripePriceID string
@ -13,10 +14,12 @@ type Config struct {
LDAPAdminDN string LDAPAdminDN string
LDAPAdminPassword string LDAPAdminPassword string
LDAPBaseDN string LDAPBaseDN string
LLDAPHttpURL string
DockerHost string DockerHost string
TraefikDomain string TraefikDomain string
TraefikNetwork string TraefikNetwork string
TemplatePath string TemplatePath string
CustomerDomain string
} }
func Load() *Config { func Load() *Config {
@ -24,6 +27,7 @@ func Load() *Config {
Port: envOrDefault("PORT", "8080"), Port: envOrDefault("PORT", "8080"),
AppURL: envOrDefault("APP_URL", "http://app.bc.a250.ca"), AppURL: envOrDefault("APP_URL", "http://app.bc.a250.ca"),
AutheliaURL: envOrDefault("AUTHELIA_URL", "http://login.bc.a250.ca"), AutheliaURL: envOrDefault("AUTHELIA_URL", "http://login.bc.a250.ca"),
AutheliaInternalURL: envOrDefault("AUTHELIA_INTERNAL_URL", "http://authelia_dev_main:9091"),
StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""), StripeSecretKey: envOrDefault("STRIPE_SECRET_KEY", ""),
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""), StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""), StripePriceID: envOrDefault("STRIPE_PRICE_ID", ""),
@ -31,10 +35,12 @@ func Load() *Config {
LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"), LDAPAdminDN: envOrDefault("LLDAP_ADMIN_DN", "uid=admin,ou=people,dc=a250,dc=ca"),
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""), LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"), LDAPBaseDN: envOrDefault("LLDAP_BASE_DN", "dc=a250,dc=ca"),
LLDAPHttpURL: envOrDefault("LLDAP_HTTP_URL", "http://lldap:17170"),
DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"), DockerHost: envOrDefault("DOCKER_HOST", "unix:///var/run/docker.sock"),
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"), TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"), TraefikNetwork: envOrDefault("TRAEFIK_NETWORK", "authelia_dev"),
TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"), TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"),
CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "app.a250.ca"),
} }
} }
@ -44,3 +50,4 @@ func envOrDefault(key, fallback string) string {
} }
return fallback return fallback
} }

View File

@ -29,7 +29,7 @@ func TestLoadDefaults(t *testing.T) {
"PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY", "PORT", "APP_URL", "AUTHELIA_URL", "STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "LLDAP_URL", "STRIPE_WEBHOOK_SECRET", "STRIPE_PRICE_ID", "LLDAP_URL",
"LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN", "LLDAP_ADMIN_DN", "LLDAP_ADMIN_PASSWORD", "LLDAP_BASE_DN",
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
} }
for _, k := range envKeys { for _, k := range envKeys {
os.Unsetenv(k) os.Unsetenv(k)
@ -54,9 +54,10 @@ func TestLoadDefaults(t *testing.T) {
{"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"}, {"LDAPAdminDN", cfg.LDAPAdminDN, "uid=admin,ou=people,dc=a250,dc=ca"},
{"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"}, {"LDAPBaseDN", cfg.LDAPBaseDN, "dc=a250,dc=ca"},
{"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"}, {"DockerHost", cfg.DockerHost, "unix:///var/run/docker.sock"},
{"TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"}, { "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"}, {"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"},
{"TemplatePath", cfg.TemplatePath, "/app/templates"}, {"TemplatePath", cfg.TemplatePath, "/app/templates"},
{"CustomerDomain", cfg.CustomerDomain, "app.a250.ca"},
} }
for _, tt := range tests { for _, tt := range tests {
if tt.got != tt.want { if tt.got != tt.want {

View File

@ -0,0 +1,45 @@
package handlers
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
func (a *App) triggerPasswordReset(username string) error {
body, _ := json.Marshal(map[string]string{"username": username})
req, err := http.NewRequest(
http.MethodPost,
a.cfg.AutheliaInternalURL+"/api/reset-password/identity/start",
bytes.NewReader(body),
)
if err != nil {
return fmt.Errorf("authelia reset build request: %w", err)
}
// Strip scheme from AutheliaURL to get the host for forwarding headers
externalHost := strings.TrimPrefix(strings.TrimPrefix(a.cfg.AutheliaURL, "https://"), "http://")
proto := "http"
if strings.HasPrefix(a.cfg.AutheliaURL, "https://") {
proto = "https"
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Forwarded-Host", externalHost)
req.Header.Set("X-Forwarded-Proto", proto)
req.Header.Set("X-Forwarded-For", "127.0.0.1")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("authelia reset request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("authelia reset returned %d", resp.StatusCode)
}
return nil
}

View File

@ -1,14 +1,57 @@
package handlers package handlers
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/version"
) )
func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) { func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User") remoteUser := r.Header.Get("Remote-User")
remoteEmail := r.Header.Get("Remote-Email") remoteEmail := r.Header.Get("Remote-Email")
remoteGroups := r.Header.Get("Remote-Groups") remoteGroups := r.Header.Get("Remote-Groups")
isSubscribed := remoteGroups != "" && contains(remoteGroups, "customers")
var customerID string
stackDeployed := false
stackRunning := false
var subStatus *ssstripe.SubscriptionStatus
if isSubscribed && remoteUser != "" {
cid, err := a.ldap.GetStripeCustomerID(remoteUser)
if err != nil {
log.Printf("dashboard: failed to get stripe customer id for %s: %v", remoteUser, err)
}
customerID = cid
if cid != "" {
subStatus = a.stripe.GetCustomerSubscriptionStatus(cid)
}
if subStatus == nil {
subStatus = &ssstripe.SubscriptionStatus{Label: "Active", Badge: "badge-active"}
}
stackName := fmt.Sprintf("customer-%s", remoteUser)
exists, err := a.swarm.StackExists(stackName)
if err != nil {
log.Printf("dashboard: stack check failed for %s: %v", remoteUser, err)
}
if !exists {
log.Printf("dashboard: deploying missing stack %s", stackName)
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
log.Printf("dashboard: stack deploy failed for %s: %v", remoteUser, err)
} else {
exists = true
}
}
stackDeployed = exists
if exists {
replicas, _ := a.swarm.GetWebReplicas(stackName)
stackRunning = replicas > 0
}
}
data := map[string]any{ data := map[string]any{
"AppURL": a.cfg.AppURL, "AppURL": a.cfg.AppURL,
@ -17,7 +60,13 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
"Email": remoteEmail, "Email": remoteEmail,
"Groups": remoteGroups, "Groups": remoteGroups,
"Domain": a.cfg.TraefikDomain, "Domain": a.cfg.TraefikDomain,
"IsSubscribed": remoteGroups != "" && contains(remoteGroups, "customers"), "IsSubscribed": isSubscribed,
"CustomerID": customerID,
"SubStatus": subStatus,
"StackDeployed": stackDeployed,
"StackRunning": stackRunning,
"Commit": version.Commit,
"BuildTime": version.BuildTime,
} }
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil { if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {

View File

@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"encoding/json"
"html/template" "html/template"
"net/http" "net/http"
"path/filepath" "path/filepath"
@ -9,6 +10,7 @@ import (
"git.nixc.us/a250/ss-atlas/internal/ldap" "git.nixc.us/a250/ss-atlas/internal/ldap"
ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe" ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe"
"git.nixc.us/a250/ss-atlas/internal/swarm" "git.nixc.us/a250/ss-atlas/internal/swarm"
"git.nixc.us/a250/ss-atlas/internal/version"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
) )
@ -43,13 +45,22 @@ func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swa
r.Get("/activate", app.handleActivateGet) r.Get("/activate", app.handleActivateGet)
r.Post("/activate", app.handleActivatePost) r.Post("/activate", app.handleActivatePost)
r.Get("/dashboard", app.handleDashboard) r.Get("/dashboard", app.handleDashboard)
r.Post("/stack-manage", app.handleStackManage)
r.Post("/subscribe", app.handleCreateCheckout) r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/portal", app.handlePortal) r.Post("/portal", app.handlePortal)
r.Post("/resubscribe", app.handleResubscribe)
r.Post("/webhook/stripe", app.handleWebhook) r.Post("/webhook/stripe", app.handleWebhook)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
w.Write([]byte("ok")) w.Write([]byte("ok"))
}) })
r.Get("/version", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"commit": version.Commit,
"build_time": version.BuildTime,
})
})
return r return r
} }

View File

@ -0,0 +1,75 @@
package handlers
import (
"fmt"
"log"
"net/http"
)
func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
remoteUser := r.Header.Get("Remote-User")
remoteGroups := r.Header.Get("Remote-Groups")
if remoteUser == "" {
http.Redirect(w, r, a.cfg.AutheliaURL, http.StatusSeeOther)
return
}
if !contains(remoteGroups, "customers") {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
action := r.FormValue("action")
stackName := fmt.Sprintf("customer-%s", remoteUser)
switch action {
case "stop":
if err := a.swarm.ScaleStack(stackName, 0); err != nil {
log.Printf("stack-manage stop %s: %v", remoteUser, err)
http.Error(w, "failed to stop stack", http.StatusInternalServerError)
return
}
case "start":
exists, _ := a.swarm.StackExists(stackName)
if !exists {
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
log.Printf("stack-manage start (deploy) %s: %v", remoteUser, err)
http.Error(w, "failed to start stack", http.StatusInternalServerError)
return
}
} else {
if err := a.swarm.ScaleStack(stackName, 1); err != nil {
log.Printf("stack-manage start (scale) %s: %v", remoteUser, err)
http.Error(w, "failed to start stack", http.StatusInternalServerError)
return
}
}
case "restart":
if err := a.swarm.RestartStack(stackName); err != nil {
log.Printf("stack-manage restart %s: %v", remoteUser, err)
http.Error(w, "failed to restart stack", http.StatusInternalServerError)
return
}
case "rebuild":
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
log.Printf("stack-manage rebuild %s: %v", remoteUser, err)
http.Error(w, "failed to rebuild stack", http.StatusInternalServerError)
return
}
case "destroy":
if err := a.swarm.RemoveStack(stackName); err != nil {
log.Printf("stack-manage destroy %s: %v", remoteUser, err)
http.Error(w, "failed to destroy stack", http.StatusInternalServerError)
return
}
default:
http.Error(w, "unknown action", http.StatusBadRequest)
return
}
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
}

View File

@ -1,14 +1,23 @@
package handlers package handlers
import ( import (
"fmt"
"log" "log"
"net/http" "net/http"
"strings" "strings"
"git.nixc.us/a250/ss-atlas/internal/version"
) )
func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) { func (a *App) handleLanding(w http.ResponseWriter, r *http.Request) {
if contains(r.Header.Get("Remote-Groups"), "customers") {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
return
}
data := map[string]any{ data := map[string]any{
"AppURL": a.cfg.AppURL, "AppURL": a.cfg.AppURL,
"Commit": version.Commit,
"BuildTime": version.BuildTime,
} }
if err := a.tmpl.ExecuteTemplate(w, "landing.html", data); err != nil { if err := a.tmpl.ExecuteTemplate(w, "landing.html", data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
@ -56,8 +65,6 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
customerID := sess.Customer.ID customerID := sess.Customer.ID
username := sanitizeUsername(email) 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) result, err := a.ldap.ProvisionUser(username, email, customerID)
if err != nil { if err != nil {
log.Printf("ldap provision failed for %s: %v", email, err) log.Printf("ldap provision failed for %s: %v", email, err)
@ -65,21 +72,50 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
return return
} }
if result.IsNew {
// New user: send password setup email, show onboarding page.
// Group membership and stack deploy happen on /activate after they set a password.
if err := a.triggerPasswordReset(result.Username); err != nil {
log.Printf("authelia reset trigger failed for %s: %v", username, err)
}
data := map[string]any{ data := map[string]any{
"Username": result.Username, "Username": result.Username,
"Password": result.Password, "IsNew": true,
"IsNew": result.IsNew,
"Email": email, "Email": email,
"LoginURL": a.cfg.AutheliaURL, "LoginURL": a.cfg.AutheliaURL,
"ResetURL": a.cfg.AutheliaURL + "/#/reset-password/step1", "ResetURL": a.cfg.AutheliaURL + "/#/reset-password/step1",
"ActivateURL": a.cfg.AppURL + "/activate", "ActivateURL": a.cfg.AppURL + "/activate",
"DashboardURL": a.cfg.AppURL + "/dashboard", "DashboardURL": a.cfg.AppURL + "/dashboard",
"InstanceURL": "https://" + result.Username + "." + a.cfg.CustomerDomain,
} }
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil { if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
log.Printf("template error: %v", err) log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
} }
return
}
// Existing user resubscribing: re-add to customers group if needed and
// ensure their stack is running, then send straight to dashboard.
inGroup, _ := a.ldap.IsInGroup(result.Username, "customers")
if !inGroup {
if err := a.ldap.AddToGroup(result.Username, "customers"); err != nil {
log.Printf("resubscribe: add to group failed for %s: %v", result.Username, err)
} else {
log.Printf("resubscribe: re-added %s to customers group", result.Username)
}
}
stackName := fmt.Sprintf("customer-%s", result.Username)
exists, _ := a.swarm.StackExists(stackName)
if !exists {
if err := a.swarm.DeployStack(stackName, result.Username, a.cfg.TraefikDomain); err != nil {
log.Printf("resubscribe: stack deploy failed for %s: %v", result.Username, err)
}
}
log.Printf("resubscribe: %s payment verified, redirecting to dashboard", result.Username)
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
} }
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) { func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
@ -99,14 +135,44 @@ func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, sess.URL, http.StatusSeeOther) http.Redirect(w, r, sess.URL, http.StatusSeeOther)
} }
// handleResubscribe creates a fresh checkout session for an existing Stripe
// customer whose subscription has expired/been cancelled. This differs from
// the portal flow which only manages active or scheduled-to-cancel subs.
func (a *App) handleResubscribe(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.CreateCheckoutForCustomer(customerID)
if err != nil {
log.Printf("stripe resubscribe error: %v", err)
http.Error(w, "failed to create checkout session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, sess.URL, http.StatusSeeOther)
}
func sanitizeUsername(email string) string { func sanitizeUsername(email string) string {
parts := strings.SplitN(email, "@", 2) parts := strings.SplitN(email, "@", 2)
name := strings.ToLower(parts[0]) local := parts[0]
name = strings.Map(func(r rune) rune { domain := ""
if len(parts) == 2 {
// Use second-level domain only (e.g. "nixc" from "nixc.us", "gmail" from "gmail.com")
domainParts := strings.Split(parts[1], ".")
if len(domainParts) >= 2 {
domain = "-" + domainParts[len(domainParts)-2]
}
}
clean := func(s string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' { if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r return r
} }
return '-' return '-'
}, name) }, strings.ToLower(s))
return name }
return clean(local) + clean(domain)
} }

View File

@ -70,7 +70,7 @@ func (a *App) onSubscriptionDeleted(event stripego.Event) {
customerID := sub.Customer.ID customerID := sub.Customer.ID
log.Printf("subscription deleted for customer %s", customerID) log.Printf("subscription deleted for customer %s", customerID)
username, err := a.ldap.FindUserByDescription(customerID) username, err := a.ldap.FindUserByStripeID(customerID)
if err != nil { if err != nil {
log.Printf("could not find user for customer %s: %v", customerID, err) log.Printf("could not find user for customer %s: %v", customerID, err)
return return

View File

@ -3,6 +3,7 @@ package ldap
import ( import (
"crypto/rand" "crypto/rand"
"encoding/base64" "encoding/base64"
"encoding/json"
"fmt" "fmt"
"log" "log"
@ -12,6 +13,7 @@ import (
type Client struct { type Client struct {
cfg *config.Config cfg *config.Config
gql *gqlClient
} }
type ProvisionResult struct { type ProvisionResult struct {
@ -21,7 +23,11 @@ type ProvisionResult struct {
} }
func New(cfg *config.Config) *Client { func New(cfg *config.Config) *Client {
return &Client{cfg: cfg} adminUID := "admin"
return &Client{
cfg: cfg,
gql: newGQLClient(cfg.LLDAPHttpURL, adminUID, cfg.LDAPAdminPassword),
}
} }
func (c *Client) connect() (*goldap.Conn, error) { func (c *Client) connect() (*goldap.Conn, error) {
@ -61,13 +67,22 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
addReq.Attribute("sn", []string{username}) addReq.Attribute("sn", []string{username})
addReq.Attribute("uid", []string{username}) addReq.Attribute("uid", []string{username})
addReq.Attribute("mail", []string{email}) addReq.Attribute("mail", []string{email})
addReq.Attribute("userPassword", []string{password})
addReq.Attribute("description", []string{stripeCustomerID})
if err := conn.Add(addReq); err != nil { if err := conn.Add(addReq); err != nil {
return nil, fmt.Errorf("ldap add user %s: %w", username, err) return nil, fmt.Errorf("ldap add user %s: %w", username, err)
} }
pwReq := goldap.NewPasswordModifyRequest(userDN, "", password)
if _, err := conn.PasswordModify(pwReq); err != nil {
return nil, fmt.Errorf("ldap set password for %s: %w", username, err)
}
if stripeCustomerID != "" {
if err := c.SetStripeCustomerID(username, stripeCustomerID); err != nil {
log.Printf("warning: failed to set stripe customer id for %s: %v", username, err)
}
}
log.Printf("created ldap user %s (%s)", username, email) log.Printf("created ldap user %s (%s)", username, email)
return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil
} }
@ -78,98 +93,152 @@ func (c *Client) EnsureUser(username, email, stripeCustomerID string) error {
} }
func (c *Client) AddToGroup(username, groupName string) error { func (c *Client) AddToGroup(username, groupName string) error {
conn, err := c.connect() groupID, err := c.getGroupID(groupName)
if err != nil { if err != nil {
return err return fmt.Errorf("resolve group %s: %w", groupName, 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)
} }
query := `mutation($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }`
_, err = c.gql.exec(query, map[string]any{"userId": username, "groupId": groupID})
if err != nil {
return fmt.Errorf("add %s to group %s: %w", username, groupName, err)
}
log.Printf("added %s to group %s", username, groupName) log.Printf("added %s to group %s", username, groupName)
return nil return nil
} }
func (c *Client) RemoveFromGroup(username, groupName string) error { func (c *Client) RemoveFromGroup(username, groupName string) error {
conn, err := c.connect() groupID, err := c.getGroupID(groupName)
if err != nil { if err != nil {
return err return fmt.Errorf("resolve group %s: %w", groupName, 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)
} }
query := `mutation($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }`
_, err = c.gql.exec(query, map[string]any{"userId": username, "groupId": groupID})
if err != nil {
return fmt.Errorf("remove %s from group %s: %w", username, groupName, err)
}
log.Printf("removed %s from group %s", username, groupName) log.Printf("removed %s from group %s", username, groupName)
return nil return nil
} }
func (c *Client) IsInGroup(username, groupName string) (bool, error) { func (c *Client) IsInGroup(username, groupName string) (bool, error) {
conn, err := c.connect() query := `query($userId: String!) { user(userId: $userId) { groups { displayName } } }`
data, err := c.gql.exec(query, map[string]any{"userId": username})
if err != nil { if err != nil {
return false, err return false, err
} }
defer conn.Close()
groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupName, c.cfg.LDAPBaseDN) var result struct {
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN) User struct {
Groups []struct {
searchReq := goldap.NewSearchRequest( DisplayName string `json:"displayName"`
groupDN, } `json:"groups"`
goldap.ScopeBaseObject, goldap.NeverDerefAliases, 1, 0, false, } `json:"user"`
fmt.Sprintf("(member=%s)", goldap.EscapeFilter(userDN)), }
[]string{"cn"}, if err := json.Unmarshal(data, &result); err != nil {
nil, return false, err
)
result, err := conn.Search(searchReq)
if err != nil {
return false, nil
} }
return len(result.Entries) > 0, nil for _, g := range result.User.Groups {
if g.DisplayName == groupName {
return true, nil
}
}
return false, nil
} }
func (c *Client) FindUserByDescription(stripeCustomerID string) (string, error) { func (c *Client) SetStripeCustomerID(username, customerID string) error {
conn, err := c.connect() query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) {
updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok }
}`
attrs := []map[string]any{
{"name": "stripe-customer-id", "value": []string{customerID}},
}
_, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs})
return err
}
func (c *Client) GetStripeCustomerID(username string) (string, error) {
query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }`
data, err := c.gql.exec(query, map[string]any{"userId": username})
if err != nil { if err != nil {
return "", err return "", err
} }
defer conn.Close()
searchReq := goldap.NewSearchRequest( var result struct {
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN), User struct {
goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 1, 0, false, Attributes []struct {
fmt.Sprintf("(description=%s)", goldap.EscapeFilter(stripeCustomerID)), Name string `json:"name"`
[]string{"uid"}, Value []string `json:"value"`
nil, } `json:"attributes"`
) } `json:"user"`
}
if err := json.Unmarshal(data, &result); err != nil {
return "", err
}
result, err := conn.Search(searchReq) for _, attr := range result.User.Attributes {
if attr.Name == "stripe-customer-id" && len(attr.Value) > 0 {
return attr.Value[0], nil
}
}
return "", nil
}
func (c *Client) FindUserByStripeID(stripeCustomerID string) (string, error) {
query := `query { users(filters: {}) { id attributes { name value } } }`
data, err := c.gql.exec(query, nil)
if err != nil { if err != nil {
return "", fmt.Errorf("ldap search by description: %w", err) return "", err
} }
if len(result.Entries) == 0 { var result struct {
Users []struct {
ID string `json:"id"`
Attributes []struct {
Name string `json:"name"`
Value []string `json:"value"`
} `json:"attributes"`
} `json:"users"`
}
if err := json.Unmarshal(data, &result); err != nil {
return "", err
}
for _, u := range result.Users {
for _, attr := range u.Attributes {
if attr.Name == "stripe-customer-id" && len(attr.Value) > 0 && attr.Value[0] == stripeCustomerID {
return u.ID, nil
}
}
}
return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID) return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID)
}
func (c *Client) getGroupID(groupName string) (int, error) {
query := `query { groups { id displayName } }`
data, err := c.gql.exec(query, nil)
if err != nil {
return 0, err
} }
return result.Entries[0].GetAttributeValue("uid"), nil var result struct {
Groups []struct {
ID int `json:"id"`
DisplayName string `json:"displayName"`
} `json:"groups"`
}
if err := json.Unmarshal(data, &result); err != nil {
return 0, err
}
for _, g := range result.Groups {
if g.DisplayName == groupName {
return g.ID, nil
}
}
return 0, fmt.Errorf("group %s not found", groupName)
} }
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) { func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {

View File

@ -0,0 +1,114 @@
package ldap
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"sync"
"time"
)
type gqlClient struct {
baseURL string
username string
password string
token string
mu sync.Mutex
client *http.Client
}
type gqlRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
type gqlResponse struct {
Data json.RawMessage `json:"data"`
Errors []struct {
Message string `json:"message"`
} `json:"errors"`
}
func newGQLClient(baseURL, username, password string) *gqlClient {
return &gqlClient{
baseURL: baseURL,
username: username,
password: password,
client: &http.Client{Timeout: 10 * time.Second},
}
}
func (g *gqlClient) authenticate() error {
body, _ := json.Marshal(map[string]string{
"username": g.username,
"password": g.password,
})
resp, err := g.client.Post(g.baseURL+"/auth/simple/login", "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("lldap auth: %w", err)
}
defer resp.Body.Close()
var result struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("lldap auth decode: %w", err)
}
if result.Token == "" {
return fmt.Errorf("lldap auth: empty token")
}
g.token = result.Token
return nil
}
func (g *gqlClient) exec(query string, variables map[string]any) (json.RawMessage, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.token == "" {
if err := g.authenticate(); err != nil {
return nil, err
}
}
data, err := g.doRequest(query, variables)
if err != nil {
if err := g.authenticate(); err != nil {
return nil, err
}
data, err = g.doRequest(query, variables)
if err != nil {
return nil, err
}
}
return data, nil
}
func (g *gqlClient) doRequest(query string, variables map[string]any) (json.RawMessage, error) {
reqBody, _ := json.Marshal(gqlRequest{Query: query, Variables: variables})
req, err := http.NewRequest("POST", g.baseURL+"/api/graphql", bytes.NewReader(reqBody))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+g.token)
resp, err := g.client.Do(req)
if err != nil {
return nil, fmt.Errorf("lldap graphql: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
var gqlResp gqlResponse
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, fmt.Errorf("lldap graphql decode: %w", err)
}
if len(gqlResp.Errors) > 0 {
return nil, fmt.Errorf("lldap graphql: %s", gqlResp.Errors[0].Message)
}
return gqlResp.Data, nil
}

View File

@ -1,6 +1,9 @@
package stripe package stripe
import ( import (
"log"
"time"
"git.nixc.us/a250/ss-atlas/internal/config" "git.nixc.us/a250/ss-atlas/internal/config"
stripego "github.com/stripe/stripe-go/v84" stripego "github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session" portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
@ -8,6 +11,12 @@ import (
"github.com/stripe/stripe-go/v84/subscription" "github.com/stripe/stripe-go/v84/subscription"
) )
type SubscriptionStatus struct {
Label string // "Active", "Cancels soon", etc.
Badge string // "badge-active", "badge-inactive", etc.
CancelAt string // empty or formatted date
}
type Client struct { type Client struct {
cfg *config.Config cfg *config.Config
} }
@ -33,6 +42,25 @@ func (c *Client) CreateCheckoutSession(email string) (*stripego.CheckoutSession,
return checkoutsession.New(params) return checkoutsession.New(params)
} }
// CreateCheckoutForCustomer creates a new subscription checkout for an existing
// Stripe customer (e.g. resubscribe after expiry). The new sub is linked to the
// same customer record so payment methods and history are preserved.
func (c *Client) CreateCheckoutForCustomer(customerID 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),
},
},
Customer: stripego.String(customerID),
SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripego.String(c.cfg.AppURL + "/dashboard"),
}
return checkoutsession.New(params)
}
func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) { func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
params := &stripego.BillingPortalSessionParams{ params := &stripego.BillingPortalSessionParams{
Customer: stripego.String(customerID), Customer: stripego.String(customerID),
@ -52,6 +80,42 @@ func (c *Client) GetSubscription(subID string) (*stripego.Subscription, error) {
return subscription.Get(subID, nil) return subscription.Get(subID, nil)
} }
func (c *Client) GetCustomerSubscriptionStatus(customerID string) *SubscriptionStatus {
if customerID == "" {
return &SubscriptionStatus{Label: "Active", Badge: "badge-active"}
}
params := &stripego.SubscriptionListParams{
Customer: stripego.String(customerID),
Status: stripego.String(string(stripego.SubscriptionStatusActive)),
}
iter := subscription.List(params)
if iter.Next() {
sub := iter.Subscription()
log.Printf("stripe: customer=%s sub=%s cancel_at_period_end=%v cancel_at=%d",
customerID, sub.ID, sub.CancelAtPeriodEnd, sub.CancelAt)
if sub.CancelAtPeriodEnd || sub.CancelAt > 0 {
// Prefer explicit cancel_at; fall back to current_period_end from the first item
endTs := sub.CancelAt
if endTs == 0 && sub.Items != nil && len(sub.Items.Data) > 0 {
endTs = sub.Items.Data[0].CurrentPeriodEnd
}
var cancelAt string
if endTs > 0 {
cancelAt = time.Unix(endTs, 0).Format("Jan 2, 2006")
}
return &SubscriptionStatus{
Label: "Expiring",
Badge: "badge-inactive",
CancelAt: cancelAt,
}
}
return &SubscriptionStatus{Label: "Active", Badge: "badge-active"}
}
log.Printf("stripe: no active subscription found for customer=%s", customerID)
// No active subscription; user was a customer so subscription has expired
return &SubscriptionStatus{Label: "Expired", Badge: "badge-inactive"}
}
func (c *Client) WebhookSecret() string { func (c *Client) WebhookSecret() string {
return c.cfg.StripeWebhookSecret return c.cfg.StripeWebhookSecret
} }

View File

@ -7,6 +7,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"text/template" "text/template"
@ -33,7 +34,7 @@ func (c *Client) DeployStack(stackName, username, domain string) error {
return fmt.Errorf("parse stack template: %w", err) return fmt.Errorf("parse stack template: %w", err)
} }
data := map[string]string{ data := map[string]any{
"ID": username, "ID": username,
"Subdomain": username, "Subdomain": username,
"Domain": domain, "Domain": domain,
@ -63,6 +64,16 @@ func (c *Client) DeployStack(stackName, username, domain string) error {
} }
log.Printf("deployed stack %s: %s", stackName, strings.TrimSpace(string(output))) log.Printf("deployed stack %s: %s", stackName, strings.TrimSpace(string(output)))
// Force-restart the web service so Traefik picks up label changes immediately.
// Traefik reads labels from running task containers, not service specs, so a
// task restart is required for routing changes to take effect.
forceCmd := exec.Command("docker", "service", "update", "--force", stackName+"_web")
forceCmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
if forceOut, forceErr := forceCmd.CombinedOutput(); forceErr != nil {
log.Printf("warn: force-update %s_web: %s", stackName, strings.TrimSpace(string(forceOut)))
}
return nil return nil
} }
@ -95,3 +106,53 @@ func (c *Client) StackExists(stackName string) (bool, error) {
} }
return false, nil return false, nil
} }
// GetWebReplicas returns the desired replica count for <stackName>_web.
// Returns 0 if the service does not exist.
func (c *Client) GetWebReplicas(stackName string) (int, error) {
cmd := exec.Command("docker", "service", "inspect",
"--format", "{{.Spec.Mode.Replicated.Replicas}}",
stackName+"_web",
)
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
output, err := cmd.CombinedOutput()
if err != nil {
return 0, nil
}
n, err := strconv.Atoi(strings.TrimSpace(string(output)))
if err != nil {
return 0, nil
}
return n, nil
}
// ScaleStack sets the desired replica count for <stackName>_web.
func (c *Client) ScaleStack(stackName string, replicas int) error {
svc := fmt.Sprintf("%s_web=%d", stackName, replicas)
cmd := exec.Command("docker", "service", "scale", svc)
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("scale stack %s: %s: %w", stackName, strings.TrimSpace(string(output)), err)
}
log.Printf("scaled %s_web to %d: %s", stackName, replicas, strings.TrimSpace(string(output)))
return nil
}
// RestartStack force-restarts the web service, triggering a fresh container.
func (c *Client) RestartStack(stackName string) error {
cmd := exec.Command("docker", "service", "update", "--force", stackName+"_web")
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("restart stack %s: %s: %w", stackName, strings.TrimSpace(string(output)), err)
}
log.Printf("restarted %s_web: %s", stackName, strings.TrimSpace(string(output)))
return nil
}

View File

@ -0,0 +1,10 @@
package version
var (
Commit = "unknown"
BuildTime = "unknown"
)
func String() string {
return Commit + " (built " + BuildTime + ")"
}

View File

@ -101,6 +101,31 @@
.actions { display: flex; gap: 0.75rem; margin-top: 1rem; flex-wrap: wrap; } .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 { text-align: center; padding: 3rem 1rem; color: var(--muted); }
.empty-state p { margin-bottom: 1.5rem; } .empty-state p { margin-bottom: 1.5rem; }
.btn-danger {
background: rgba(239,68,68,0.15);
color: var(--red);
border: 1px solid rgba(239,68,68,0.3);
}
.btn-danger:hover { background: rgba(239,68,68,0.25); color: var(--red); }
.btn-warning {
background: rgba(234,179,8,0.12);
color: #eab308;
border: 1px solid rgba(234,179,8,0.25);
}
.btn-warning:hover { background: rgba(234,179,8,0.22); color: #eab308; }
.btn-sm { padding: 0.45rem 0.9rem; font-size: 0.82rem; }
.divider { border: none; border-top: 1px solid var(--border); margin: 1rem 0; }
.version-badge {
position: fixed;
bottom: 0.75rem;
right: 0.75rem;
font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.65rem;
color: var(--muted);
opacity: 0.5;
pointer-events: none;
user-select: all;
}
</style> </style>
</head> </head>
<body> <body>
@ -116,8 +141,18 @@
<h2>Subscription</h2> <h2>Subscription</h2>
<div class="status-row"> <div class="status-row">
<span class="status-label">Status</span> <span class="status-label">Status</span>
{{if .SubStatus}}
<span class="badge {{.SubStatus.Badge}}">{{.SubStatus.Label}}</span>
{{else}}
<span class="badge badge-active">Active</span> <span class="badge badge-active">Active</span>
{{end}}
</div> </div>
{{if and .SubStatus .SubStatus.CancelAt}}
<div class="status-row">
<span class="status-label">Access until</span>
<span class="status-value">{{.SubStatus.CancelAt}}</span>
</div>
{{end}}
<div class="status-row"> <div class="status-row">
<span class="status-label">Email</span> <span class="status-label">Email</span>
<span class="status-value">{{.Email}}</span> <span class="status-value">{{.Email}}</span>
@ -125,16 +160,67 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Your Stack</h2> <h2>Your Stack</h2>
<p style="color: var(--muted); font-size: 0.9rem;">Your dedicated environment is live and accessible at:</p> <div class="status-row">
<a class="stack-link" href="http://{{.User}}.{{.Domain}}">{{.User}}.{{.Domain}}</a> <span class="status-label">Status</span>
{{if .StackRunning}}
<span class="badge badge-active">Running</span>
{{else if .StackDeployed}}
<span class="badge" style="background:rgba(234,179,8,0.12);color:#eab308;">Stopped</span>
{{else}}
<span class="badge badge-inactive">Not deployed</span>
{{end}}
</div>
{{if .StackRunning}}
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your dedicated environment is accessible at:</p>
<a class="stack-link" href="https://{{.User}}.{{.Domain}}">{{.User}}.{{.Domain}}</a>
{{else if not .StackDeployed}}
<p style="color: var(--muted); font-size: 0.9rem; margin-top: 0.75rem;">Your stack is being provisioned. Refresh this page in a moment.</p>
{{end}}
{{if .StackDeployed}}
<hr class="divider">
<div class="actions">
{{if .StackRunning}}
<form method="POST" action="/stack-manage" style="margin:0">
<input type="hidden" name="action" value="restart">
<button type="submit" class="btn btn-outline btn-sm">Restart</button>
</form>
<form method="POST" action="/stack-manage" style="margin:0">
<input type="hidden" name="action" value="stop">
<button type="submit" class="btn btn-warning btn-sm">Stop</button>
</form>
{{else}}
<form method="POST" action="/stack-manage" style="margin:0">
<input type="hidden" name="action" value="start">
<button type="submit" class="btn btn-sm">Start</button>
</form>
{{end}}
<form method="POST" action="/stack-manage" style="margin:0"
onsubmit="return confirm('Destroy your stack? All containers will be removed. Volumes are preserved.')">
<input type="hidden" name="action" value="destroy">
<button type="submit" class="btn btn-danger btn-sm">Destroy</button>
</form>
</div>
{{end}}
</div> </div>
<div class="card"> <div class="card">
<h2>Manage</h2> <h2>Manage</h2>
<div class="actions"> <div class="actions">
<form method="POST" action="/portal" style="margin:0"> {{if and .SubStatus (eq .SubStatus.Label "Expired")}}
<input type="hidden" name="customer_id" value=""> <form method="POST" action="/resubscribe" style="margin:0">
<button type="submit" class="btn">Manage Subscription</button> <input type="hidden" name="customer_id" value="{{.CustomerID}}">
<button type="submit" class="btn">Resubscribe</button>
</form> </form>
{{else}}
<form method="POST" action="/portal" style="margin:0">
<input type="hidden" name="customer_id" value="{{.CustomerID}}">
{{if and .SubStatus (eq .SubStatus.Label "Expiring")}}
<button type="submit" class="btn">Keep Subscription</button>
{{else}}
<button type="submit" class="btn">Manage Subscription</button>
{{end}}
</form>
{{end}}
<a href="{{.AutheliaURL}}" class="btn btn-outline">Account Settings</a> <a href="{{.AutheliaURL}}" class="btn btn-outline">Account Settings</a>
</div> </div>
<p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;"> <p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;">
@ -157,5 +243,6 @@
</div> </div>
{{end}} {{end}}
</div> </div>
<div class="version-badge">{{.Commit}}</div>
</body> </body>
</html> </html>

View File

@ -78,6 +78,17 @@
button:hover { background: var(--accent-hover); } button:hover { background: var(--accent-hover); }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; } .footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
.footer a { color: var(--accent); text-decoration: none; } .footer a { color: var(--accent); text-decoration: none; }
.version-badge {
position: fixed;
bottom: 0.75rem;
right: 0.75rem;
font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
font-size: 0.65rem;
color: var(--muted);
opacity: 0.5;
pointer-events: none;
user-select: all;
}
</style> </style>
</head> </head>
<body> <body>
@ -102,5 +113,6 @@
Already subscribed? <a href="/dashboard">Go to Dashboard</a> Already subscribed? <a href="/dashboard">Go to Dashboard</a>
</div> </div>
</div> </div>
<div class="version-badge">{{.Commit}}</div>
</body> </body>
</html> </html>

View File

@ -114,6 +114,34 @@
.actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; } .actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
.returning { text-align: center; padding: 2rem; color: var(--muted); } .returning { text-align: center; padding: 2rem; color: var(--muted); }
.returning p { margin-bottom: 1rem; } .returning p { margin-bottom: 1rem; }
.download-gate {
background: rgba(99, 102, 241, 0.08);
border: 1px solid rgba(99, 102, 241, 0.35);
border-radius: 8px;
padding: 1rem 1.25rem;
margin-bottom: 1.5rem;
font-size: 0.9rem;
line-height: 1.5;
color: var(--text);
}
.download-gate strong { color: var(--accent-hover); }
.btn-download {
background: var(--green);
color: #fff;
border: none;
}
.btn-download:hover { background: #16a34a; }
.btn-download.downloaded {
background: #166534;
cursor: default;
opacity: 0.7;
}
.nav-actions { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
.nav-actions a.locked {
pointer-events: none;
opacity: 0.35;
cursor: not-allowed;
}
</style> </style>
</head> </head>
<body> <body>
@ -122,50 +150,64 @@
{{if .IsNew}} {{if .IsNew}}
<div class="subtitle">Payment successful — your account is ready!</div> <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"> <div class="card">
<h2>Your Temporary Login</h2> <h2>Check your email</h2>
<p style="color:var(--muted);font-size:0.95rem;line-height:1.6;margin-bottom:1rem;">
We've sent a password setup link to <strong style="color:var(--text)">{{.Email}}</strong>.<br>
Click the link in that email to set your password and log in.
</p>
<div class="cred-row"> <div class="cred-row">
<span class="cred-label">Username</span> <span class="cred-label">Username</span>
<span class="cred-value">{{.Username}}</span> <span class="cred-value">{{.Username}}</span>
</div> </div>
<div class="cred-row"> <div class="cred-row">
<span class="cred-label">Temporary Password</span> <span class="cred-label">Your Instance</span>
<span class="cred-value">{{.Password}}</span> <span class="cred-value">{{.InstanceURL}}</span>
</div>
</div>
<div class="card">
<h2>Next Steps</h2>
<ol class="steps">
<li>Open the email sent to <strong>{{.Email}}</strong> and click the link</li>
<li>Set your password</li>
<li>Return here and click <strong>Activate Stack</strong> to go live</li>
<li>Your instance will be at <strong>{{.InstanceURL}}</strong></li>
</ol>
</div>
<div class="actions">
<a href="{{.ResetURL}}" class="btn">Resend / Set Password</a>
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
</div>
{{else}}
<div class="subtitle">Welcome back!</div>
<div class="warning">
<strong>Credentials not shown.</strong> Your account <strong>{{.Username}}</strong> already exists — we cannot display your original password again. Use the link below if you need to reset it.
</div>
<div class="card">
<h2>Account Details</h2>
<div class="cred-row">
<span class="cred-label">Username</span>
<span class="cred-value">{{.Username}}</span>
</div> </div>
<div class="cred-row"> <div class="cred-row">
<span class="cred-label">Email</span> <span class="cred-label">Email</span>
<span class="cred-value">{{.Email}}</span> <span class="cred-value">{{.Email}}</span>
</div> </div>
<div class="cred-row">
<span class="cred-label">Your Instance</span>
<span class="cred-value">{{.InstanceURL}}</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>
<div class="actions"> <div class="actions">
<a href="{{.ResetURL}}" class="btn">Sign In &amp; Set Password</a> <a href="{{.ResetURL}}" class="btn">Reset Password</a>
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a> <a href="{{.LoginURL}}" class="btn btn-outline">Sign In</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> <a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
</div> </div>
</div>
{{end}} {{end}}
</div> </div>
</body> </body>

View File

@ -1,3 +1,22 @@
# =============================================================================
# CUSTOMER STACK TEMPLATE
# =============================================================================
# This is the Docker Swarm stack that gets deployed for each paying customer.
# It defines what product/service they receive when they subscribe.
#
# REPLACE the `web` service image (currently traefik/whoami as a placeholder)
# with the actual application you are selling.
#
# Template variables (injected at deploy time by swarm/client.go):
# {{.ID}} - customer's username (used for unique resource naming)
# {{.Subdomain}} - customer's subdomain (same as ID by default)
# {{.Domain}} - base domain (e.g. bc.a250.ca)
# {{.TraefikNetwork}} - Traefik overlay network name
#
# Each customer gets their stack at: https://{{.Subdomain}}.{{.Domain}}
# Access is restricted to the owning user via Authelia forward-auth.
# =============================================================================
version: "3.8" version: "3.8"
services: services:
@ -11,10 +30,11 @@ services:
replicas: 1 replicas: 1
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.docker.network: "{{.TraefikNetwork}}" traefik.docker.network: "atlas_{{.TraefikNetwork}}"
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Subdomain}}.{{.Domain}}`)" traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Subdomain}}.{{.Domain}}`)"
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "web" traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@docker" traefik.http.routers.customer-{{.ID}}-web.tls: "true"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@swarm"
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "80" traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "80"
restart_policy: restart_policy:
condition: on-failure condition: on-failure
@ -34,7 +54,7 @@ services:
networks: networks:
traefik_net: traefik_net:
external: true external: true
name: "{{.TraefikNetwork}}" name: "atlas_{{.TraefikNetwork}}"
backend: backend:
driver: overlay driver: overlay

38
scripts/deploy-stack-dev.sh Executable file
View File

@ -0,0 +1,38 @@
#!/bin/sh
# Build images and deploy ATLAS stack to local swarm.
set -e
cd "$(dirname "$0")/.."
[ -f .env ] && set -a && . .env && set +a
if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2
exit 1
fi
BUILD_COMMIT="$(git rev-parse --short HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "=== Building commit $BUILD_COMMIT ==="
echo "=== Ensuring swarm mode ==="
docker info --format '{{.Swarm.LocalNodeState}}' | grep -q "active" || docker swarm init
echo "=== Creating overlay network ==="
docker network inspect authelia_dev >/dev/null 2>&1 || \
docker network create -d overlay --attachable authelia_dev
echo "=== Building images ==="
docker compose build \
--build-arg BUILD_COMMIT="$BUILD_COMMIT" \
--build-arg BUILD_TIME="$BUILD_TIME"
echo "=== Deploying stack ==="
docker stack deploy -c stack.yml atlas
echo "=== Force-updating local images ==="
docker service update --force --image atlas-ss-atlas:latest atlas_ss-atlas
docker service update --force --image git.nixc.us/a250/authelia:dev-authelia atlas_authelia
echo ""
echo "=== Ready. Visit https://app.bc.a250.ca ==="

22
scripts/local-dns-setup.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/sh
# Local DNS setup so *.app.a250.ca and *.bc.a250.ca resolve to 127.0.0.1
# For full wildcard support use dnsmasq. For quick test, use /etc/hosts (limited).
set -e
echo "Option 1: dnsmasq (recommended - true wildcard)"
echo " Add to /etc/dnsmasq.conf or /usr/local/etc/dnsmasq.conf:"
echo " address=/.app.a250.ca/127.0.0.1"
echo " address=/.bc.a250.ca/127.0.0.1"
echo " Then: brew services restart dnsmasq"
echo " And point your Mac to use 127.0.0.1 for DNS, or add to /etc/resolvers/app.a250.ca"
echo ""
echo "Option 2: /etc/hosts (manual per-subdomain)"
echo " Add lines for each host you need:"
echo " 127.0.0.1 app.bc.a250.ca login.bc.a250.ca lldap.bc.a250.ca traefik.bc.a250.ca colin-nixc.bc.a250.ca"
echo " 127.0.0.1 testuser.app.a250.ca"
echo " /etc/hosts does NOT support wildcards."
echo ""
echo "Option 3: resolvers file for *.app.a250.ca only"
echo " mkdir -p /etc/resolver"
echo " echo 'nameserver 127.0.0.1' | sudo tee /etc/resolver/app.a250.ca"
echo " (requires dnsmasq listening on 127.0.0.1 with address=/.app.a250.ca/127.0.0.1)"

78
scripts/local-rebuild-deploy.sh Executable file
View File

@ -0,0 +1,78 @@
#!/bin/sh
# Static workflow: clean (stack rm + prune) → rebuild (on deploy context) → redeploy. No push; uses local images.
# Set DOCKER_DEPLOY_CONTEXT for stack target (default: default).
# Requires: swarm mode, secrets/networks on deploy node.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
STACK_NAME="${STACK_NAME:-authelia}"
DEPLOY_CTX="${DOCKER_DEPLOY_CONTEXT:-orbstack}"
STACK_RM_WAIT=15
cd "$REPO_ROOT"
if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2
exit 1
fi
BUILD_COMMIT="$(git rev-parse --short HEAD)"
BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
echo "=== Building commit $BUILD_COMMIT ==="
clean_on_deploy_context() {
docker context use "$DEPLOY_CTX"
docker stack rm "$STACK_NAME" 2>/dev/null || true
sleep "$STACK_RM_WAIT"
docker image prune -a -f 2>/dev/null || true
for vol in authelia_authelia_config authelia_authelia_assets authelia_authelia_redis_data authelia_authelia_mariadb_data authelia_lldap_data; do
docker volume rm "$vol" 2>/dev/null || true
done
for name in AUTHENTICATION_BACKEND_LDAP_PASSWORD IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET NOTIFIER_SMTP_PASSWORD SESSION_SECRET STORAGE_ENCRYPTION_KEY; do
docker secret rm "$name" 2>/dev/null || true
done
}
build_on_deploy_context() {
docker context use "$DEPLOY_CTX"
docker compose -f docker-compose.production.yml build --no-cache \
--build-arg BUILD_COMMIT="$BUILD_COMMIT" \
--build-arg BUILD_TIME="$BUILD_TIME"
}
ensure_external_networks() {
for net in traefik ad; do
docker network inspect "$net" --format '{{.Name}}' 2>/dev/null | grep -q . || docker network create "$net" --driver overlay --attachable
done
}
ensure_secrets() {
LDAP_PW="/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0="
echo "$LDAP_PW" | docker secret create AUTHENTICATION_BACKEND_LDAP_PASSWORD - 2>/dev/null || true
openssl rand -base64 32 | docker secret create IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET - 2>/dev/null || true
echo "not-configured" | docker secret create NOTIFIER_SMTP_PASSWORD - 2>/dev/null || true
openssl rand -base64 32 | docker secret create SESSION_SECRET - 2>/dev/null || true
openssl rand -base64 32 | docker secret create STORAGE_ENCRYPTION_KEY - 2>/dev/null || true
}
deploy_on_deploy_context() {
docker context use "$DEPLOY_CTX"
docker info --format '{{.Swarm.LocalNodeState}}' | grep -q active || docker swarm init
ensure_external_networks
ensure_secrets
docker stack deploy --with-registry-auth -c ./stack.production.yml "$STACK_NAME"
docker stack ps "$STACK_NAME"
}
echo "=== Clean (stack rm + prune) on context: $DEPLOY_CTX ==="
clean_on_deploy_context
echo "=== Rebuild (on $DEPLOY_CTX, local images) ==="
build_on_deploy_context
echo "=== Redeploy on context: $DEPLOY_CTX ==="
deploy_on_deploy_context
echo "=== Done ==="

View File

@ -3,7 +3,7 @@ x-authelia-env: &authelia-env
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=a250,dc=ca X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: a250.ca TRAEFIK_DOMAIN: bc.a250.ca
secrets: secrets:
AUTHENTICATION_BACKEND_LDAP_PASSWORD: AUTHENTICATION_BACKEND_LDAP_PASSWORD:
@ -51,6 +51,8 @@ volumes:
driver: local driver: local
authelia_mariadb_data: authelia_mariadb_data:
driver: local driver: local
lldap_data:
driver: local
services: services:
authelia: authelia:
@ -60,6 +62,7 @@ services:
- --config=/config/configuration.server.yml - --config=/config/configuration.server.yml
- --config=/config/configuration.ldap.yml - --config=/config/configuration.ldap.yml
- --config=/config/configuration.acl.yml - --config=/config/configuration.acl.yml
- --config=/config/configuration.notifier.yml
secrets: secrets:
- AUTHENTICATION_BACKEND_LDAP_PASSWORD - AUTHENTICATION_BACKEND_LDAP_PASSWORD
- IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET - IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
@ -91,24 +94,19 @@ services:
restart_policy: restart_policy:
condition: on-failure condition: on-failure
replicas: 1 replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels: labels:
us.a250.autodeploy: "true" us.a250.autodeploy: "true"
homepage.group: Infrastructure homepage.group: Infrastructure
homepage.name: Authelia homepage.name: Authelia
homepage.href: https://login.a250.ca homepage.href: https://login.bc.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.a250.ca`) traefik.http.routers.authelia_authelia.rule: Host(`login.bc.a250.ca`)
traefik.http.routers.authelia_authelia.entrypoints: websecure traefik.http.routers.authelia_authelia.entrypoints: web
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.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.a250.ca/ traefik.http.middlewares.authelia_authelia.forwardauth.address: http://authelia_authelia:9091/api/verify?rd=https://login.bc.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
@ -141,9 +139,6 @@ services:
restart_policy: restart_policy:
condition: on-failure condition: on-failure
replicas: 1 replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels: labels:
us.a250.autodeploy: "true" us.a250.autodeploy: "true"
traefik.enable: "false" traefik.enable: "false"
@ -159,6 +154,26 @@ services:
max-size: 10m max-size: 10m
max-file: "3" max-file: "3"
lldap:
image: nitnelave/lldap:latest
volumes:
- lldap_data:/data
environment:
LLDAP_JWT_SECRET: I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I=
LLDAP_LDAP_USER_PASS: /ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
LLDAP_LDAP_BASE_DN: dc=a250,dc=ca
networks:
- default
deploy:
restart_policy:
condition: on-failure
replicas: 1
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
mariadb: mariadb:
image: git.nixc.us/a250/authelia:production-mariadb image: git.nixc.us/a250/authelia:production-mariadb
environment: environment:
@ -178,9 +193,6 @@ services:
restart_policy: restart_policy:
condition: on-failure condition: on-failure
replicas: 1 replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels: labels:
us.a250.autodeploy: "true" us.a250.autodeploy: "true"
traefik.enable: "false" traefik.enable: "false"

View File

@ -1,130 +0,0 @@
x-authelia-env: &authelia-env
X_AUTHELIA_EMAIL: authelia@a250.ca
X_AUTHELIA_SITE_NAME: ATLAS
X_AUTHELIA_CONFIG_FILTERS: template
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: a250.ca
networks:
default:
driver: overlay
traefik:
external: true
ad:
external: true
volumes:
authelia_staging_config:
driver: local
authelia_staging_redis_data:
driver: local
authelia_staging_mariadb_data:
driver: local
services:
authelia:
image: git.nixc.us/a250/authelia:staging-authelia
command:
- authelia
- --config=/config/configuration.server.yml
- --config=/config/configuration.ldap.yml
- --config=/config/configuration.acl.yml
- --config=/config/configuration.identity.providers.yml
- --config=/config/configuration.oidc.clients.yml
environment: *authelia-env
volumes:
- authelia_staging_config:/config:rw
networks:
- traefik
- default
- ad
deploy:
update_config:
order: start-first
failure_action: rollback
parallelism: 1
restart_policy:
condition: on-failure
replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels:
us.a250.autodeploy: "true"
traefik.enable: "true"
traefik.docker.network: traefik
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.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
traefik.http.middlewares.staging-authelia-basic.forwardauth.trustForwardHeader: "true"
traefik.http.middlewares.staging-authelia-basic.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
redis:
image: git.nixc.us/a250/authelia:staging-redis
command: redis-server --appendonly yes
volumes:
- authelia_staging_redis_data:/data:rw
networks:
- default
deploy:
update_config:
order: start-first
failure_action: rollback
parallelism: 1
restart_policy:
condition: on-failure
replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels:
us.a250.autodeploy: "true"
traefik.enable: "false"
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"
mariadb:
image: git.nixc.us/a250/authelia:staging-mariadb
environment:
MYSQL_ROOT_PASSWORD: authelia
MYSQL_DATABASE: authelia
MYSQL_USER: authelia
MYSQL_PASSWORD: authelia
volumes:
- authelia_staging_mariadb_data:/var/lib/mysql:rw
networks:
- default
deploy:
update_config:
order: start-first
failure_action: rollback
parallelism: 1
restart_policy:
condition: on-failure
replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels:
us.a250.autodeploy: "true"
traefik.enable: "false"
logging:
driver: json-file
options:
max-size: 10m
max-file: "3"

View File

@ -1,7 +1,6 @@
services: services:
mariadb: mariadb:
image: mariadb:latest image: mariadb:latest
container_name: authelia_mariadb
environment: environment:
MYSQL_ROOT_PASSWORD: dev_authelia_root MYSQL_ROOT_PASSWORD: dev_authelia_root
MYSQL_DATABASE: authelia MYSQL_DATABASE: authelia
@ -9,7 +8,6 @@ services:
MYSQL_PASSWORD: authelia MYSQL_PASSWORD: authelia
volumes: volumes:
- mariadb_data:/var/lib/mysql - mariadb_data:/var/lib/mysql
# No ports exposed - internal only
networks: networks:
- authelia_dev - authelia_dev
healthcheck: healthcheck:
@ -21,11 +19,9 @@ services:
redis: redis:
image: redis:latest image: redis:latest
container_name: authelia_redis
command: redis-server --appendonly yes command: redis-server --appendonly yes
volumes: volumes:
- redis_data:/data - redis_data:/data
# No ports exposed - internal only
networks: networks:
- authelia_dev - authelia_dev
healthcheck: healthcheck:
@ -37,7 +33,6 @@ services:
lldap: lldap:
image: nitnelave/lldap:latest image: nitnelave/lldap:latest
container_name: lldap_lldap
volumes: volumes:
- lldap_data:/data - lldap_data:/data
environment: environment:
@ -46,15 +41,14 @@ services:
- LLDAP_LDAP_BASE_DN=dc=a250,dc=ca - LLDAP_LDAP_BASE_DN=dc=a250,dc=ca
- PUID=33 - PUID=33
- PGID=33 - PGID=33
ports:
# Only expose web UI for manual testing
- "17170:17170" # Web interface port
networks: networks:
- authelia_dev - authelia_dev
deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)" - "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)"
- "traefik.http.routers.lldap.entrypoints=web" - "traefik.http.routers.lldap.entrypoints=websecure"
- "traefik.http.routers.lldap.tls=true"
- "traefik.http.services.lldap.loadbalancer.server.port=17170" - "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" ]
@ -64,17 +58,12 @@ services:
retries: 3 retries: 3
authelia: authelia:
build:
context: ./docker/authelia/
dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:dev-authelia image: git.nixc.us/a250/authelia:dev-authelia
container_name: authelia_dev_main
user: root user: root
command: command:
- sh - sh
- -c - -c
- | - |
# Create the secrets directory and populate with environment variables
mkdir -p /run/secrets mkdir -p /run/secrets
echo "$${IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}" > /run/secrets/IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET echo "$${IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}" > /run/secrets/IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
echo "$${STORAGE_ENCRYPTION_KEY}" > /run/secrets/STORAGE_ENCRYPTION_KEY echo "$${STORAGE_ENCRYPTION_KEY}" > /run/secrets/STORAGE_ENCRYPTION_KEY
@ -88,27 +77,14 @@ services:
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: login.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: app.bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe$$'"; echo " - '^/success(\\?.*)?$$'"; echo " - '^/webhook/stripe$$'"; echo " - '^/health$$'"; echo " - '^/version$$'"; echo ' - domain: app.bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/dashboard$$'"; echo " - '^/activate$$'"; echo " - '^/portal$$'"; echo " - '^/resubscribe$$'"; echo " - '^/stack-manage$$'"; echo ' - domain:'; echo ' - lldap.bc.a250.ca'; echo ' - whoami.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: "{user}.bc.a250.ca"'; echo ' policy: one_factor'; echo ' - domain: "*.bc.a250.ca"'; echo ' policy: deny'; } > /config/configuration.acl.yml
# Override configuration for local dev 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
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
X_AUTHELIA_EMAIL: authelia@a250.ca X_AUTHELIA_EMAIL: authelia@a250.ca
X_AUTHELIA_SITE_NAME: a250.ca X_AUTHELIA_SITE_NAME: a250.ca
X_AUTHELIA_CONFIG_FILTERS: template X_AUTHELIA_CONFIG_FILTERS: template
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: bc.a250.ca TRAEFIK_DOMAIN: bc.a250.ca
# 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=
SESSION_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE= SESSION_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
@ -151,25 +127,18 @@ services:
CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA= CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
volumes: volumes:
- authelia_data:/data - authelia_data:/data
ports:
- "9091:9091"
networks: networks:
- authelia_dev - authelia_dev
deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)" - "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)"
- "traefik.http.routers.authelia.entrypoints=web" - "traefik.http.routers.authelia.entrypoints=websecure"
- "traefik.http.routers.authelia.tls=true"
- "traefik.http.services.authelia.loadbalancer.server.port=9091" - "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.address=http://authelia:9091/api/verify?rd=https://login.bc.a250.ca/"
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true" - "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email" - "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
depends_on:
redis:
condition: service_healthy
mariadb:
condition: service_healthy
lldap:
condition: service_healthy
healthcheck: healthcheck:
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ] test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ]
start_period: 15s start_period: 15s
@ -179,75 +148,85 @@ services:
traefik: traefik:
image: traefik:v3.1 image: traefik:v3.1
container_name: authelia_traefik
command: command:
- "--api.insecure=true" - "--api.insecure=true"
- "--providers.docker=true" - "--providers.swarm=true"
- "--providers.docker.exposedbydefault=false" - "--providers.swarm.endpoint=unix:///var/run/docker.sock"
- "--providers.swarm.watch=true"
- "--providers.swarm.exposedbydefault=false"
- "--providers.swarm.network=atlas_authelia_dev"
- "--entrypoints.web.address=:80" - "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
ports: ports:
- "80:80" - "80:80"
- "443:443"
- "8080:8080" - "8080:8080"
volumes: volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro" - "/var/run/docker.sock:/var/run/docker.sock:ro"
networks: networks:
- authelia_dev - authelia_dev
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.traefik-dashboard.rule=Host(`traefik.bc.a250.ca`)"
- "traefik.http.routers.traefik-dashboard.entrypoints=websecure"
- "traefik.http.routers.traefik-dashboard.tls=true"
- "traefik.http.routers.traefik-dashboard.service=traefik-api"
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
ss-atlas: ss-atlas:
build: image: atlas-ss-atlas:latest
context: ./docker/ss-atlas/
dockerfile: Dockerfile
container_name: atlas_ss_app
environment: environment:
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder} - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-sk_test_placeholder}
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET:-whsec_placeholder}
- STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-price_placeholder} - STRIPE_PRICE_ID=${STRIPE_PRICE_ID:-price_placeholder}
- LLDAP_URL=ldap://lldap_lldap:3890 - LLDAP_URL=ldap://lldap:3890
- LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca - LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca
- LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0= - LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_BASE_DN=dc=a250,dc=ca - LLDAP_BASE_DN=dc=a250,dc=ca
- LLDAP_HTTP_URL=http://lldap:17170
- DOCKER_HOST=unix:///var/run/docker.sock - DOCKER_HOST=unix:///var/run/docker.sock
- APP_URL=http://app.bc.a250.ca - APP_URL=https://app.bc.a250.ca
- AUTHELIA_URL=http://login.bc.a250.ca - AUTHELIA_URL=https://login.bc.a250.ca
- AUTHELIA_INTERNAL_URL=http://authelia:9091
- TRAEFIK_DOMAIN=bc.a250.ca - TRAEFIK_DOMAIN=bc.a250.ca
- TRAEFIK_NETWORK=authelia_dev - TRAEFIK_NETWORK=authelia_dev
- CUSTOMER_DOMAIN=app.a250.ca
- TEMPLATE_PATH=/app/templates - TEMPLATE_PATH=/app/templates
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
networks: networks:
- authelia_dev - authelia_dev
deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)" - "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)"
- "traefik.http.routers.ss-atlas.entrypoints=web" - "traefik.http.routers.ss-atlas.entrypoints=websecure"
- "traefik.http.routers.ss-atlas.tls=true"
- "traefik.http.routers.ss-atlas.middlewares=authelia-auth@swarm"
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080" - "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
depends_on:
lldap:
condition: service_healthy
authelia:
condition: service_healthy
whoami: whoami:
image: traefik/whoami image: traefik/whoami
container_name: authelia_whoami
networks: networks:
- authelia_dev - authelia_dev
deploy:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)" - "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)"
- "traefik.http.routers.whoami.entrypoints=web" - "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.middlewares=authelia-auth@docker" - "traefik.http.routers.whoami.tls=true"
- "traefik.http.routers.whoami.middlewares=authelia-auth@swarm"
networks: networks:
authelia_dev: authelia_dev:
driver: bridge driver: overlay
attachable: true
volumes: volumes:
mariadb_data: mariadb_data:
driver: local
redis_data: redis_data:
driver: local
authelia_data: authelia_data:
driver: local
lldap_data: lldap_data:
driver: local