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")}}
@@ -242,6 +258,12 @@

No refunds for the current billing period. Access continues until the end of your paid month.

+ {{else}} +

Link a Stripe billing account to manage payment or cancel from the portal.

+ + +
+ {{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