From b66dfa053e66fd147b35e3603096e24eef3a9d2d Mon Sep 17 00:00:00 2001 From: Leopere Date: Tue, 3 Mar 2026 15:51:25 -0500 Subject: [PATCH] Force auth on all customer stacks, migrate to swarm stack.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- docker-compose.swarm-dev.yml | 14 -- docker-compose.yml | 15 ++ .../config/configuration.notifier.yml | 10 +- .../authelia/config/configuration.server.yml | 1 + docker/ss-atlas/internal/config/config.go | 13 +- docker/ss-atlas/internal/handlers/activate.go | 2 +- docker/ss-atlas/internal/handlers/authelia.go | 45 +++++ .../ss-atlas/internal/handlers/dashboard.go | 63 +++++- docker/ss-atlas/internal/handlers/routes.go | 2 + docker/ss-atlas/internal/handlers/stack.go | 75 +++++++ .../internal/handlers/subscription.go | 104 ++++++++-- docker/ss-atlas/internal/handlers/webhook.go | 2 +- docker/ss-atlas/internal/ldap/client.go | 191 ++++++++++++------ docker/ss-atlas/internal/ldap/graphql.go | 114 +++++++++++ docker/ss-atlas/internal/stripe/client.go | 59 ++++++ docker/ss-atlas/internal/swarm/client.go | 63 +++++- .../ss-atlas/templates/pages/dashboard.html | 89 +++++++- docker/ss-atlas/templates/pages/welcome.html | 100 ++++++--- docker/ss-atlas/templates/stack-template.yml | 9 +- scripts/deploy-stack-dev.sh | 24 +-- scripts/local-dns-setup.sh | 2 +- docker-compose.dev.yml => stack.yml | 143 ++++++------- 22 files changed, 889 insertions(+), 251 deletions(-) delete mode 100644 docker-compose.swarm-dev.yml create mode 100644 docker-compose.yml create mode 100644 docker/ss-atlas/internal/handlers/authelia.go create mode 100644 docker/ss-atlas/internal/handlers/stack.go create mode 100644 docker/ss-atlas/internal/ldap/graphql.go rename docker-compose.dev.yml => stack.yml (63%) diff --git a/docker-compose.swarm-dev.yml b/docker-compose.swarm-dev.yml deleted file mode 100644 index e8b8f3e..0000000 --- a/docker-compose.swarm-dev.yml +++ /dev/null @@ -1,14 +0,0 @@ -# Override for local swarm: overlay network so customer stacks attach to Traefik. -# Traefik stays in container mode (swarmMode=false) so it sees both compose and stack containers. -# -# Usage: ./scripts/deploy-stack-dev.sh -# Customer stacks get clientname.app.a250.ca. Run scripts/local-dns-setup.sh for DNS. - -services: - ss-atlas: - environment: - - CUSTOMER_DOMAIN=app.a250.ca - -networks: - authelia_dev: - external: true diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c7881a5 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docker/authelia/config/configuration.notifier.yml b/docker/authelia/config/configuration.notifier.yml index 5aa6f33..c4af334 100644 --- a/docker/authelia/config/configuration.notifier.yml +++ b/docker/authelia/config/configuration.notifier.yml @@ -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 ' + subject: '[a250.ca] {title}' + disable_require_tls: false + disable_html_emails: false diff --git a/docker/authelia/config/configuration.server.yml b/docker/authelia/config/configuration.server.yml index e5e3ad1..0393bfb 100644 --- a/docker/authelia/config/configuration.server.yml +++ b/docker/authelia/config/configuration.server.yml @@ -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' diff --git a/docker/ss-atlas/internal/config/config.go b/docker/ss-atlas/internal/config/config.go index 37aea10..f7f0ba0 100644 --- a/docker/ss-atlas/internal/config/config.go +++ b/docker/ss-atlas/internal/config/config.go @@ -6,6 +6,7 @@ type Config struct { Port string AppURL string AutheliaURL string + AutheliaInternalURL string StripeSecretKey string StripeWebhookSecret string StripePriceID string @@ -13,11 +14,12 @@ type Config struct { LDAPAdminDN string LDAPAdminPassword string LDAPBaseDN string + LLDAPHttpURL string DockerHost string TraefikDomain string TraefikNetwork string - TemplatePath string - CustomerDomain string // e.g. app.a250.ca for clientname.app.a250.ca + TemplatePath string + CustomerDomain string } func Load() *Config { @@ -25,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", ""), @@ -32,11 +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"), - CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "app.a250.ca"), + TemplatePath: envOrDefault("TEMPLATE_PATH", "/app/templates"), + CustomerDomain: envOrDefault("CUSTOMER_DOMAIN", "app.a250.ca"), } } @@ -46,3 +50,4 @@ func envOrDefault(key, fallback string) string { } return fallback } + diff --git a/docker/ss-atlas/internal/handlers/activate.go b/docker/ss-atlas/internal/handlers/activate.go index 13d2d6d..6b5f8e3 100644 --- a/docker/ss-atlas/internal/handlers/activate.go +++ b/docker/ss-atlas/internal/handlers/activate.go @@ -55,7 +55,7 @@ func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) { } stackName := fmt.Sprintf("customer-%s", remoteUser) - if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.CustomerDomain); err != nil { + if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil { log.Printf("activate: stack deploy failed for %s: %v", remoteUser, err) } diff --git a/docker/ss-atlas/internal/handlers/authelia.go b/docker/ss-atlas/internal/handlers/authelia.go new file mode 100644 index 0000000..5cf6c7a --- /dev/null +++ b/docker/ss-atlas/internal/handlers/authelia.go @@ -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 +} diff --git a/docker/ss-atlas/internal/handlers/dashboard.go b/docker/ss-atlas/internal/handlers/dashboard.go index 4f53258..083d8a9 100644 --- a/docker/ss-atlas/internal/handlers/dashboard.go +++ b/docker/ss-atlas/internal/handlers/dashboard.go @@ -1,9 +1,11 @@ package handlers import ( + "fmt" "log" "net/http" + ssstripe "git.nixc.us/a250/ss-atlas/internal/stripe" "git.nixc.us/a250/ss-atlas/internal/version" ) @@ -11,17 +13,60 @@ 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"), - "Commit": version.Commit, - "BuildTime": version.BuildTime, + "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 { diff --git a/docker/ss-atlas/internal/handlers/routes.go b/docker/ss-atlas/internal/handlers/routes.go index c0aaa19..1c399b3 100644 --- a/docker/ss-atlas/internal/handlers/routes.go +++ b/docker/ss-atlas/internal/handlers/routes.go @@ -45,8 +45,10 @@ 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) diff --git a/docker/ss-atlas/internal/handlers/stack.go b/docker/ss-atlas/internal/handlers/stack.go new file mode 100644 index 0000000..5698361 --- /dev/null +++ b/docker/ss-atlas/internal/handlers/stack.go @@ -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) +} diff --git a/docker/ss-atlas/internal/handlers/subscription.go b/docker/ss-atlas/internal/handlers/subscription.go index da88960..14e50dc 100644 --- a/docker/ss-atlas/internal/handlers/subscription.go +++ b/docker/ss-atlas/internal/handlers/subscription.go @@ -1,6 +1,7 @@ package handlers import ( + "fmt" "log" "net/http" "strings" @@ -9,6 +10,10 @@ import ( ) 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, "Commit": version.Commit, @@ -60,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) @@ -69,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) { @@ -103,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) } diff --git a/docker/ss-atlas/internal/handlers/webhook.go b/docker/ss-atlas/internal/handlers/webhook.go index 5c8991c..ca87e2d 100644 --- a/docker/ss-atlas/internal/handlers/webhook.go +++ b/docker/ss-atlas/internal/handlers/webhook.go @@ -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 diff --git a/docker/ss-atlas/internal/ldap/client.go b/docker/ss-atlas/internal/ldap/client.go index 1531610..33dd805 100644 --- a/docker/ss-atlas/internal/ldap/client.go +++ b/docker/ss-atlas/internal/ldap/client.go @@ -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) { diff --git a/docker/ss-atlas/internal/ldap/graphql.go b/docker/ss-atlas/internal/ldap/graphql.go new file mode 100644 index 0000000..0679e17 --- /dev/null +++ b/docker/ss-atlas/internal/ldap/graphql.go @@ -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 +} diff --git a/docker/ss-atlas/internal/stripe/client.go b/docker/ss-atlas/internal/stripe/client.go index 1ad872f..a5c0225 100644 --- a/docker/ss-atlas/internal/stripe/client.go +++ b/docker/ss-atlas/internal/stripe/client.go @@ -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,37 @@ 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 { + var cancelAt string + if sub.CancelAt > 0 { + cancelAt = time.Unix(sub.CancelAt, 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 } diff --git a/docker/ss-atlas/internal/swarm/client.go b/docker/ss-atlas/internal/swarm/client.go index 67c3739..26228ee 100644 --- a/docker/ss-atlas/internal/swarm/client.go +++ b/docker/ss-atlas/internal/swarm/client.go @@ -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 _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 _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 +} diff --git a/docker/ss-atlas/templates/pages/dashboard.html b/docker/ss-atlas/templates/pages/dashboard.html index b5b3476..e1dd82c 100644 --- a/docker/ss-atlas/templates/pages/dashboard.html +++ b/docker/ss-atlas/templates/pages/dashboard.html @@ -101,6 +101,20 @@ .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; @@ -127,8 +141,18 @@

