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:
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-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"
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)
- domain: 'app.{{ env "TRAEFIK_DOMAIN" }}'
policy: bypass

View File

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

View File

@ -1,3 +1,9 @@
notifier:
filesystem:
filename: /data/notification.txt
smtp:
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:
# path: /config/db.sqlite3
mysql:
address: 'tcp://authelia_mariadb:3306'
address: 'tcp://mariadb:3306'
database: authelia
username: authelia
## Password can also be set using a secret: https://www.authelia.com/docs/configuration/secrets.html
@ -59,6 +59,7 @@ session:
cookies:
- domain: {{ env "TRAEFIK_DOMAIN" }}
authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}'
default_redirection_url: 'https://app.{{ env "TRAEFIK_DOMAIN" }}/dashboard'
name: 'authelia_session'
same_site: 'lax'
inactivity: '5m'
@ -66,7 +67,7 @@ session:
remember_me: '1d'
redis:
host: 'authelia_redis'
host: 'redis'
port: 6379
database_index: 0
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
ARG BUILD_COMMIT=unknown
ARG BUILD_TIME=unknown
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
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
RUN apk add --no-cache ca-certificates
RUN apk add --no-cache ca-certificates docker-cli
WORKDIR /app
COPY --from=builder /ss-atlas /app/ss-atlas
COPY templates/ /app/templates/

View File

@ -14,9 +14,11 @@ import (
"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"
"git.nixc.us/a250/ss-atlas/internal/version"
)
func main() {
log.Printf("ss-atlas %s", version.String())
cfg := config.Load()
stripeClient := ssstripe.New(cfg)

View File

@ -6,6 +6,7 @@ type Config struct {
Port string
AppURL string
AutheliaURL string
AutheliaInternalURL string
StripeSecretKey string
StripeWebhookSecret string
StripePriceID string
@ -13,10 +14,12 @@ type Config struct {
LDAPAdminDN string
LDAPAdminPassword string
LDAPBaseDN string
LLDAPHttpURL string
DockerHost string
TraefikDomain string
TraefikNetwork string
TemplatePath string
TemplatePath string
CustomerDomain string
}
func Load() *Config {
@ -24,6 +27,7 @@ func Load() *Config {
Port: envOrDefault("PORT", "8080"),
AppURL: envOrDefault("APP_URL", "http://app.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", ""),
StripeWebhookSecret: envOrDefault("STRIPE_WEBHOOK_SECRET", ""),
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"),
LDAPAdminPassword: envOrDefault("LLDAP_ADMIN_PASSWORD", ""),
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"),
TraefikDomain: envOrDefault("TRAEFIK_DOMAIN", "bc.a250.ca"),
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
}

View File

@ -29,7 +29,7 @@ func TestLoadDefaults(t *testing.T) {
"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",
"DOCKER_HOST", "TRAEFIK_DOMAIN", "TRAEFIK_NETWORK", "TEMPLATE_PATH", "CUSTOMER_DOMAIN",
}
for _, k := range envKeys {
os.Unsetenv(k)
@ -54,9 +54,10 @@ func TestLoadDefaults(t *testing.T) {
{"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"},
{ "TraefikDomain", cfg.TraefikDomain, "bc.a250.ca"},
{"TraefikNetwork", cfg.TraefikNetwork, "authelia_dev"},
{"TemplatePath", cfg.TemplatePath, "/app/templates"},
{"CustomerDomain", cfg.CustomerDomain, "app.a250.ca"},
}
for _, tt := range tests {
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,23 +1,72 @@
package handlers
import (
"fmt"
"log"
"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) {
remoteUser := r.Header.Get("Remote-User")
remoteEmail := r.Header.Get("Remote-Email")
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{
"AppURL": a.cfg.AppURL,
"AutheliaURL": a.cfg.AutheliaURL,
"User": remoteUser,
"Email": remoteEmail,
"Groups": remoteGroups,
"Domain": a.cfg.TraefikDomain,
"IsSubscribed": remoteGroups != "" && contains(remoteGroups, "customers"),
"AppURL": a.cfg.AppURL,
"AutheliaURL": a.cfg.AutheliaURL,
"User": remoteUser,
"Email": remoteEmail,
"Groups": remoteGroups,
"Domain": a.cfg.TraefikDomain,
"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 {

View File

@ -1,6 +1,7 @@
package handlers
import (
"encoding/json"
"html/template"
"net/http"
"path/filepath"
@ -9,6 +10,7 @@ import (
"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"
"git.nixc.us/a250/ss-atlas/internal/version"
"github.com/go-chi/chi/v5"
"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.Post("/activate", app.handleActivatePost)
r.Get("/dashboard", app.handleDashboard)
r.Post("/stack-manage", app.handleStackManage)
r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/portal", app.handlePortal)
r.Post("/resubscribe", app.handleResubscribe)
r.Post("/webhook/stripe", app.handleWebhook)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
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
}

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
import (
"fmt"
"log"
"net/http"
"strings"
"git.nixc.us/a250/ss-atlas/internal/version"
)
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{
"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 {
log.Printf("template error: %v", err)
@ -56,8 +65,6 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
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)
@ -65,21 +72,50 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
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 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{
"Username": result.Username,
"IsNew": true,
"Email": email,
"LoginURL": a.cfg.AutheliaURL,
"ResetURL": a.cfg.AutheliaURL + "/#/reset-password/step1",
"ActivateURL": a.cfg.AppURL + "/activate",
"DashboardURL": a.cfg.AppURL + "/dashboard",
"InstanceURL": "https://" + result.Username + "." + a.cfg.CustomerDomain,
}
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
}
return
}
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError)
// 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) {
@ -99,14 +135,44 @@ func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
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 {
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
local := parts[0]
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]
}
return '-'
}, name)
return name
}
clean := func(s string) string {
return strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '-'
}, strings.ToLower(s))
}
return clean(local) + clean(domain)
}

