diff --git a/.cursor/rules/protect-subscribe-settings.mdc b/.cursor/rules/protect-subscribe-settings.mdc
new file mode 100644
index 0000000..e41f0e2
--- /dev/null
+++ b/.cursor/rules/protect-subscribe-settings.mdc
@@ -0,0 +1,18 @@
+---
+description: Never remove or alter subscribe/Stripe configuration
+alwaysApply: true
+---
+
+# Subscribe / Stripe configuration is off-limits
+
+**Do not under any circumstance:**
+
+- Remove, comment out, reorder, or rename the `STRIPE_*` or subscribe-related environment variables in `stack.yml` (the `ss-atlas` service `environment:` block).
+- Remove or alter the same variables in `.env`.
+- Stash, replace, or overwrite `stack.yml` or `.env` in a way that drops or changes the Stripe/subscribe env vars.
+- Add logic that clears or overwrites these values at deploy or runtime.
+
+**Required subscribe-related vars in `stack.yml` for `ss-atlas`:**
+`STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `STRIPE_PRICE_ID`, `STRIPE_PRICE_ID_FREE`, `STRIPE_PRICE_ID_YEAR`, `STRIPE_PRICE_ID_MONTH_100`, `STRIPE_PRICE_ID_MONTH_200`, `STRIPE_PAYMENT_LINK`, `FREE_TIER_LIMIT`, `YEAR_TIER_LIMIT`, `MAX_SIGNUPS`.
+
+**If editing `stack.yml` or deploy flow:** preserve the full `ss-atlas` environment section exactly; only add new vars or change non-Stripe defaults when the user explicitly asks.
diff --git a/README.md b/README.md
index 7595c45..8210f4d 100644
--- a/README.md
+++ b/README.md
@@ -78,11 +78,10 @@ For comprehensive CI/CD vault setup and secret management:
- `IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY` - OIDC token signing private key (RSA)
- `IDENTITY_PROVIDERS_OIDC_JWKS_KEY` - OIDC JWKS validation key (RSA)
-#### Client Secrets (4)
+#### Client Secrets (3)
- `CLIENT_SECRET_HEADSCALE` - Headscale VPN OIDC client secret
- `CLIENT_SECRET_HEADADMIN` - Headscale admin panel OIDC client secret
- `CLIENT_SECRET_PORTAINER` - Portainer OAuth client secret
-- `CLIENT_SECRET_GITEA` - Gitea OAuth client secret
## ๐งช Testing
@@ -137,12 +136,12 @@ Key environment variables for customization:
## ๐ OAuth/OIDC Integration
-For advanced OAuth/OIDC setup with services like Portainer and Gitea, see the comprehensive guide:
+For advanced OAuth/OIDC setup with services like Portainer, see the comprehensive guide:
**๐ [OAuth Setup Guide](docs/OAUTH_SETUP.md)**
This includes:
-- OAuth client configuration for Portainer and Gitea
+- OAuth client configuration for Portainer
- Client secret generation and management
- CI/CD vault setup instructions
- Step-by-step authentication flow setup
diff --git a/authelia-config.tar.gz b/authelia-config.tar.gz
deleted file mode 100644
index b047e4e..0000000
Binary files a/authelia-config.tar.gz and /dev/null differ
diff --git a/docker-compose.production.yml b/docker-compose.production.yml
index 36bd473..8c65460 100644
--- a/docker-compose.production.yml
+++ b/docker-compose.production.yml
@@ -1,5 +1,3 @@
-version: '3.8'
-
services:
mariadb:
build:
diff --git a/docker/ss-atlas/internal/config/config.go b/docker/ss-atlas/internal/config/config.go
index 1aaac15..df12cb4 100644
--- a/docker/ss-atlas/internal/config/config.go
+++ b/docker/ss-atlas/internal/config/config.go
@@ -35,6 +35,7 @@ type Config struct {
ArchivePath string
LandingTagline string // Main tagline under logo
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env)
+ AdminSecret string // If set, enables POST /admin/delete-user (X-Admin-Secret header)
}
func Load() *Config {
@@ -69,6 +70,7 @@ func Load() *Config {
"Your own workspace, ready in minutes."),
LandingFeatures: envListOrDefault("LANDING_FEATURES",
[]string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}),
+ AdminSecret: os.Getenv("ADMIN_SECRET"),
}
}
diff --git a/docker/ss-atlas/internal/handlers/admin.go b/docker/ss-atlas/internal/handlers/admin.go
new file mode 100644
index 0000000..9d0d785
--- /dev/null
+++ b/docker/ss-atlas/internal/handlers/admin.go
@@ -0,0 +1,64 @@
+package handlers
+
+import (
+ "encoding/json"
+ "log"
+ "net/http"
+ "regexp"
+)
+
+var validUsername = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
+
+// handleDeleteUser fully deletes an account: LDAP user + customer stack and volumes.
+// Requires ADMIN_SECRET env set and X-Admin-Secret header. POST /admin/delete-user?user=username
+func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ if a.cfg.AdminSecret == "" {
+ http.NotFound(w, r)
+ return
+ }
+ secret := r.Header.Get("X-Admin-Secret")
+ if secret != a.cfg.AdminSecret {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ username := r.URL.Query().Get("user")
+ if username == "" {
+ username = r.FormValue("user")
+ }
+ if username == "" {
+ http.Error(w, "user required", http.StatusBadRequest)
+ return
+ }
+ if !validUsername.MatchString(username) {
+ http.Error(w, "invalid username", http.StatusBadRequest)
+ return
+ }
+
+ if err := a.ldap.DeleteUser(username); err != nil {
+ log.Printf("admin delete-user %s: ldap: %v", username, err)
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusInternalServerError)
+ json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
+ return
+ }
+
+ stackName := "customer-" + username
+ if err := a.swarm.RemoveStackAndVolumes(stackName); err != nil {
+ log.Printf("admin delete-user %s: stack/volumes: %v", username, err)
+ // LDAP user already deleted; report but don't fail
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(http.StatusOK)
+ json.NewEncoder(w).Encode(map[string]string{
+ "status": "user deleted",
+ "warning": "stack/volumes: " + err.Error(),
+ })
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "deleted", "user": username})
+}
diff --git a/docker/ss-atlas/internal/handlers/dashboard.go b/docker/ss-atlas/internal/handlers/dashboard.go
index 37189b4..f7a44a5 100644
--- a/docker/ss-atlas/internal/handlers/dashboard.go
+++ b/docker/ss-atlas/internal/handlers/dashboard.go
@@ -75,13 +75,16 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
"Domain": a.cfg.TraefikDomain,
"IsSubscribed": isSubscribed,
"PaidNotActivated": paidNotActivated,
- "CustomerID": customerID,
- "SubStatus": subStatus,
- "StackDeployed": stackDeployed,
- "StackRunning": stackRunning,
- "CustomerDomain": customerDomain,
- "Commit": version.Commit,
- "BuildTime": version.BuildTime,
+ "CustomerID": customerID,
+ "SubStatus": subStatus,
+ "StackDeployed": stackDeployed,
+ "StackRunning": stackRunning,
+ "CustomerDomain": customerDomain,
+ "StackError": r.URL.Query().Get("stack_error"),
+ "PortalError": r.URL.Query().Get("portal_error"),
+ "Linked": r.URL.Query().Get("linked") == "1",
+ "Commit": version.Commit,
+ "BuildTime": version.BuildTime,
}
if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {
diff --git a/docker/ss-atlas/internal/handlers/routes.go b/docker/ss-atlas/internal/handlers/routes.go
index 713ffd8..2063210 100644
--- a/docker/ss-atlas/internal/handlers/routes.go
+++ b/docker/ss-atlas/internal/handlers/routes.go
@@ -49,9 +49,11 @@ func NewRouter(cfg *config.Config, sc *ssstripe.Client, lc *ldap.Client, sw *swa
r.Post("/stack-manage", app.handleStackManage)
r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/resend-reset", app.handleResendReset)
+ r.Post("/link-stripe-customer", app.handleLinkStripeCustomer)
r.Post("/portal", app.handlePortal)
r.Post("/resubscribe", app.handleResubscribe)
r.Post("/webhook/stripe", app.handleWebhook)
+ r.Post("/admin/delete-user", app.handleDeleteUser)
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
diff --git a/docker/ss-atlas/internal/handlers/stack.go b/docker/ss-atlas/internal/handlers/stack.go
index 5698361..c43921d 100644
--- a/docker/ss-atlas/internal/handlers/stack.go
+++ b/docker/ss-atlas/internal/handlers/stack.go
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"net/http"
+ "net/url"
)
func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
@@ -25,7 +26,7 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
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)
+ redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return
}
@@ -34,13 +35,13 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
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)
+ redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
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)
+ redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return
}
}
@@ -48,21 +49,21 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
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)
+ redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
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)
+ redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
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)
+ redirectWithStackError(w, r, a.cfg.AppURL+"/dashboard", err)
return
}
@@ -73,3 +74,11 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
}
+
+func redirectWithStackError(w http.ResponseWriter, r *http.Request, baseURL string, err error) {
+ u, _ := url.Parse(baseURL)
+ q := u.Query()
+ q.Set("stack_error", err.Error())
+ u.RawQuery = q.Encode()
+ http.Redirect(w, r, u.String(), http.StatusSeeOther)
+}
diff --git a/docker/ss-atlas/internal/handlers/subscription.go b/docker/ss-atlas/internal/handlers/subscription.go
index 7d73809..42853f5 100644
--- a/docker/ss-atlas/internal/handlers/subscription.go
+++ b/docker/ss-atlas/internal/handlers/subscription.go
@@ -5,6 +5,7 @@ import (
"fmt"
"log"
"net/http"
+ "net/url"
"strings"
"time"
@@ -188,10 +189,52 @@ func (a *App) handleSuccess(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, a.cfg.AppURL+"/dashboard", http.StatusSeeOther)
}
+// handleLinkStripeCustomer creates a Stripe customer for the current user and saves the ID in LDAP,
+// so "Manage Subscription" works. Used when the user is in customers group but has no customer_id (e.g. manual add).
+func (a *App) handleLinkStripeCustomer(w http.ResponseWriter, r *http.Request) {
+ remoteUser := r.Header.Get("Remote-User")
+ remoteEmail := r.Header.Get("Remote-Email")
+ if remoteUser == "" {
+ http.Redirect(w, r, a.cfg.AutheliaURL, http.StatusSeeOther)
+ return
+ }
+ if !contains(r.Header.Get("Remote-Groups"), "customers") {
+ http.Error(w, "forbidden", http.StatusForbidden)
+ return
+ }
+ existing, _ := a.ldap.GetStripeCustomerID(remoteUser)
+ if existing != "" {
+ redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Billing account already linked. Use Manage Subscription below.")
+ return
+ }
+ email := strings.TrimSpace(remoteEmail)
+ if email == "" {
+ redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No email on account. Contact support to link billing.")
+ return
+ }
+ customerID, err := a.stripe.CreateCustomer(email)
+ if err != nil {
+ log.Printf("link-stripe-customer: create customer failed for %s: %v", remoteUser, err)
+ redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Failed to create billing account: "+err.Error())
+ return
+ }
+ if err := a.ldap.SetStripeCustomerID(remoteUser, customerID); err != nil {
+ log.Printf("link-stripe-customer: set LDAP failed for %s: %v", remoteUser, err)
+ redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "Billing account created but link failed. Contact support.")
+ return
+ }
+ log.Printf("link-stripe-customer: linked %s -> Stripe customer %s", remoteUser, customerID)
+ u, _ := url.Parse(a.cfg.AppURL + "/dashboard")
+ q := u.Query()
+ q.Set("linked", "1")
+ u.RawQuery = q.Encode()
+ http.Redirect(w, r, u.String(), http.StatusSeeOther)
+}
+
func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
customerID := r.FormValue("customer_id")
if customerID == "" {
- http.Error(w, "customer_id required", http.StatusBadRequest)
+ redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No billing account linked. Contact support to manage your subscription.")
return
}
@@ -211,7 +254,7 @@ func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
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)
+ redirectWithPortalError(w, r, a.cfg.AppURL+"/dashboard", "No billing account linked. Contact support to resubscribe.")
return
}
@@ -251,3 +294,11 @@ func sanitizeUsername(email string) string {
}
return clean(local) + clean(domain)
}
+
+func redirectWithPortalError(w http.ResponseWriter, r *http.Request, baseURL, message string) {
+ u, _ := url.Parse(baseURL)
+ q := u.Query()
+ q.Set("portal_error", message)
+ u.RawQuery = q.Encode()
+ http.Redirect(w, r, u.String(), http.StatusSeeOther)
+}
diff --git a/docker/ss-atlas/internal/ldap/client.go b/docker/ss-atlas/internal/ldap/client.go
index 1207a43..006d533 100644
--- a/docker/ss-atlas/internal/ldap/client.go
+++ b/docker/ss-atlas/internal/ldap/client.go
@@ -271,6 +271,31 @@ func (c *Client) CountCustomers() (int, error) {
return n, nil
}
+// DeleteUser removes the user from the customers group (if present) and deletes the LDAP entry.
+// Call RemoveStack for the customer stack and prune volumes separately if needed.
+func (c *Client) DeleteUser(username string) error {
+ _ = c.RemoveFromGroup(username, "customers")
+ conn, err := c.connect()
+ if err != nil {
+ return err
+ }
+ defer conn.Close()
+ exists, err := c.userExists(conn, username)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ return fmt.Errorf("user %s not found", username)
+ }
+ userDN := fmt.Sprintf("uid=%s,ou=people,%s", username, c.cfg.LDAPBaseDN)
+ delReq := goldap.NewDelRequest(userDN, nil)
+ if err := conn.Del(delReq); err != nil {
+ return fmt.Errorf("ldap delete user %s: %w", username, err)
+ }
+ log.Printf("deleted ldap user %s", username)
+ return nil
+}
+
func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
searchReq := goldap.NewSearchRequest(
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),
diff --git a/docker/ss-atlas/internal/stripe/client.go b/docker/ss-atlas/internal/stripe/client.go
index 6448cf0..c4f0023 100644
--- a/docker/ss-atlas/internal/stripe/client.go
+++ b/docker/ss-atlas/internal/stripe/client.go
@@ -11,6 +11,7 @@ import (
stripego "github.com/stripe/stripe-go/v84"
portalsession "github.com/stripe/stripe-go/v84/billingportal/session"
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
+ "github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/subscription"
)
@@ -102,6 +103,18 @@ func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int)
return checkoutsession.New(params)
}
+// CreateCustomer creates a Stripe customer with the given email. Returns the customer ID.
+func (c *Client) CreateCustomer(email string) (string, error) {
+ params := &stripego.CustomerParams{
+ Email: stripego.String(email),
+ }
+ cust, err := customer.New(params)
+ if err != nil {
+ return "", err
+ }
+ return cust.ID, nil
+}
+
func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
params := &stripego.BillingPortalSessionParams{
Customer: stripego.String(customerID),
diff --git a/docker/ss-atlas/internal/swarm/client.go b/docker/ss-atlas/internal/swarm/client.go
index 26228ee..f3afa29 100644
--- a/docker/ss-atlas/internal/swarm/client.go
+++ b/docker/ss-atlas/internal/swarm/client.go
@@ -90,6 +90,34 @@ func (c *Client) RemoveStack(stackName string) error {
return nil
}
+// RemoveStackAndVolumes removes the stack and then any volumes that belonged to it.
+func (c *Client) RemoveStackAndVolumes(stackName string) error {
+ if err := c.RemoveStack(stackName); err != nil {
+ return err
+ }
+ cmd := exec.Command("docker", "volume", "ls", "-q", "--filter", "label=com.docker.stack.namespace="+stackName)
+ cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ log.Printf("warn: list volumes for %s: %s", stackName, strings.TrimSpace(string(output)))
+ return nil
+ }
+ for _, name := range strings.Split(strings.TrimSpace(string(output)), "\n") {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ rmCmd := exec.Command("docker", "volume", "rm", name)
+ rmCmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
+ if out, err := rmCmd.CombinedOutput(); err != nil {
+ log.Printf("warn: remove volume %s: %s", name, strings.TrimSpace(string(out)))
+ } else {
+ log.Printf("removed volume %s", name)
+ }
+ }
+ return nil
+}
+
func (c *Client) StackExists(stackName string) (bool, error) {
cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}")
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)
diff --git a/docker/ss-atlas/templates/pages/dashboard.html b/docker/ss-atlas/templates/pages/dashboard.html
index 3071b68..526aeae 100644
--- a/docker/ss-atlas/templates/pages/dashboard.html
+++ b/docker/ss-atlas/templates/pages/dashboard.html
@@ -171,6 +171,11 @@
Your Stack
+ {{if .StackError}}
+
+ {{.StackError}}
+
+ {{end}}
Status
{{if .StackRunning}}
@@ -222,6 +227,17 @@
Manage
+ {{if .Linked}}
+
+ Billing account linked. You can now use Manage Subscription below.
+
+ {{end}}
+ {{if .PortalError}}
+
+ {{.PortalError}}
+
+ {{end}}
+ {{if .CustomerID}}
{{if and .SubStatus (eq .SubStatus.Label "Expired")}}
+ {{end}}
Account Security
diff --git a/docker/ss-atlas/templates/stack-template.yml b/docker/ss-atlas/templates/stack-template.yml
index 348ecc1..ddf9786 100644
--- a/docker/ss-atlas/templates/stack-template.yml
+++ b/docker/ss-atlas/templates/stack-template.yml
@@ -1,47 +1,22 @@
# =============================================================================
-# CUSTOMER STACK TEMPLATE โ Gitea + PostgreSQL
+# CUSTOMER STACK TEMPLATE โ Uptime Kuma
# =============================================================================
-# This is the Docker Swarm stack deployed for each paying customer.
-# It defines what product/service they receive when they subscribe.
+# Single-service stack for each paying customer. Simple webapp for testing
+# routing and auth at https://{{.Domain}}/i/{{.Subdomain}}
#
-# PRODUCT: Gitea โ a self-hosted Git service, backed by PostgreSQL.
-# Each customer gets their own isolated instance at a sub-path.
+# Traefik: priority 10 ensures /i/{{.Subdomain}} always hits this stack, not
+# ss-atlas (priority 1). Strip prefix sends e.g. /i/user/foo -> /foo to the app.
#
-# Structure:
-# web โ the application, exposed via Traefik behind Authelia auth
-# db โ PostgreSQL, internal only (backend network, never exposed)
-#
-# To sell a different product: replace the `web` image, update the port
-# in the Traefik loadbalancer label, and adjust `db` env/image as needed.
-#
-# Template variables (injected at deploy time by swarm/client.go):
-# {{.ID}} - customer's username (unique resource naming)
-# {{.Subdomain}} - customer's username (used in path: /i/{subdomain})
-# {{.Domain}} - base domain (e.g. bc.a250.ca)
-# {{.TraefikNetwork}} - Traefik overlay network name
-#
-# Each customer gets their stack at: https://{{.Domain}}/i/{{.Subdomain}}
-# Access is restricted to the owning user via Authelia forward-auth.
+# Template variables (injected by swarm/client.go):
+# {{.ID}}, {{.Subdomain}}, {{.Domain}}, {{.TraefikNetwork}}
# =============================================================================
services:
web:
- image: gitea/gitea:1-rootless
- environment:
- GITEA__database__DB_TYPE: postgres
- GITEA__database__HOST: db:5432
- GITEA__database__NAME: gitea
- GITEA__database__USER: gitea
- GITEA__database__PASSWD: gitea
- GITEA__server__DOMAIN: "{{.Domain}}"
- GITEA__server__ROOT_URL: "https://{{.Domain}}/i/{{.Subdomain}}/"
- GITEA__server__HTTP_PORT: "3000"
- GITEA__security__INSTALL_LOCK: "true"
+ image: louislam/uptime-kuma:2
volumes:
- - gitea_data:/var/lib/gitea
- - gitea_config:/etc/gitea
+ - app_data:/app/data
networks:
- traefik_net
- - backend
deploy:
replicas: 1
labels:
@@ -49,26 +24,11 @@ services:
traefik.docker.network: "atlas_{{.TraefikNetwork}}"
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)"
traefik.http.routers.customer-{{.ID}}-web.entrypoints: "websecure"
- traefik.http.routers.customer-{{.ID}}-web.priority: "2"
+ traefik.http.routers.customer-{{.ID}}-web.priority: "10"
traefik.http.routers.customer-{{.ID}}-web.tls: "true"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authelia-auth@swarm"
traefik.http.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}"
- traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3000"
- restart_policy:
- condition: on-failure
-
- db:
- image: postgres:16-alpine
- environment:
- POSTGRES_DB: gitea
- POSTGRES_USER: gitea
- POSTGRES_PASSWORD: gitea
- volumes:
- - db_data:/var/lib/postgresql/data
- networks:
- - backend
- deploy:
- replicas: 1
+ traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3001"
restart_policy:
condition: on-failure
@@ -76,13 +36,7 @@ networks:
traefik_net:
external: true
name: "atlas_{{.TraefikNetwork}}"
- backend:
- driver: overlay
volumes:
- gitea_data:
- driver: local
- gitea_config:
- driver: local
- db_data:
+ app_data:
driver: local
diff --git a/docs/CI_CD_VAULT_SETUP.md b/docs/CI_CD_VAULT_SETUP.md
index e1a84e4..a49336e 100644
--- a/docs/CI_CD_VAULT_SETUP.md
+++ b/docs/CI_CD_VAULT_SETUP.md
@@ -28,7 +28,6 @@ Your Woodpecker CI vault must contain **12 total secrets** for proper Authelia d
| `CLIENT_SECRET_HEADSCALE` | Headscale VPN OIDC client | `./generate-secrets.sh` |
| `CLIENT_SECRET_HEADADMIN` | Headscale admin OIDC client | `./generate-secrets.sh` |
| `CLIENT_SECRET_PORTAINER` | Portainer OAuth client | `./scripts/generate-oauth-secrets.sh` |
-| `CLIENT_SECRET_GITEA` | Gitea OAuth client | `./scripts/generate-oauth-secrets.sh` |
## ๐ Setup Process
@@ -64,7 +63,6 @@ export WOODPECKER_TOKEN=your-api-token
# Update all secrets (example commands)
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
-woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --value "$(cat secrets/clients/gitea-secret.txt)"
```
## ๐ Secret Rotation
@@ -85,7 +83,7 @@ woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --val
# Regenerate OAuth client secrets only
./scripts/generate-oauth-secrets.sh
-# Update CLIENT_SECRET_PORTAINER and CLIENT_SECRET_GITEA in vault
+# Update CLIENT_SECRET_PORTAINER in vault
# Deploy when convenient
```
diff --git a/docs/OAUTH_SETUP.md b/docs/OAUTH_SETUP.md
index 77ab191..f1237d9 100644
--- a/docs/OAUTH_SETUP.md
+++ b/docs/OAUTH_SETUP.md
@@ -1,6 +1,6 @@
# OAuth/OIDC Client Setup Guide
-This guide covers setting up OAuth/OIDC authentication for services like Portainer and Gitea using Authelia as the identity provider.
+This guide covers setting up OAuth/OIDC authentication for services like Portainer using Authelia as the identity provider.
## ๐ง Overview
@@ -27,10 +27,6 @@ Add these to your Woodpecker CI vault:
- **Variable**: `CLIENT_SECRET_PORTAINER`
- **Value**: Generated from `secrets/clients/portainer-secret.txt`
-#### Gitea OAuth
-- **Variable**: `CLIENT_SECRET_GITEA`
-- **Value**: Generated from `secrets/clients/gitea-secret.txt`
-
## ๐ฑ Client Configurations
### Portainer OAuth Setup
@@ -75,39 +71,6 @@ Once OAuth is working, remove middleware protection:
# traefik.http.routers.portainer.middlewares: authelia_authelia
```
-### Gitea OAuth Setup
-
-#### 1. Authelia Configuration
-Already configured in `docker/authelia/config/configuration.oidc.clients.yml`:
-
-```yaml
-- client_id: gitea
- client_name: Gitea
- client_secret: {{ secret "/run/secrets/CLIENT_SECRET_GITEA" }}
- public: false
- authorization_policy: one_factor
- consent_mode: implicit
- scopes:
- - openid
- - email
- - profile
- - groups
- redirect_uris:
- - https://git.{{ env "TRAEFIK_DOMAIN" }}/user/oauth2/authelia/callback
- userinfo_signed_response_alg: none
-```
-
-#### 2. Gitea OAuth Settings
-Configure in Gitea โ Site Administration โ Authentication Sources:
-
-- **Authentication Type**: OAuth2
-- **Authentication Name**: `Authelia`
-- **OAuth2 Provider**: OpenID Connect
-- **Client ID**: `gitea`
-- **Client Secret**: ``
-- **OpenID Connect Auto Discovery URL**: `https://login.a250.ca/.well-known/openid_configuration`
-- **Icon URL**: `https://login.a250.ca/static/media/logo.png` (optional)
-
## ๐ Deployment Process
### 1. Generate Secrets
@@ -118,7 +81,6 @@ Configure in Gitea โ Site Administration โ Authentication Sources:
### 2. Update CI/CD Vault
Add the generated secrets to your Woodpecker CI vault:
- `CLIENT_SECRET_PORTAINER`
-- `CLIENT_SECRET_GITEA`
### 3. Deploy Authelia
Push changes to trigger CI/CD deployment with new OAuth clients.
diff --git a/docs/README.md b/docs/README.md
index 1e73c81..6bf70f6 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -5,7 +5,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
## ๐ Available Guides
### ๐ง Setup & Configuration
-- **[OAuth/OIDC Setup Guide](OAUTH_SETUP.md)** - Complete OAuth integration for Portainer, Gitea, and other services
+- **[OAuth/OIDC Setup Guide](OAUTH_SETUP.md)** - Complete OAuth integration for Portainer and other services
- **[CI/CD Vault Setup](CI_CD_VAULT_SETUP.md)** - Secret management and Woodpecker CI vault configuration
### ๐ Getting Started
@@ -18,7 +18,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
2. **OAuth Integration**
- Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh`
- Follow [OAuth Setup Guide](OAUTH_SETUP.md) for service configuration
- - Configure individual services (Portainer, Gitea) with OAuth
+ - Configure individual services (e.g. Portainer) with OAuth
3. **Production Deployment**
- Commit changes to trigger CI/CD pipeline
@@ -55,7 +55,7 @@ docker compose -f docker-compose.dev.yml up -d
### Required Secrets (12 Total)
- **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP
- **OIDC Secrets (3)**: HMAC, private key, JWKS key
-- **Client Secrets (4)**: Headscale (2), Portainer, Gitea
+- **Client Secrets (3)**: Headscale (2), Portainer
## ๐ Troubleshooting
diff --git a/scripts/generate-oauth-secrets.sh b/scripts/generate-oauth-secrets.sh
index d05bd0d..06c76f5 100755
--- a/scripts/generate-oauth-secrets.sh
+++ b/scripts/generate-oauth-secrets.sh
@@ -106,11 +106,6 @@ Add these secrets to your Woodpecker CI vault:
- **Secret File**: `secrets/clients/portainer-secret.txt`
- **Value**: (copy content from the file above)
-### Gitea OAuth
-- **Variable Name**: `CLIENT_SECRET_GITEA`
-- **Secret File**: `secrets/clients/gitea-secret.txt`
-- **Value**: (copy content from the file above)
-
## Important Notes
1. **Never commit these files** - they are automatically gitignored
@@ -124,9 +119,6 @@ If using Woodpecker CLI:
```bash
# Update Portainer secret
woodpecker secret update --repository your-repo --name CLIENT_SECRET_PORTAINER --value "$(cat secrets/clients/portainer-secret.txt)"
-
-# Update Gitea secret
-woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --value "$(cat secrets/clients/gitea-secret.txt)"
```
## Verification
@@ -149,17 +141,15 @@ print_summary() {
echo "${YELLOW}๐ Generated Files:${NC}"
echo " โข secrets/oauth-secrets.env"
echo " โข secrets/clients/portainer-secret.txt"
- echo " โข secrets/clients/gitea-secret.txt"
echo " โข secrets/VAULT_SECRETS.md"
echo
echo "${YELLOW}๐ Required CI/CD Vault Updates:${NC}"
echo " โข CLIENT_SECRET_PORTAINER"
- echo " โข CLIENT_SECRET_GITEA"
echo
echo "${RED}โ ๏ธ NEXT STEPS:${NC}"
echo " 1. Update your CI/CD vault with new secrets"
echo " 2. Deploy Authelia to use new client configurations"
- echo " 3. Configure OAuth in Portainer and Gitea admin panels"
+ echo " 3. Configure OAuth in Portainer admin panel"
echo " 4. Test authentication flows"
echo
echo "${BLUE}๐ Full setup guide: docs/OAUTH_SETUP.md${NC}"
@@ -195,8 +185,7 @@ main() {
# Generate client secrets
generate_client_secret "portainer" "portainer-secret.txt"
- generate_client_secret "gitea" "gitea-secret.txt"
-
+
create_vault_instructions
print_summary
}
diff --git a/stack.production.yml b/stack.production.yml
deleted file mode 100644
index 4860062..0000000
--- a/stack.production.yml
+++ /dev/null
@@ -1,209 +0,0 @@
-x-authelia-env: &authelia-env
- X_AUTHELIA_EMAIL: authelia@a250.ca
- X_AUTHELIA_SITE_NAME: ATLAS
- X_AUTHELIA_CONFIG_FILTERS: template
- X_AUTHELIA_LDAP_DOMAIN: dc=a250,dc=ca
- TRAEFIK_DOMAIN: bc.a250.ca
-
-secrets:
- AUTHENTICATION_BACKEND_LDAP_PASSWORD:
- external: true
- IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET:
- external: true
- # TEMPORARILY DISABLED - OIDC provider disabled
- # IDENTITY_PROVIDERS_OIDC_HMAC_SECRET:
- # external: true
- # IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY:
- # external: true
- # IDENTITY_PROVIDERS_OIDC_JWKS_KEY:
- # external: true
- NOTIFIER_SMTP_PASSWORD:
- external: true
- SESSION_SECRET:
- external: true
- STORAGE_ENCRYPTION_KEY:
- external: true
- # TEMPORARILY DISABLED - OAuth clients disabled
- # CLIENT_SECRET_HEADSCALE:
- # external: true
- # CLIENT_SECRET_HEADADMIN:
- # external: true
- # CLIENT_SECRET_PORTAINER:
- # external: true
- # TEMPORARILY DISABLED - Gitea OAuth (not ready yet)
- # CLIENT_SECRET_GITEA:
- # external: true
-
-networks:
- default:
- driver: overlay
- traefik:
- external: true
- ad:
- external: true
-
-volumes:
- authelia_config:
- driver: local
- authelia_assets:
- driver: local
- authelia_redis_data:
- driver: local
- authelia_mariadb_data:
- driver: local
- lldap_data:
- driver: local
-
-services:
- authelia:
- image: git.nixc.us/a250/authelia:production-authelia
- command:
- - authelia
- - --config=/config/configuration.server.yml
- - --config=/config/configuration.ldap.yml
- - --config=/config/configuration.acl.yml
- - --config=/config/configuration.notifier.yml
- secrets:
- - AUTHENTICATION_BACKEND_LDAP_PASSWORD
- - IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET
- # - IDENTITY_PROVIDERS_OIDC_HMAC_SECRET
- # - IDENTITY_PROVIDERS_OIDC_ISSUER_PRIVATE_KEY
- # - IDENTITY_PROVIDERS_OIDC_JWKS_KEY
- - NOTIFIER_SMTP_PASSWORD
- - SESSION_SECRET
- - STORAGE_ENCRYPTION_KEY
- # - CLIENT_SECRET_HEADSCALE
- # - CLIENT_SECRET_HEADADMIN
- # - CLIENT_SECRET_PORTAINER
- environment: *authelia-env
- dns:
- - 1.1.1.1 # Cloudflare
- - 9.9.9.9 # Quad9
- volumes:
- # - authelia_config:/config:rw
- - authelia_assets:/config/assets:rw
- networks:
- - traefik
- - default
- - ad
- deploy:
- update_config:
- order: start-first
- failure_action: rollback
- parallelism: 1
- restart_policy:
- condition: on-failure
- replicas: 1
- labels:
- us.a250.autodeploy: "true"
- homepage.group: Infrastructure
- homepage.name: Authelia
- homepage.href: https://login.bc.a250.ca
- homepage.description: ATLAS
- traefik.enable: "true"
- traefik.docker.network: traefik
- traefik.http.routers.authelia_authelia.rule: Host(`login.bc.a250.ca`)
- traefik.http.routers.authelia_authelia.entrypoints: web
- traefik.http.routers.authelia_authelia.service: authelia_authelia
- traefik.http.services.authelia_authelia.loadbalancer.server.port: 9091
- traefik.http.middlewares.authelia_authelia.forwardauth.address: http://authelia_authelia:9091/api/verify?rd=https://login.bc.a250.ca/
- traefik.http.middlewares.authelia_authelia.forwardauth.trustForwardHeader: "true"
- traefik.http.middlewares.authelia_authelia.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
- traefik.http.middlewares.authelia-basic.forwardauth.address: http://authelia_authelia:9091/api/verify?auth=basic
- traefik.http.middlewares.authelia-basic.forwardauth.trustForwardHeader: "true"
- traefik.http.middlewares.authelia-basic.forwardauth.authResponseHeaders: Remote-User,Remote-Groups,Remote-Name,Remote-Email
- # healthcheck:
- # test: ["CMD", "nc", "-z", "localhost", "9091"]
- # start_period: 30s
- # interval: 30s
- # timeout: 10s
- # retries: 3
- logging:
- driver: json-file
- options:
- max-size: 10m
- max-file: "3"
-
- redis:
- image: git.nixc.us/a250/authelia:production-redis
- command: redis-server --appendonly yes
- volumes:
- - authelia_redis_data:/data:rw
- networks:
- - default
- deploy:
- update_config:
- order: start-first
- failure_action: rollback
- parallelism: 1
- restart_policy:
- condition: on-failure
- replicas: 1
- labels:
- us.a250.autodeploy: "true"
- traefik.enable: "false"
- # healthcheck:
- # test: ["CMD", "redis-cli", "ping"]
- # start_period: 10s
- # interval: 30s
- # timeout: 5s
- # retries: 3
- logging:
- driver: json-file
- options:
- max-size: 10m
- max-file: "3"
-
- lldap:
- image: nitnelave/lldap:latest
- volumes:
- - lldap_data:/data
- environment:
- LLDAP_JWT_SECRET: I2sNvGvhzZlTJWPfNL9MBPFGhyG/gWU5wHz6wFsIC3I=
- LLDAP_LDAP_USER_PASS: /ETAToLiZPWo6QK171abAUqsa3WDpd9IgneZnTA4zU0=
- LLDAP_LDAP_BASE_DN: dc=a250,dc=ca
- networks:
- - default
- deploy:
- restart_policy:
- condition: on-failure
- replicas: 1
- logging:
- driver: json-file
- options:
- max-size: 10m
- max-file: "3"
-
- mariadb:
- image: git.nixc.us/a250/authelia:production-mariadb
- environment:
- MYSQL_ROOT_PASSWORD: authelia
- MYSQL_DATABASE: authelia
- MYSQL_USER: authelia
- MYSQL_PASSWORD: authelia
- volumes:
- - authelia_mariadb_data:/var/lib/mysql:rw
- networks:
- - default
- deploy:
- update_config:
- order: start-first
- failure_action: rollback
- parallelism: 1
- restart_policy:
- condition: on-failure
- replicas: 1
- labels:
- us.a250.autodeploy: "true"
- traefik.enable: "false"
- # healthcheck:
- # test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "authelia", "-pauthelia"]
- # start_period: 15s
- # interval: 30s
- # timeout: 10s
- # retries: 3
- logging:
- driver: json-file
- options:
- max-size: 10m
- max-file: "3"
\ No newline at end of file
diff --git a/stack.yml b/stack.yml
index ba6698a..eaf7a6c 100644
--- a/stack.yml
+++ b/stack.yml
@@ -78,8 +78,7 @@ services:
echo "$${CLIENT_SECRET_HEADSCALE}" > /run/secrets/CLIENT_SECRET_HEADSCALE
echo "$${CLIENT_SECRET_HEADADMIN}" > /run/secrets/CLIENT_SECRET_HEADADMIN
echo "$${CLIENT_SECRET_PORTAINER}" > /run/secrets/CLIENT_SECRET_PORTAINER
- echo "$${CLIENT_SECRET_GITEA}" > /run/secrets/CLIENT_SECRET_GITEA
- { echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/login(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe/?$$'"; echo " - '^/success(/|\\?.*)?$$'"; echo " - '^/webhook/stripe/?$$'"; echo " - '^/resend-reset/?$$'"; echo " - '^/health/?$$'"; echo " - '^/version/?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/activate/?$$'"; echo " - '^/dashboard/?$$'"; echo ' - domain: bc.a250.ca'; echo ' subject:'; echo ' - group:customers'; echo ' policy: two_factor'; echo ' resources:'; echo " - '^/portal/?$$'"; echo " - '^/resubscribe/?$$'"; echo " - '^/stack-manage/?$$'"; echo " - '^/i/[a-z0-9-]+(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/admin/lldap(/.*)?$$'"; echo " - '^/whoami(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: deny'; } > /config/configuration.acl.yml
+ { echo 'access_control:'; echo ' default_policy: deny'; echo ' rules:'; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/login(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/$$'"; echo " - '^/subscribe/?$$'"; echo " - '^/success(/|\\?.*)?$$'"; echo " - '^/webhook/stripe/?$$'"; echo " - '^/resend-reset/?$$'"; echo " - '^/health/?$$'"; echo " - '^/version/?$$'"; echo " - '^/admin/delete-user/?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: one_factor'; echo ' resources:'; echo " - '^/activate/?$$'"; echo " - '^/dashboard/?$$'"; echo ' - domain: bc.a250.ca'; echo ' subject:'; echo ' - group:customers'; echo ' policy: two_factor'; echo ' resources:'; echo " - '^/link-stripe-customer/?$$'"; echo " - '^/portal/?$$'"; echo " - '^/resubscribe/?$$'"; echo " - '^/stack-manage/?$$'"; echo " - '^/i/[a-z0-9-]+(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: bypass'; echo ' resources:'; echo " - '^/admin/lldap(/.*)?$$'"; echo " - '^/whoami(/.*)?$$'"; echo ' - domain: bc.a250.ca'; echo ' policy: deny'; } > /config/configuration.acl.yml
exec authelia --config=/config/configuration.server.yml --config=/config/configuration.ldap.yml --config=/config/configuration.acl.yml --config=/config/configuration.notifier.yml --config=/config/configuration.identity.providers.yml --config=/config/configuration.oidc.clients.yml
environment:
AUTHELIA_SERVER_ADDRESS: tcp://0.0.0.0:9091/login
@@ -127,7 +126,6 @@ services:
CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8=
CLIENT_SECRET_PORTAINER: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
- CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
volumes:
- authelia_data:/data
networks:
@@ -182,6 +180,8 @@ services:
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080"
+ # SUBSCRIBE/STRIPE: Do not remove or reorder the STRIPE_* and tier env vars below.
+ # They are loaded from .env at deploy time. See .cursor/rules/protect-subscribe-settings.mdc
ss-atlas:
image: atlas-ss-atlas:latest
environment:
@@ -212,6 +212,7 @@ services:
- ARCHIVE_PATH=/archives
- LANDING_TAGLINE=${LANDING_TAGLINE:-Your own workspace, ready in minutes.}
- LANDING_FEATURES=${LANDING_FEATURES:-Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime}
+ - ADMIN_SECRET=${ADMIN_SECRET:-}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- atlas_archives:/archives