Subscription

Status + {{if .SubStatus}} + {{.SubStatus.Label}} + {{else}} Active + {{end}}
+ {{if and .SubStatus .SubStatus.CancelAt}} +
+ Access until + {{.SubStatus.CancelAt}} +
+ {{end}}
Email {{.Email}} @@ -136,16 +160,71 @@

Your Stack

-

Your dedicated environment is live and accessible at:

- {{.User}}.{{.Domain}} +
+ Status + {{if .StackRunning}} + Running + {{else if .StackDeployed}} + Stopped + {{else}} + Not deployed + {{end}} +
+ {{if .StackRunning}} +

Your dedicated environment is accessible at:

+ {{.User}}.{{.Domain}} + {{else if not .StackDeployed}} +

Your stack is being provisioned. Refresh this page in a moment.

+ {{end}} + + {{if .StackDeployed}} +
+
+ {{if .StackRunning}} +
+ + +
+
+ + +
+ {{else}} +
+ + +
+ {{end}} +
+ + +
+
+ + +
+
+ {{end}}

Manage

-
- - + {{if and .SubStatus (eq .SubStatus.Label "Expired")}} + + +
+ {{else}} +
+ + {{if and .SubStatus (eq .SubStatus.Label "Expiring")}} + + {{else}} + + {{end}} +
+ {{end}} Account Settings