View File

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

View File

@ -3,6 +3,7 @@ package ldap
import (
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"log"
@ -12,6 +13,7 @@ import (
type Client struct {
cfg *config.Config
gql *gqlClient
}
type ProvisionResult struct {
@ -21,7 +23,11 @@ type ProvisionResult struct {
}
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) {
@ -61,13 +67,22 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
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)
}
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)
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 {
conn, err := c.connect()
groupID, err := c.getGroupID(groupName)
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)
return fmt.Errorf("resolve group %s: %w", 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)
return nil
}
func (c *Client) RemoveFromGroup(username, groupName string) error {
conn, err := c.connect()
groupID, err := c.getGroupID(groupName)
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)
return fmt.Errorf("resolve group %s: %w", 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)
return nil
}
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 {
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
var result struct {
User struct {
Groups []struct {
DisplayName string `json:"displayName"`
} `json:"groups"`
} `json:"user"`
}
if err := json.Unmarshal(data, &result); err != nil {
return false, err
}
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) {
conn, err := c.connect()
func (c *Client) SetStripeCustomerID(username, customerID string) error {
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 {
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,
)
var result struct {
User struct {
Attributes []struct {
Name string `json:"name"`
Value []string `json:"value"`
} `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 {
return "", fmt.Errorf("ldap search by description: %w", err)
return "", err
}
if len(result.Entries) == 0 {
return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID)
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
}
return result.Entries[0].GetAttributeValue("uid"), nil
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)
}
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
}
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) {

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
import (
"log"
"time"
"git.nixc.us/a250/ss-atlas/internal/config"
stripego "github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
@ -8,6 +11,12 @@ import (
"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 {
cfg *config.Config
}
@ -33,6 +42,25 @@ func (c *Client) CreateCheckoutSession(email string) (*stripego.CheckoutSession,
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) {
params := &stripego.BillingPortalSessionParams{
Customer: stripego.String(customerID),
@ -52,6 +80,42 @@ func (c *Client) GetSubscription(subID string) (*stripego.Subscription, error) {
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 {
return c.cfg.StripeWebhookSecret
}

View File

@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"text/template"
@ -33,7 +34,7 @@ func (c *Client) DeployStack(stackName, username, domain string) error {
return fmt.Errorf("parse stack template: %w", err)
}
data := map[string]string{
data := map[string]any{
"ID": username,
"Subdomain": username,
"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)))
// 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
}
@ -95,3 +106,53 @@ func (c *Client) StackExists(stackName string) (bool, error) {
}
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; }
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--muted); }
.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>
</head>
<body>
@ -116,8 +141,18 @@
<h2>Subscription</h2>
<div class="status-row">
<span class="status-label">Status</span>
{{if .SubStatus}}
<span class="badge {{.SubStatus.Badge}}">{{.SubStatus.Label}}</span>
{{else}}
<span class="badge badge-active">Active</span>
{{end}}
</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">
<span class="status-label">Email</span>
<span class="status-value">{{.Email}}</span>
@ -125,16 +160,67 @@
</div>
<div class="card">
<h2>Your Stack</h2>
<p style="color: var(--muted); font-size: 0.9rem;">Your dedicated environment is live and accessible at:</p>
<a class="stack-link" href="http://{{.User}}.{{.Domain}}">{{.User}}.{{.Domain}}</a>
<div class="status-row">
<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 class="card">
<h2>Manage</h2>
<div class="actions">
<form method="POST" action="/portal" style="margin:0">
<input type="hidden" name="customer_id" value="">
<button type="submit" class="btn">Manage Subscription</button>
{{if and .SubStatus (eq .SubStatus.Label "Expired")}}
<form method="POST" action="/resubscribe" style="margin:0">
<input type="hidden" name="customer_id" value="{{.CustomerID}}">
<button type="submit" class="btn">Resubscribe</button>
</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>
</div>
<p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;">
@ -157,5 +243,6 @@
</div>
{{end}}
</div>
<div class="version-badge">{{.Commit}}</div>
</body>
</html>

View File

@ -78,6 +78,17 @@
button:hover { background: var(--accent-hover); }
.footer { margin-top: 2rem; color: var(--muted); font-size: 0.8rem; text-align: center; }
.footer a { color: var(--accent); text-decoration: none; }
.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>
</head>
<body>
@ -102,5 +113,6 @@
Already subscribed? <a href="/dashboard">Go to Dashboard</a>
</div>
</div>
<div class="version-badge">{{.Commit}}</div>
</body>
</html>

View File

@ -114,6 +114,34 @@
.actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
.returning { text-align: center; padding: 2rem; color: var(--muted); }
.returning p { margin-bottom: 1rem; }
.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>
</head>
<body>
@ -122,49 +150,63 @@
{{if .IsNew}}
<div class="subtitle">Payment successful — your account is ready!</div>
<div class="warning">
<strong>Temporary password.</strong> Use this to sign in for the first time, then you will be asked to reset it to something you choose.
</div>
<div class="card">
<h2>Your Temporary Login</h2>
<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">
<span class="cred-label">Username</span>
<span class="cred-value">{{.Username}}</span>
</div>
<div class="cred-row">
<span class="cred-label">Temporary Password</span>
<span class="cred-value">{{.Password}}</span>
<span class="cred-label">Your Instance</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 class="cred-row">
<span class="cred-label">Email</span>
<span class="cred-value">{{.Email}}</span>
</div>
</div>
<div class="card">
<h2>Getting Started</h2>
<ol class="steps">
<li>Copy your username and temporary password above</li>
<li>Click "Sign In" — you'll be taken to the login page</li>
<li>Log in with your temporary credentials</li>
<li>You'll be prompted to set a new password of your choice</li>
<li>Once signed in, visit the activation page to launch your stack</li>
</ol>
<div class="cred-row">
<span class="cred-label">Your Instance</span>
<span class="cred-value">{{.InstanceURL}}</span>
</div>
</div>
<div class="actions">
<a href="{{.ResetURL}}" class="btn">Sign In &amp; Set Password</a>
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
</div>
{{else}}
<div class="subtitle">Welcome back!</div>
<div class="card">
<div class="returning">
<p>Your account <strong>{{.Username}}</strong> is already set up.</p>
<a href="{{.LoginURL}}" class="btn">Sign In</a>
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
</div>
<a href="{{.ResetURL}}" class="btn">Reset Password</a>
<a href="{{.LoginURL}}" class="btn btn-outline">Sign In</a>
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
</div>
{{end}}
</div>

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"
services:
@ -11,10 +30,11 @@ services:
replicas: 1
labels:
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.entrypoints: "web"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@docker"
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
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"
restart_policy:
condition: on-failure
@ -34,7 +54,7 @@ services:
networks:
traefik_net:
external: true
name: "{{.TraefikNetwork}}"
name: "atlas_{{.TraefikNetwork}}"
backend:
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_CONFIG_FILTERS: template
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
TRAEFIK_DOMAIN: a250.ca
TRAEFIK_DOMAIN: bc.a250.ca
secrets:
AUTHENTICATION_BACKEND_LDAP_PASSWORD:
@ -51,6 +51,8 @@ volumes:
driver: local
authelia_mariadb_data:
driver: local
lldap_data:
driver: local
services:
authelia:
@ -60,6 +62,7 @@ services:
- --config=/config/configuration.server.yml
- --config=/config/configuration.ldap.yml
- --config=/config/configuration.acl.yml
- --config=/config/configuration.notifier.yml
secrets:
- AUTHENTICATION_BACKEND_LDAP_PASSWORD
- IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
@ -91,24 +94,19 @@ services:
restart_policy:
condition: on-failure
replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels:
us.a250.autodeploy: "true"
homepage.group: Infrastructure
homepage.name: Authelia
homepage.href: https://login.a250.ca
homepage.href: https://login.bc.a250.ca
homepage.description: ATLAS
traefik.enable: "true"
traefik.docker.network: traefik
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.rule: Host(`login.bc.a250.ca`)
traefik.http.routers.authelia_authelia.entrypoints: web
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.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.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
traefik.http.middlewares.authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic
@ -141,9 +139,6 @@ services:
restart_policy:
condition: on-failure
replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels:
us.a250.autodeploy: "true"
traefik.enable: "false"
@ -159,6 +154,26 @@ services:
max-size: 10m
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:
image: git.nixc.us/a250/authelia:production-mariadb
environment:
@ -178,9 +193,6 @@ services:
restart_policy:
condition: on-failure
replicas: 1
placement:
constraints:
- node.hostname == ingress.a250.ca
labels:
us.a250.autodeploy: "true"
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:
mariadb:
image: mariadb:latest
container_name: authelia_mariadb
environment:
MYSQL_ROOT_PASSWORD: dev_authelia_root
MYSQL_DATABASE: authelia
@ -9,7 +8,6 @@ services:
MYSQL_PASSWORD: authelia
volumes:
- mariadb_data:/var/lib/mysql
# No ports exposed - internal only
networks:
- authelia_dev
healthcheck:
@ -21,11 +19,9 @@ services:
redis:
image: redis:latest
container_name: authelia_redis
command: redis-server --appendonly yes
volumes:
- redis_data:/data
# No ports exposed - internal only
networks:
- authelia_dev
healthcheck:
@ -37,7 +33,6 @@ services:
lldap:
image: nitnelave/lldap:latest
container_name: lldap_lldap
volumes:
- lldap_data:/data
environment:
@ -46,16 +41,15 @@ services:
- 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
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"
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)"
- "traefik.http.routers.lldap.entrypoints=websecure"
- "traefik.http.routers.lldap.tls=true"
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:17170/health" ]
start_period: 10s
@ -64,17 +58,12 @@ services:
retries: 3
authelia:
build:
context: ./docker/authelia/
dockerfile: Dockerfile
image: git.nixc.us/a250/authelia:dev-authelia
container_name: authelia_dev_main
user: root
command:
- sh
- -c
- |
# Create the secrets directory and populate with environment variables
mkdir -p /run/secrets
echo "$${IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}" > /run/secrets/IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
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_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
{ 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
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@a250.ca
X_AUTHELIA_SITE_NAME: a250.ca
X_AUTHELIA_CONFIG_FILTERS: template
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=
SESSION_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
@ -151,25 +127,18 @@ services:
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
mariadb:
condition: service_healthy
lldap:
condition: service_healthy
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)"
- "traefik.http.routers.authelia.entrypoints=websecure"
- "traefik.http.routers.authelia.tls=true"
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
- "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.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
healthcheck:
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ]
start_period: 15s
@ -179,75 +148,85 @@ services:
traefik:
image: traefik:v3.1
container_name: authelia_traefik
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--providers.swarm=true"
- "--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.http.redirections.entrypoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
ports:
- "80:80"
- "443:443"
- "8080:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock:ro"
networks:
- 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:
build:
context: ./docker/ss-atlas/
dockerfile: Dockerfile
container_name: atlas_ss_app
image: atlas-ss-atlas:latest
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_URL=ldap://lldap:3890
- LLDAP_ADMIN_DN=uid=admin,ou=people,dc=a250,dc=ca
- LLDAP_ADMIN_PASSWORD=/ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_BASE_DN=dc=a250,dc=ca
- LLDAP_HTTP_URL=http://lldap:17170
- DOCKER_HOST=unix:///var/run/docker.sock
- APP_URL=http://app.bc.a250.ca
- AUTHELIA_URL=http://login.bc.a250.ca
- APP_URL=https://app.bc.a250.ca
- AUTHELIA_URL=https://login.bc.a250.ca
- AUTHELIA_INTERNAL_URL=http://authelia:9091
- TRAEFIK_DOMAIN=bc.a250.ca
- TRAEFIK_NETWORK=authelia_dev
- CUSTOMER_DOMAIN=app.a250.ca
- 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
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)"
- "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"
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"
deploy:
labels:
- "traefik.enable=true"
- "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)"
- "traefik.http.routers.whoami.entrypoints=websecure"
- "traefik.http.routers.whoami.tls=true"
- "traefik.http.routers.whoami.middlewares=authelia-auth@swarm"
networks:
authelia_dev:
driver: bridge
driver: overlay
attachable: true
volumes:
mariadb_data:
driver: local
redis_data:
driver: local
authelia_data:
driver: local
lldap_data:
driver: local