forked from Nixius/authelia
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:
parent
6fcdd1262d
commit
b66dfa053e
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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,6 +35,7 @@ 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"),
|
||||||
|
|
@ -46,3 +50,4 @@ func envOrDefault(key, fallback string) string {
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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,6 +13,45 @@ 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,
|
||||||
|
|
@ -19,7 +60,11 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
"Email": remoteEmail,
|
"Email": remoteEmail,
|
||||||
"Groups": remoteGroups,
|
"Groups": remoteGroups,
|
||||||
"Domain": a.cfg.TraefikDomain,
|
"Domain": a.cfg.TraefikDomain,
|
||||||
"IsSubscribed": remoteGroups != "" && contains(remoteGroups, "customers"),
|
"IsSubscribed": isSubscribed,
|
||||||
|
"CustomerID": customerID,
|
||||||
|
"SubStatus": subStatus,
|
||||||
|
"StackDeployed": stackDeployed,
|
||||||
|
"StackRunning": stackRunning,
|
||||||
"Commit": version.Commit,
|
"Commit": version.Commit,
|
||||||
"BuildTime": version.BuildTime,
|
"BuildTime": version.BuildTime,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if result.IsNew {
|
||||||
|
// New user: send password setup email, show onboarding page.
|
||||||
|
// Group membership and stack deploy happen on /activate after they set a password.
|
||||||
|
if err := a.triggerPasswordReset(result.Username); err != nil {
|
||||||
|
log.Printf("authelia reset trigger failed for %s: %v", username, err)
|
||||||
|
}
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
"Username": result.Username,
|
"Username": result.Username,
|
||||||
"Password": result.Password,
|
"IsNew": true,
|
||||||
"IsNew": result.IsNew,
|
|
||||||
"Email": email,
|
"Email": email,
|
||||||
"LoginURL": a.cfg.AutheliaURL,
|
"LoginURL": a.cfg.AutheliaURL,
|
||||||
"ResetURL": a.cfg.AutheliaURL + "/#/reset-password/step1",
|
"ResetURL": a.cfg.AutheliaURL + "/#/reset-password/step1",
|
||||||
"ActivateURL": a.cfg.AppURL + "/activate",
|
"ActivateURL": a.cfg.AppURL + "/activate",
|
||||||
"DashboardURL": a.cfg.AppURL + "/dashboard",
|
"DashboardURL": a.cfg.AppURL + "/dashboard",
|
||||||
|
"InstanceURL": "https://" + result.Username + "." + a.cfg.CustomerDomain,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
|
if err := a.tmpl.ExecuteTemplate(w, "welcome.html", data); err != nil {
|
||||||
log.Printf("template error: %v", err)
|
log.Printf("template error: %v", err)
|
||||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing user resubscribing: re-add to customers group if needed and
|
||||||
|
// ensure their stack is running, then send straight to dashboard.
|
||||||
|
inGroup, _ := a.ldap.IsInGroup(result.Username, "customers")
|
||||||
|
if !inGroup {
|
||||||
|
if err := a.ldap.AddToGroup(result.Username, "customers"); err != nil {
|
||||||
|
log.Printf("resubscribe: add to group failed for %s: %v", result.Username, err)
|
||||||
|
} else {
|
||||||
|
log.Printf("resubscribe: re-added %s to customers group", result.Username)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stackName := fmt.Sprintf("customer-%s", result.Username)
|
||||||
|
exists, _ := a.swarm.StackExists(stackName)
|
||||||
|
if !exists {
|
||||||
|
if err := a.swarm.DeployStack(stackName, result.Username, a.cfg.TraefikDomain); err != nil {
|
||||||
|
log.Printf("resubscribe: stack deploy failed for %s: %v", result.Username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("resubscribe: %s payment verified, redirecting to dashboard", result.Username)
|
||||||
|
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
|
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -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 len(parts) == 2 {
|
||||||
|
// Use second-level domain only (e.g. "nixc" from "nixc.us", "gmail" from "gmail.com")
|
||||||
|
domainParts := strings.Split(parts[1], ".")
|
||||||
|
if len(domainParts) >= 2 {
|
||||||
|
domain = "-" + domainParts[len(domainParts)-2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clean := func(s string) string {
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
return '-'
|
return '-'
|
||||||
}, name)
|
}, strings.ToLower(s))
|
||||||
return name
|
}
|
||||||
|
return clean(local) + clean(domain)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package ldap
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ import (
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
|
gql *gqlClient
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProvisionResult struct {
|
type ProvisionResult struct {
|
||||||
|
|
@ -21,7 +23,11 @@ type ProvisionResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(cfg *config.Config) *Client {
|
func New(cfg *config.Config) *Client {
|
||||||
return &Client{cfg: cfg}
|
adminUID := "admin"
|
||||||
|
return &Client{
|
||||||
|
cfg: cfg,
|
||||||
|
gql: newGQLClient(cfg.LLDAPHttpURL, adminUID, cfg.LDAPAdminPassword),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) connect() (*goldap.Conn, error) {
|
func (c *Client) connect() (*goldap.Conn, error) {
|
||||||
|
|
@ -61,13 +67,22 @@ func (c *Client) ProvisionUser(username, email, stripeCustomerID string) (*Provi
|
||||||
addReq.Attribute("sn", []string{username})
|
addReq.Attribute("sn", []string{username})
|
||||||
addReq.Attribute("uid", []string{username})
|
addReq.Attribute("uid", []string{username})
|
||||||
addReq.Attribute("mail", []string{email})
|
addReq.Attribute("mail", []string{email})
|
||||||
addReq.Attribute("userPassword", []string{password})
|
|
||||||
addReq.Attribute("description", []string{stripeCustomerID})
|
|
||||||
|
|
||||||
if err := conn.Add(addReq); err != nil {
|
if err := conn.Add(addReq); err != nil {
|
||||||
return nil, fmt.Errorf("ldap add user %s: %w", username, err)
|
return nil, fmt.Errorf("ldap add user %s: %w", username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pwReq := goldap.NewPasswordModifyRequest(userDN, "", password)
|
||||||
|
if _, err := conn.PasswordModify(pwReq); err != nil {
|
||||||
|
return nil, fmt.Errorf("ldap set password for %s: %w", username, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if stripeCustomerID != "" {
|
||||||
|
if err := c.SetStripeCustomerID(username, stripeCustomerID); err != nil {
|
||||||
|
log.Printf("warning: failed to set stripe customer id for %s: %v", username, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log.Printf("created ldap user %s (%s)", username, email)
|
log.Printf("created ldap user %s (%s)", username, email)
|
||||||
return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil
|
return &ProvisionResult{Username: username, Password: password, IsNew: true}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -78,98 +93,152 @@ func (c *Client) EnsureUser(username, email, stripeCustomerID string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) AddToGroup(username, groupName string) error {
|
func (c *Client) AddToGroup(username, groupName string) error {
|
||||||
conn, err := c.connect()
|
groupID, err := c.getGroupID(groupName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("resolve group %s: %w", groupName, err)
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupName, c.cfg.LDAPBaseDN)
|
|
||||||
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
|
|
||||||
|
|
||||||
modReq := goldap.NewModifyRequest(groupDN, nil)
|
|
||||||
modReq.Add("member", []string{userDN})
|
|
||||||
|
|
||||||
if err := conn.Modify(modReq); err != nil {
|
|
||||||
return fmt.Errorf("ldap add %s to group %s: %w", username, groupName, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query := `mutation($userId: String!, $groupId: Int!) { addUserToGroup(userId: $userId, groupId: $groupId) { ok } }`
|
||||||
|
_, err = c.gql.exec(query, map[string]any{"userId": username, "groupId": groupID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("add %s to group %s: %w", username, groupName, err)
|
||||||
|
}
|
||||||
log.Printf("added %s to group %s", username, groupName)
|
log.Printf("added %s to group %s", username, groupName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) RemoveFromGroup(username, groupName string) error {
|
func (c *Client) RemoveFromGroup(username, groupName string) error {
|
||||||
conn, err := c.connect()
|
groupID, err := c.getGroupID(groupName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("resolve group %s: %w", groupName, err)
|
||||||
}
|
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupName, c.cfg.LDAPBaseDN)
|
|
||||||
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
|
|
||||||
|
|
||||||
modReq := goldap.NewModifyRequest(groupDN, nil)
|
|
||||||
modReq.Delete("member", []string{userDN})
|
|
||||||
|
|
||||||
if err := conn.Modify(modReq); err != nil {
|
|
||||||
return fmt.Errorf("ldap remove %s from group %s: %w", username, groupName, err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query := `mutation($userId: String!, $groupId: Int!) { removeUserFromGroup(userId: $userId, groupId: $groupId) { ok } }`
|
||||||
|
_, err = c.gql.exec(query, map[string]any{"userId": username, "groupId": groupID})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("remove %s from group %s: %w", username, groupName, err)
|
||||||
|
}
|
||||||
log.Printf("removed %s from group %s", username, groupName)
|
log.Printf("removed %s from group %s", username, groupName)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) IsInGroup(username, groupName string) (bool, error) {
|
func (c *Client) IsInGroup(username, groupName string) (bool, error) {
|
||||||
conn, err := c.connect()
|
query := `query($userId: String!) { user(userId: $userId) { groups { displayName } } }`
|
||||||
|
data, err := c.gql.exec(query, map[string]any{"userId": username})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
groupDN := fmt.Sprintf("cn=%s,ou=groups,%s", groupName, c.cfg.LDAPBaseDN)
|
var result struct {
|
||||||
userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
|
User struct {
|
||||||
|
Groups []struct {
|
||||||
searchReq := goldap.NewSearchRequest(
|
DisplayName string `json:"displayName"`
|
||||||
groupDN,
|
} `json:"groups"`
|
||||||
goldap.ScopeBaseObject, goldap.NeverDerefAliases, 1, 0, false,
|
} `json:"user"`
|
||||||
fmt.Sprintf("(member=%s)", goldap.EscapeFilter(userDN)),
|
}
|
||||||
[]string{"cn"},
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
nil,
|
return false, err
|
||||||
)
|
|
||||||
|
|
||||||
result, err := conn.Search(searchReq)
|
|
||||||
if err != nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return len(result.Entries) > 0, nil
|
for _, g := range result.User.Groups {
|
||||||
|
if g.DisplayName == groupName {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) FindUserByDescription(stripeCustomerID string) (string, error) {
|
func (c *Client) SetStripeCustomerID(username, customerID string) error {
|
||||||
conn, err := c.connect()
|
query := `mutation($userId: String!, $attrs: [AttributeValueInput!]!) {
|
||||||
|
updateUser(user: { id: $userId, insertAttributes: $attrs }) { ok }
|
||||||
|
}`
|
||||||
|
attrs := []map[string]any{
|
||||||
|
{"name": "stripe-customer-id", "value": []string{customerID}},
|
||||||
|
}
|
||||||
|
_, err := c.gql.exec(query, map[string]any{"userId": username, "attrs": attrs})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStripeCustomerID(username string) (string, error) {
|
||||||
|
query := `query($userId: String!) { user(userId: $userId) { attributes { name value } } }`
|
||||||
|
data, err := c.gql.exec(query, map[string]any{"userId": username})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
|
||||||
|
|
||||||
searchReq := goldap.NewSearchRequest(
|
var result struct {
|
||||||
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),
|
User struct {
|
||||||
goldap.ScopeWholeSubtree, goldap.NeverDerefAliases, 1, 0, false,
|
Attributes []struct {
|
||||||
fmt.Sprintf("(description=%s)", goldap.EscapeFilter(stripeCustomerID)),
|
Name string `json:"name"`
|
||||||
[]string{"uid"},
|
Value []string `json:"value"`
|
||||||
nil,
|
} `json:"attributes"`
|
||||||
)
|
} `json:"user"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
result, err := conn.Search(searchReq)
|
for _, attr := range result.User.Attributes {
|
||||||
|
if attr.Name == "stripe-customer-id" && len(attr.Value) > 0 {
|
||||||
|
return attr.Value[0], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) FindUserByStripeID(stripeCustomerID string) (string, error) {
|
||||||
|
query := `query { users(filters: {}) { id attributes { name value } } }`
|
||||||
|
data, err := c.gql.exec(query, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("ldap search by description: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(result.Entries) == 0 {
|
var result struct {
|
||||||
|
Users []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Attributes []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Value []string `json:"value"`
|
||||||
|
} `json:"attributes"`
|
||||||
|
} `json:"users"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range result.Users {
|
||||||
|
for _, attr := range u.Attributes {
|
||||||
|
if attr.Name == "stripe-customer-id" && len(attr.Value) > 0 && attr.Value[0] == stripeCustomerID {
|
||||||
|
return u.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID)
|
return "", fmt.Errorf("no user found with stripe customer %s", stripeCustomerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) getGroupID(groupName string) (int, error) {
|
||||||
|
query := `query { groups { id displayName } }`
|
||||||
|
data, err := c.gql.exec(query, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.Entries[0].GetAttributeValue("uid"), nil
|
var result struct {
|
||||||
|
Groups []struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
|
} `json:"groups"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &result); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range result.Groups {
|
||||||
|
if g.DisplayName == groupName {
|
||||||
|
return g.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, fmt.Errorf("group %s not found", groupName)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
|
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;">
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,34 @@
|
||||||
.actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
.actions { margin-top: 1.5rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||||
.returning { text-align: center; padding: 2rem; color: var(--muted); }
|
.returning { text-align: center; padding: 2rem; color: var(--muted); }
|
||||||
.returning p { margin-bottom: 1rem; }
|
.returning p { margin-bottom: 1rem; }
|
||||||
|
.download-gate {
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
border: 1px solid rgba(99, 102, 241, 0.35);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.download-gate strong { color: var(--accent-hover); }
|
||||||
|
.btn-download {
|
||||||
|
background: var(--green);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.btn-download:hover { background: #16a34a; }
|
||||||
|
.btn-download.downloaded {
|
||||||
|
background: #166534;
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.nav-actions { margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 0.75rem; }
|
||||||
|
.nav-actions a.locked {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.35;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
@ -122,50 +150,64 @@
|
||||||
{{if .IsNew}}
|
{{if .IsNew}}
|
||||||
<div class="subtitle">Payment successful — your account is ready!</div>
|
<div class="subtitle">Payment successful — your account is ready!</div>
|
||||||
|
|
||||||
<div class="warning">
|
|
||||||
<strong>Temporary password.</strong> Use this to sign in for the first time, then you will be asked to reset it to something you choose.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Your Temporary Login</h2>
|
<h2>Check your email</h2>
|
||||||
|
<p style="color:var(--muted);font-size:0.95rem;line-height:1.6;margin-bottom:1rem;">
|
||||||
|
We've sent a password setup link to <strong style="color:var(--text)">{{.Email}}</strong>.<br>
|
||||||
|
Click the link in that email to set your password and log in.
|
||||||
|
</p>
|
||||||
<div class="cred-row">
|
<div class="cred-row">
|
||||||
<span class="cred-label">Username</span>
|
<span class="cred-label">Username</span>
|
||||||
<span class="cred-value">{{.Username}}</span>
|
<span class="cred-value">{{.Username}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cred-row">
|
<div class="cred-row">
|
||||||
<span class="cred-label">Temporary Password</span>
|
<span class="cred-label">Your Instance</span>
|
||||||
<span class="cred-value">{{.Password}}</span>
|
<span class="cred-value">{{.InstanceURL}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Next Steps</h2>
|
||||||
|
<ol class="steps">
|
||||||
|
<li>Open the email sent to <strong>{{.Email}}</strong> and click the link</li>
|
||||||
|
<li>Set your password</li>
|
||||||
|
<li>Return here and click <strong>Activate Stack</strong> to go live</li>
|
||||||
|
<li>Your instance will be at <strong>{{.InstanceURL}}</strong></li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="{{.ResetURL}}" class="btn">Resend / Set Password</a>
|
||||||
|
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="subtitle">Welcome back!</div>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>Credentials not shown.</strong> Your account <strong>{{.Username}}</strong> already exists — we cannot display your original password again. Use the link below if you need to reset it.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Account Details</h2>
|
||||||
|
<div class="cred-row">
|
||||||
|
<span class="cred-label">Username</span>
|
||||||
|
<span class="cred-value">{{.Username}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="cred-row">
|
<div class="cred-row">
|
||||||
<span class="cred-label">Email</span>
|
<span class="cred-label">Email</span>
|
||||||
<span class="cred-value">{{.Email}}</span>
|
<span class="cred-value">{{.Email}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="cred-row">
|
||||||
|
<span class="cred-label">Your Instance</span>
|
||||||
|
<span class="cred-value">{{.InstanceURL}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
|
||||||
<h2>Getting Started</h2>
|
|
||||||
<ol class="steps">
|
|
||||||
<li>Copy your username and temporary password above</li>
|
|
||||||
<li>Click "Sign In" — you'll be taken to the login page</li>
|
|
||||||
<li>Log in with your temporary credentials</li>
|
|
||||||
<li>You'll be prompted to set a new password of your choice</li>
|
|
||||||
<li>Once signed in, visit the activation page to launch your stack</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<a href="{{.ResetURL}}" class="btn">Sign In & Set Password</a>
|
<a href="{{.ResetURL}}" class="btn">Reset Password</a>
|
||||||
<a href="{{.ActivateURL}}" class="btn btn-outline">Activate Stack</a>
|
<a href="{{.LoginURL}}" class="btn btn-outline">Sign In</a>
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="subtitle">Welcome back!</div>
|
|
||||||
<div class="card">
|
|
||||||
<div class="returning">
|
|
||||||
<p>Your account <strong>{{.Username}}</strong> is already set up.</p>
|
|
||||||
<a href="{{.LoginURL}}" class="btn">Sign In</a>
|
|
||||||
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
|
<a href="{{.DashboardURL}}" class="btn btn-outline">Dashboard</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
services:
|
services:
|
||||||
mariadb:
|
mariadb:
|
||||||
image: mariadb:latest
|
image: mariadb:latest
|
||||||
container_name: authelia_mariadb
|
|
||||||
environment:
|
environment:
|
||||||
MYSQL_ROOT_PASSWORD: dev_authelia_root
|
MYSQL_ROOT_PASSWORD: dev_authelia_root
|
||||||
MYSQL_DATABASE: authelia
|
MYSQL_DATABASE: authelia
|
||||||
|
|
@ -9,7 +8,6 @@ services:
|
||||||
MYSQL_PASSWORD: authelia
|
MYSQL_PASSWORD: authelia
|
||||||
volumes:
|
volumes:
|
||||||
- mariadb_data:/var/lib/mysql
|
- mariadb_data:/var/lib/mysql
|
||||||
# No ports exposed - internal only
|
|
||||||
networks:
|
networks:
|
||||||
- authelia_dev
|
- authelia_dev
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -21,11 +19,9 @@ services:
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
container_name: authelia_redis
|
|
||||||
command: redis-server --appendonly yes
|
command: redis-server --appendonly yes
|
||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
# No ports exposed - internal only
|
|
||||||
networks:
|
networks:
|
||||||
- authelia_dev
|
- authelia_dev
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|
@ -37,7 +33,6 @@ services:
|
||||||
|
|
||||||
lldap:
|
lldap:
|
||||||
image: nitnelave/lldap:latest
|
image: nitnelave/lldap:latest
|
||||||
container_name: lldap_lldap
|
|
||||||
volumes:
|
volumes:
|
||||||
- lldap_data:/data
|
- lldap_data:/data
|
||||||
environment:
|
environment:
|
||||||
|
|
@ -46,15 +41,14 @@ services:
|
||||||
- LLDAP_LDAP_BASE_DN=dc=a250,dc=ca
|
- LLDAP_LDAP_BASE_DN=dc=a250,dc=ca
|
||||||
- PUID=33
|
- PUID=33
|
||||||
- PGID=33
|
- PGID=33
|
||||||
ports:
|
|
||||||
# Only expose web UI for manual testing
|
|
||||||
- "17170:17170" # Web interface port
|
|
||||||
networks:
|
networks:
|
||||||
- authelia_dev
|
- authelia_dev
|
||||||
|
deploy:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)"
|
- "traefik.http.routers.lldap.rule=Host(`lldap.bc.a250.ca`)"
|
||||||
- "traefik.http.routers.lldap.entrypoints=web"
|
- "traefik.http.routers.lldap.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.lldap.tls=true"
|
||||||
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
|
- "traefik.http.services.lldap.loadbalancer.server.port=17170"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD", "curl", "-f", "http://localhost:17170/health" ]
|
test: [ "CMD", "curl", "-f", "http://localhost:17170/health" ]
|
||||||
|
|
@ -64,17 +58,12 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
authelia:
|
authelia:
|
||||||
build:
|
|
||||||
context: ./docker/authelia/
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
image: git.nixc.us/a250/authelia:dev-authelia
|
image: git.nixc.us/a250/authelia:dev-authelia
|
||||||
container_name: authelia_dev_main
|
|
||||||
user: root
|
user: root
|
||||||
command:
|
command:
|
||||||
- sh
|
- sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
# Create the secrets directory and populate with environment variables
|
|
||||||
mkdir -p /run/secrets
|
mkdir -p /run/secrets
|
||||||
echo "$${IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}" > /run/secrets/IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
|
echo "$${IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET}" > /run/secrets/IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
|
||||||
echo "$${STORAGE_ENCRYPTION_KEY}" > /run/secrets/STORAGE_ENCRYPTION_KEY
|
echo "$${STORAGE_ENCRYPTION_KEY}" > /run/secrets/STORAGE_ENCRYPTION_KEY
|
||||||
|
|
@ -88,27 +77,14 @@ services:
|
||||||
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
|
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
|
||||||
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
|
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
|
||||||
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
|
echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
|
||||||
|
{ echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: login.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: app.bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe$$'"; echo " - '^/success(\\?.*)?$$'"; echo " - '^/webhook/stripe$$'"; echo " - '^/health$$'"; echo " - '^/version$$'"; echo ' - domain: app.bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/dashboard$$'"; echo " - '^/activate$$'"; echo " - '^/portal$$'"; echo " - '^/resubscribe$$'"; echo " - '^/stack-manage$$'"; echo ' - domain:'; echo ' - lldap.bc.a250.ca'; echo ' - whoami.bc.a250.ca'; echo ' policy: bypass'; echo ' - domain: "{user}.bc.a250.ca"'; echo ' policy: one_factor'; echo ' - domain: "*.bc.a250.ca"'; echo ' policy: deny'; } > /config/configuration.acl.yml
|
||||||
# Override configuration for local dev
|
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml
|
||||||
printf "notifier:\n filesystem:\n filename: /data/notification.txt\n" > /config/configuration.notifier.yml
|
|
||||||
printf "access_control:\n default_policy: bypass\n rules:\n - domain: [\"*.bc.a250.ca\", \"bc.a250.ca\"]\n policy: bypass\n" > /config/configuration.acl.yml
|
|
||||||
|
|
||||||
# Start Authelia with dev overrides
|
|
||||||
exec authelia \
|
|
||||||
--config=/config/configuration.server.yml \
|
|
||||||
--config=/config/configuration.ldap.yml \
|
|
||||||
--config=/config/configuration.acl.yml \
|
|
||||||
--config=/config/configuration.notifier.yml \
|
|
||||||
--config=/config/configuration.identity.providers.yml \
|
|
||||||
--config=/config/configuration.oidc.clients.yml
|
|
||||||
environment:
|
environment:
|
||||||
# Template environment variables
|
|
||||||
X_AUTHELIA_EMAIL: authelia@a250.ca
|
X_AUTHELIA_EMAIL: authelia@a250.ca
|
||||||
X_AUTHELIA_SITE_NAME: a250.ca
|
X_AUTHELIA_SITE_NAME: a250.ca
|
||||||
X_AUTHELIA_CONFIG_FILTERS: template
|
X_AUTHELIA_CONFIG_FILTERS: template
|
||||||
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
|
X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
|
||||||
TRAEFIK_DOMAIN: bc.a250.ca
|
TRAEFIK_DOMAIN: bc.a250.ca
|
||||||
# Development secrets for templates
|
|
||||||
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
|
IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
|
||||||
STORAGE_ENCRYPTION_KEY: DvbtMjsNDIC3eqtNaPtdHm/f07dtlHREgieDStTu9NA=
|
STORAGE_ENCRYPTION_KEY: DvbtMjsNDIC3eqtNaPtdHm/f07dtlHREgieDStTu9NA=
|
||||||
SESSION_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
|
SESSION_SECRET: DoXL9Z1aCrXQ3Ylc2J9MWLO8QeseI8W6F91R0lS0SIE=
|
||||||
|
|
@ -151,25 +127,18 @@ services:
|
||||||
CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
|
CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
|
||||||
volumes:
|
volumes:
|
||||||
- authelia_data:/data
|
- authelia_data:/data
|
||||||
ports:
|
|
||||||
- "9091:9091"
|
|
||||||
networks:
|
networks:
|
||||||
- authelia_dev
|
- authelia_dev
|
||||||
|
deploy:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)"
|
- "traefik.http.routers.authelia.rule=Host(`login.bc.a250.ca`)"
|
||||||
- "traefik.http.routers.authelia.entrypoints=web"
|
- "traefik.http.routers.authelia.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.authelia.tls=true"
|
||||||
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
|
- "traefik.http.services.authelia.loadbalancer.server.port=9091"
|
||||||
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia_dev_main:9091/api/verify?rd=http://login.bc.a250.ca/"
|
- "traefik.http.middlewares.authelia-auth.forwardauth.address=http://authelia:9091/api/verify?rd=https://login.bc.a250.ca/"
|
||||||
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
|
- "traefik.http.middlewares.authelia-auth.forwardauth.trustForwardHeader=true"
|
||||||
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
|
- "traefik.http.middlewares.authelia-auth.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Name,Remote-Email"
|
||||||
depends_on:
|
|
||||||
redis:
|
|
||||||
condition: service_healthy
|
|
||||||
mariadb:
|
|
||||||
condition: service_healthy
|
|
||||||
lldap:
|
|
||||||
condition: service_healthy
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ]
|
test: [ "CMD-SHELL", "/usr/bin/wget --spider --quiet http://localhost:9091/api/health || exit 1" ]
|
||||||
start_period: 15s
|
start_period: 15s
|
||||||
|
|
@ -179,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
|
||||||
|
deploy:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)"
|
- "traefik.http.routers.ss-atlas.rule=Host(`app.bc.a250.ca`)"
|
||||||
- "traefik.http.routers.ss-atlas.entrypoints=web"
|
- "traefik.http.routers.ss-atlas.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.ss-atlas.tls=true"
|
||||||
|
- "traefik.http.routers.ss-atlas.middlewares=authelia-auth@swarm"
|
||||||
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
|
- "traefik.http.services.ss-atlas.loadbalancer.server.port=8080"
|
||||||
depends_on:
|
|
||||||
lldap:
|
|
||||||
condition: service_healthy
|
|
||||||
authelia:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
whoami:
|
whoami:
|
||||||
image: traefik/whoami
|
image: traefik/whoami
|
||||||
container_name: authelia_whoami
|
|
||||||
networks:
|
networks:
|
||||||
- authelia_dev
|
- authelia_dev
|
||||||
|
deploy:
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
- "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)"
|
- "traefik.http.routers.whoami.rule=Host(`whoami.bc.a250.ca`)"
|
||||||
- "traefik.http.routers.whoami.entrypoints=web"
|
- "traefik.http.routers.whoami.entrypoints=websecure"
|
||||||
- "traefik.http.routers.whoami.middlewares=authelia-auth@docker"
|
- "traefik.http.routers.whoami.tls=true"
|
||||||
|
- "traefik.http.routers.whoami.middlewares=authelia-auth@swarm"
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
authelia_dev:
|
authelia_dev:
|
||||||
driver: bridge
|
driver: overlay
|
||||||
|
attachable: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
mariadb_data:
|
mariadb_data:
|
||||||
driver: local
|
|
||||||
redis_data:
|
redis_data:
|
||||||
driver: local
|
|
||||||
authelia_data:
|
authelia_data:
|
||||||
driver: local
|
|
||||||
lldap_data:
|
lldap_data:
|
||||||
driver: local
|
|
||||||
Loading…
Reference in New Issue