diff --git a/docker/ss-atlas/templates/pages/welcome.html b/docker/ss-atlas/templates/pages/welcome.html index b5ad42a..387c8c3 100644 --- a/docker/ss-atlas/templates/pages/welcome.html +++ b/docker/ss-atlas/templates/pages/welcome.html @@ -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; + } @@ -122,49 +150,63 @@ {{if .IsNew}}

Payment successful — your account is ready!
-
- Temporary password. Use this to sign in for the first time, then you will be asked to reset it to something you choose. -
-
-

Your Temporary Login

+

Check your email

+

+ We've sent a password setup link to {{.Email}}.
+ Click the link in that email to set your password and log in. +

Username {{.Username}}
- Temporary Password - {{.Password}} + Your Instance + {{.InstanceURL}} +
+
+ +
+

Next Steps

+
    +
  1. Open the email sent to {{.Email}} and click the link
  2. +
  3. Set your password
  4. +
  5. Return here and click Activate Stack to go live
  6. +
  7. Your instance will be at {{.InstanceURL}}
  8. +
+
+ + + {{else}} +
Welcome back!
+ +
+ Credentials not shown. Your account {{.Username}} already exists — we cannot display your original password again. Use the link below if you need to reset it. +
+ +
+

Account Details

+
+ Username + {{.Username}}
Email {{.Email}}
-
- -
-

