forked from Nixius/authelia
1
0
Fork 0

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
This commit is contained in:
Leopere 2026-03-03 15:51:25 -05:00
parent 6fcdd1262d
commit b66dfa053e
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
22 changed files with 889 additions and 251 deletions

View File

@ -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

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

View File

@ -59,6 +59,7 @@ session:
cookies: cookies:
- domain: {{ env "TRAEFIK_DOMAIN" }} - domain: {{ env "TRAEFIK_DOMAIN" }}
authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}' authelia_url: 'https://login.{{ env "TRAEFIK_DOMAIN" }}'
default_redirection_url: 'https://app.{{ env "TRAEFIK_DOMAIN" }}/dashboard'
name: 'authelia_session' name: 'authelia_session'
same_site: 'lax' same_site: 'lax'
inactivity: '5m' inactivity: '5m'

View File

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

View File

@ -55,7 +55,7 @@ func (a *App) handleActivatePost(w http.ResponseWriter, r *http.Request) {
} }
stackName := fmt.Sprintf("customer-%s", remoteUser) 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) log.Printf("activate: stack deploy failed for %s: %v", remoteUser, err)
} }

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

View File

@ -45,8 +45,10 @@ func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swa
r.Get("/activate", app.handleActivateGet) r.Get("/activate", app.handleActivateGet)
r.Post("/activate", app.handleActivatePost) r.Post("/activate", app.handleActivatePost)
r.Get("/dashboard", app.handleDashboard) r.Get("/dashboard", app.handleDashboard)
r.Post("/stack-manage", app.handleStackManage)
r.Post("/subscribe", app.handleCreateCheckout) r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/portal", app.handlePortal) r.Post("/portal", app.handlePortal)
r.Post("/resubscribe", app.handleResubscribe)
r.Post("/webhook/stripe", app.handleWebhook) r.Post("/webhook/stripe", app.handleWebhook)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) { r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

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

View File

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

View File

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

View File

@ -1,6 +1,9 @@
package stripe package stripe
import ( import (
"log"
"time"
"git.nixc.us/a250/ss-atlas/internal/config" "git.nixc.us/a250/ss-atlas/internal/config"
stripego "github.com/stripe/stripe-go/v84" stripego "github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session" portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
@ -8,6 +11,12 @@ import (
"github.com/stripe/stripe-go/v84/subscription" "github.com/stripe/stripe-go/v84/subscription"
) )
type SubscriptionStatus struct {
Label string // "Active", "Cancels soon", etc.
Badge string // "badge-active", "badge-inactive", etc.
CancelAt string // empty or formatted date
}
type Client struct { type Client struct {
cfg *config.Config cfg *config.Config
} }
@ -33,6 +42,25 @@ func (c *Client) CreateCheckoutSession(email string) (*stripego.CheckoutSession,
return checkoutsession.New(params) return checkoutsession.New(params)
} }
// CreateCheckoutForCustomer creates a new subscription checkout for an existing
// Stripe customer (e.g. resubscribe after expiry). The new sub is linked to the
// same customer record so payment methods and history are preserved.
func (c *Client) CreateCheckoutForCustomer(customerID string) (*stripego.CheckoutSession, error) {
params := &stripego.CheckoutSessionParams{
Mode: stripego.String(string(stripego.CheckoutSessionModeSubscription)),
LineItems: []*stripego.CheckoutSessionLineItemParams{
{
Price: stripego.String(c.cfg.StripePriceID),
Quantity: stripego.Int64(1),
},
},
Customer: stripego.String(customerID),
SuccessURL: stripego.String(c.cfg.AppURL + "/success?session_id={CHECKOUT_SESSION_ID}"),
CancelURL: stripego.String(c.cfg.AppURL + "/dashboard"),
}
return checkoutsession.New(params)
}
func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) { func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
params := &stripego.BillingPortalSessionParams{ params := &stripego.BillingPortalSessionParams{
Customer: stripego.String(customerID), Customer: stripego.String(customerID),
@ -52,6 +80,37 @@ func (c *Client) GetSubscription(subID string) (*stripego.Subscription, error) {
return subscription.Get(subID, nil) return subscription.Get(subID, nil)
} }
func (c *Client) GetCustomerSubscriptionStatus(customerID string) *SubscriptionStatus {
if customerID == "" {
return &SubscriptionStatus{Label: "Active", Badge: "badge-active"}
}
params := &stripego.SubscriptionListParams{
Customer: stripego.String(customerID),
Status: stripego.String(string(stripego.SubscriptionStatusActive)),
}
iter := subscription.List(params)
if iter.Next() {
sub := iter.Subscription()
log.Printf("stripe: customer=%s sub=%s cancel_at_period_end=%v cancel_at=%d",
customerID, sub.ID, sub.CancelAtPeriodEnd, sub.CancelAt)
if sub.CancelAtPeriodEnd {
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 { func (c *Client) WebhookSecret() string {
return c.cfg.StripeWebhookSecret return c.cfg.StripeWebhookSecret
} }

View File

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

View File

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

View File

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

View File

@ -11,10 +11,11 @@ services:
replicas: 1 replicas: 1
labels: labels:
traefik.enable: "true" traefik.enable: "true"
traefik.docker.network: "{{.TraefikNetwork}}" traefik.docker.network: "atlas_{{.TraefikNetwork}}"
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Subdomain}}.{{.Domain}}`)" traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Subdomain}}.{{.Domain}}`)"
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "web" traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@docker" traefik.http.routers.customer-{{.ID}}-web.tls: "true"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "authelia-auth@swarm"
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "80" traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "80"
restart_policy: restart_policy:
condition: on-failure condition: on-failure
@ -34,7 +35,7 @@ services:
networks: networks:
traefik_net: traefik_net:
external: true external: true
name: "{{.TraefikNetwork}}" name: "atlas_{{.TraefikNetwork}}"
backend: backend:
driver: overlay driver: overlay

View File

@ -1,10 +1,11 @@
#!/bin/sh #!/bin/sh
# Deploy ss-atlas + infra in local swarm mode for testing subscribe → deploy → teardown. # Build images and deploy ATLAS stack to local swarm.
# Customer stacks get clientname.app.a250.ca. Run scripts/local-dns-setup.sh first for DNS.
set -e set -e
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
[ -f .env ] && set -a && . .env && set +a
if [ -n "$(git status --porcelain)" ]; then if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2 echo "ERROR: Working tree is dirty. Commit your changes before deploying." >&2
exit 1 exit 1
@ -17,22 +18,17 @@ echo "=== Building commit $BUILD_COMMIT ==="
echo "=== Ensuring swarm mode ===" echo "=== Ensuring swarm mode ==="
docker info --format '{{.Swarm.LocalNodeState}}' | grep -q "active" || docker swarm init 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 inspect authelia_dev >/dev/null 2>&1 || \
docker network create -d overlay --attachable authelia_dev docker network create -d overlay --attachable authelia_dev
echo "=== Building ss-atlas ===" echo "=== Building images ==="
docker compose -f docker-compose.dev.yml build \ docker compose build \
--build-arg BUILD_COMMIT="$BUILD_COMMIT" \ --build-arg BUILD_COMMIT="$BUILD_COMMIT" \
--build-arg BUILD_TIME="$BUILD_TIME" \ --build-arg BUILD_TIME="$BUILD_TIME"
ss-atlas
echo "=== Deploying with swarm overlay ===" echo "=== Deploying stack ==="
docker compose -f docker-compose.dev.yml -f docker-compose.swarm-dev.yml up -d docker stack deploy -c stack.yml atlas
echo "" echo ""
echo "=== Ready. Test flow: ===" echo "=== Ready. Visit https://app.bc.a250.ca ==="
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://<username>.app.a250.ca"
echo " 4. Cancel subscription -> webhook tears down stack"

View File

@ -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 ""
echo "Option 2: /etc/hosts (manual per-subdomain)" echo "Option 2: /etc/hosts (manual per-subdomain)"
echo " Add lines for each host you need:" 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 " 127.0.0.1 testuser.app.a250.ca"
echo " /etc/hosts does NOT support wildcards." echo " /etc/hosts does NOT support wildcards."
echo "" echo ""

View File

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