Getting Started

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

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

- Sign In - Dashboard -
+ Reset Password + Sign In + Dashboard
{{end}}
diff --git a/docker/ss-atlas/templates/stack-template.yml b/docker/ss-atlas/templates/stack-template.yml index e33e456..42aaad8 100644 --- a/docker/ss-atlas/templates/stack-template.yml +++ b/docker/ss-atlas/templates/stack-template.yml @@ -11,10 +11,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 +35,7 @@ services: networks: traefik_net: external: true - name: "{{.TraefikNetwork}}" + name: "atlas_{{.TraefikNetwork}}" backend: driver: overlay diff --git a/scripts/deploy-stack-dev.sh b/scripts/deploy-stack-dev.sh index 1d98485..d21982f 100755 --- a/scripts/deploy-stack-dev.sh +++ b/scripts/deploy-stack-dev.sh @@ -1,10 +1,11 @@ #!/bin/sh -# Deploy ss-atlas + infra in local swarm mode for testing subscribe → deploy → teardown. -# Customer stacks get clientname.app.a250.ca. Run scripts/local-dns-setup.sh first for DNS. +# 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 @@ -17,22 +18,17 @@ echo "=== Building commit $BUILD_COMMIT ===" echo "=== Ensuring swarm mode ===" docker info --format '{{.Swarm.LocalNodeState}}' | grep -q "active" || docker swarm init -echo "=== Creating overlay network for customer stacks ===" +echo "=== Creating overlay network ===" docker network inspect authelia_dev >/dev/null 2>&1 || \ docker network create -d overlay --attachable authelia_dev -echo "=== Building ss-atlas ===" -docker compose -f docker-compose.dev.yml build \ +echo "=== Building images ===" +docker compose build \ --build-arg BUILD_COMMIT="$BUILD_COMMIT" \ - --build-arg BUILD_TIME="$BUILD_TIME" \ - ss-atlas + --build-arg BUILD_TIME="$BUILD_TIME" -echo "=== Deploying with swarm overlay ===" -docker compose -f docker-compose.dev.yml -f docker-compose.swarm-dev.yml up -d +echo "=== Deploying stack ===" +docker stack deploy -c stack.yml atlas echo "" -echo "=== Ready. Test flow: ===" -echo " 1. Add /etc/hosts or dnsmasq: *.app.a250.ca, app.bc.a250.ca, login.bc.a250.ca -> 127.0.0.1" -echo " 2. Visit http://app.bc.a250.ca, subscribe (Stripe test), activate" -echo " 3. After activate, customer stack deploys -> http://.app.a250.ca" -echo " 4. Cancel subscription -> webhook tears down stack" +echo "=== Ready. Visit https://app.bc.a250.ca ===" diff --git a/scripts/local-dns-setup.sh b/scripts/local-dns-setup.sh index f4837df..1153bf0 100755 --- a/scripts/local-dns-setup.sh +++ b/scripts/local-dns-setup.sh @@ -12,7 +12,7 @@ echo " And point your Mac to use 127.0.0.1 for DNS, or add to /etc/resolvers/ap 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" +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 "" diff --git a/docker-compose.dev.yml b/stack.yml similarity index 63% rename from docker-compose.dev.yml rename to stack.yml index 87ebfaf..619f3e6 100644 --- a/docker-compose.dev.yml +++ b/stack.yml @@ -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,39 +148,49 @@ 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 - args: - BUILD_COMMIT: ${BUILD_COMMIT:-unknown} - BUILD_TIME: ${BUILD_TIME:-unknown} - 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 @@ -220,38 +199,34 @@ services: - /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