forked from Nixius/authelia
1
0
Fork 0
This commit is contained in:
Leopere 2026-03-05 15:20:55 -05:00
parent 2e8979d4d8
commit 630bd3d3f4
Signed by: colin
SSH Key Fingerprint: SHA256:nRPCQTeMFLdGytxRQmPVK9VXY3/ePKQ5lGRyJhT5DY8
21 changed files with 278 additions and 349 deletions

View File

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

View File

@ -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_ISSUER_PRIVATE_KEY` - OIDC token signing private key (RSA)
- `IDENTITY_PROVIDERS_OIDC_JWKS_KEY` - OIDC JWKS validation 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_HEADSCALE` - Headscale VPN OIDC client secret
- `CLIENT_SECRET_HEADADMIN` - Headscale admin panel OIDC client secret - `CLIENT_SECRET_HEADADMIN` - Headscale admin panel OIDC client secret
- `CLIENT_SECRET_PORTAINER` - Portainer OAuth client secret - `CLIENT_SECRET_PORTAINER` - Portainer OAuth client secret
- `CLIENT_SECRET_GITEA` - Gitea OAuth client secret
## 🧪 Testing ## 🧪 Testing
@ -137,12 +136,12 @@ Key environment variables for customization:
## 🔗 OAuth/OIDC Integration ## 🔗 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)** **📖 [OAuth Setup Guide](docs/OAUTH_SETUP.md)**
This includes: This includes:
- OAuth client configuration for Portainer and Gitea - OAuth client configuration for Portainer
- Client secret generation and management - Client secret generation and management
- CI/CD vault setup instructions - CI/CD vault setup instructions
- Step-by-step authentication flow setup - Step-by-step authentication flow setup

Binary file not shown.

View File

@ -1,5 +1,3 @@
version: '3.8'
services: services:
mariadb: mariadb:
build: build:

View File

@ -35,6 +35,7 @@ type Config struct {
ArchivePath string ArchivePath string
LandingTagline string // Main tagline under logo LandingTagline string // Main tagline under logo
LandingFeatures []string // Bullet points on subscribe card (comma-separated in env) 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 { func Load() *Config {
@ -69,6 +70,7 @@ func Load() *Config {
"Your own workspace, ready in minutes."), "Your own workspace, ready in minutes."),
LandingFeatures: envListOrDefault("LANDING_FEATURES", LandingFeatures: envListOrDefault("LANDING_FEATURES",
[]string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}), []string{"Dedicated environment", "Secure single sign-on", "Automatic provisioning", "Manage subscription anytime"}),
AdminSecret: os.Getenv("ADMIN_SECRET"),
} }
} }

View File

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

View File

@ -75,13 +75,16 @@ func (a *App) handleDashboard(w http.ResponseWriter, r *http.Request) {
"Domain": a.cfg.TraefikDomain, "Domain": a.cfg.TraefikDomain,
"IsSubscribed": isSubscribed, "IsSubscribed": isSubscribed,
"PaidNotActivated": paidNotActivated, "PaidNotActivated": paidNotActivated,
"CustomerID": customerID, "CustomerID": customerID,
"SubStatus": subStatus, "SubStatus": subStatus,
"StackDeployed": stackDeployed, "StackDeployed": stackDeployed,
"StackRunning": stackRunning, "StackRunning": stackRunning,
"CustomerDomain": customerDomain, "CustomerDomain": customerDomain,
"Commit": version.Commit, "StackError": r.URL.Query().Get("stack_error"),
"BuildTime": version.BuildTime, "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 { if err := a.tmpl.ExecuteTemplate(w, "dashboard.html", data); err != nil {

View File

@ -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("/stack-manage", app.handleStackManage)
r.Post("/subscribe", app.handleCreateCheckout) r.Post("/subscribe", app.handleCreateCheckout)
r.Post("/resend-reset", app.handleResendReset) r.Post("/resend-reset", app.handleResendReset)
r.Post("/link-stripe-customer", app.handleLinkStripeCustomer)
r.Post("/portal", app.handlePortal) r.Post("/portal", app.handlePortal)
r.Post("/resubscribe", app.handleResubscribe) r.Post("/resubscribe", app.handleResubscribe)
r.Post("/webhook/stripe", app.handleWebhook) r.Post("/webhook/stripe", app.handleWebhook)
r.Post("/admin/delete-user", app.handleDeleteUser)
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)
w.Write([]byte("ok")) w.Write([]byte("ok"))

View File

@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/url"
) )
func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) { 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": case "stop":
if err := a.swarm.ScaleStack(stackName, 0); err != nil { if err := a.swarm.ScaleStack(stackName, 0); err != nil {
log.Printf("stack-manage stop %s: %v", remoteUser, err) 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 return
} }
@ -34,13 +35,13 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
if !exists { if !exists {
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil { if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
log.Printf("stack-manage start (deploy) %s: %v", remoteUser, err) 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 return
} }
} else { } else {
if err := a.swarm.ScaleStack(stackName, 1); err != nil { if err := a.swarm.ScaleStack(stackName, 1); err != nil {
log.Printf("stack-manage start (scale) %s: %v", remoteUser, err) 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 return
} }
} }
@ -48,21 +49,21 @@ func (a *App) handleStackManage(w http.ResponseWriter, r *http.Request) {
case "restart": case "restart":
if err := a.swarm.RestartStack(stackName); err != nil { if err := a.swarm.RestartStack(stackName); err != nil {
log.Printf("stack-manage restart %s: %v", remoteUser, err) 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 return
} }
case "rebuild": case "rebuild":
if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil { if err := a.swarm.DeployStack(stackName, remoteUser, a.cfg.TraefikDomain); err != nil {
log.Printf("stack-manage rebuild %s: %v", remoteUser, err) 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 return
} }
case "destroy": case "destroy":
if err := a.swarm.RemoveStack(stackName); err != nil { if err := a.swarm.RemoveStack(stackName); err != nil {
log.Printf("stack-manage destroy %s: %v", remoteUser, err) 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 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) 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)
}

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"net/url"
"strings" "strings"
"time" "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) 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) { func (a *App) handlePortal(w http.ResponseWriter, r *http.Request) {
customerID := r.FormValue("customer_id") customerID := r.FormValue("customer_id")
if customerID == "" { 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 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) { func (a *App) handleResubscribe(w http.ResponseWriter, r *http.Request) {
customerID := r.FormValue("customer_id") customerID := r.FormValue("customer_id")
if customerID == "" { 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 return
} }
@ -251,3 +294,11 @@ func sanitizeUsername(email string) string {
} }
return clean(local) + clean(domain) 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)
}

View File

@ -271,6 +271,31 @@ func (c *Client) CountCustomers() (int, error) {
return n, nil 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) { func (c *Client) userExists(conn *goldap.Conn, username string) (bool, error) {
searchReq := goldap.NewSearchRequest( searchReq := goldap.NewSearchRequest(
fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN), fmt.Sprintf("ou=people,%s", c.cfg.LDAPBaseDN),

View File

@ -11,6 +11,7 @@ import (
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"
checkoutsession "github.com/stripe/stripe-go/v84/checkout/session" checkoutsession "github.com/stripe/stripe-go/v84/checkout/session"
"github.com/stripe/stripe-go/v84/customer"
"github.com/stripe/stripe-go/v84/subscription" "github.com/stripe/stripe-go/v84/subscription"
) )
@ -102,6 +103,18 @@ func (c *Client) CreateCheckoutForCustomer(customerID string, customerCount int)
return checkoutsession.New(params) 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) { func (c *Client) CreatePortalSession(customerID string) (*stripego.BillingPortalSession, error) {
params := &stripego.BillingPortalSessionParams{ params := &stripego.BillingPortalSessionParams{
Customer: stripego.String(customerID), Customer: stripego.String(customerID),

View File

@ -90,6 +90,34 @@ func (c *Client) RemoveStack(stackName string) error {
return nil 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) { func (c *Client) StackExists(stackName string) (bool, error) {
cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}") cmd := exec.Command("docker", "stack", "ls", "--format", "{{.Name}}")
cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost) cmd.Env = append(os.Environ(), "DOCKER_HOST="+c.cfg.DockerHost)

View File

@ -171,6 +171,11 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Your Stack</h2> <h2>Your Stack</h2>
{{if .StackError}}
<div class="stack-error" style="background: rgba(239,68,68,0.12); border: 1px solid var(--red); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: #fca5a5;">
{{.StackError}}
</div>
{{end}}
<div class="status-row"> <div class="status-row">
<span class="status-label">Status</span> <span class="status-label">Status</span>
{{if .StackRunning}} {{if .StackRunning}}
@ -222,6 +227,17 @@
</div> </div>
<div class="card"> <div class="card">
<h2>Manage</h2> <h2>Manage</h2>
{{if .Linked}}
<div class="portal-success" style="background: rgba(34,197,94,0.12); border: 1px solid var(--green); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: #86efac;">
Billing account linked. You can now use Manage Subscription below.
</div>
{{end}}
{{if .PortalError}}
<div class="portal-error" style="background: rgba(239,68,68,0.12); border: 1px solid var(--red); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.9rem; color: #fca5a5;">
{{.PortalError}}
</div>
{{end}}
{{if .CustomerID}}
<div class="actions"> <div class="actions">
{{if and .SubStatus (eq .SubStatus.Label "Expired")}} {{if and .SubStatus (eq .SubStatus.Label "Expired")}}
<form method="POST" action="/resubscribe" style="margin:0"> <form method="POST" action="/resubscribe" style="margin:0">
@ -242,6 +258,12 @@
<p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;"> <p style="color: var(--muted); font-size: 0.8rem; margin-top: 1rem;">
No refunds for the current billing period. Access continues until the end of your paid month. No refunds for the current billing period. Access continues until the end of your paid month.
</p> </p>
{{else}}
<p style="color: var(--muted); font-size: 0.9rem; margin-bottom: 0.75rem;">Link a Stripe billing account to manage payment or cancel from the portal.</p>
<form method="POST" action="/link-stripe-customer" style="margin:0">
<button type="submit" class="btn btn-sm">Link billing account</button>
</form>
{{end}}
</div> </div>
<div class="card"> <div class="card">
<h2>Account Security</h2> <h2>Account Security</h2>

View File

@ -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. # Single-service stack for each paying customer. Simple webapp for testing
# It defines what product/service they receive when they subscribe. # routing and auth at https://{{.Domain}}/i/{{.Subdomain}}
# #
# PRODUCT: Gitea — a self-hosted Git service, backed by PostgreSQL. # Traefik: priority 10 ensures /i/{{.Subdomain}} always hits this stack, not
# Each customer gets their own isolated instance at a sub-path. # ss-atlas (priority 1). Strip prefix sends e.g. /i/user/foo -> /foo to the app.
# #
# Structure: # Template variables (injected by swarm/client.go):
# web — the application, exposed via Traefik behind Authelia auth # {{.ID}}, {{.Subdomain}}, {{.Domain}}, {{.TraefikNetwork}}
# 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.
# ============================================================================= # =============================================================================
services: services:
web: web:
image: gitea/gitea:1-rootless image: louislam/uptime-kuma:2
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"
volumes: volumes:
- gitea_data:/var/lib/gitea - app_data:/app/data
- gitea_config:/etc/gitea
networks: networks:
- traefik_net - traefik_net
- backend
deploy: deploy:
replicas: 1 replicas: 1
labels: labels:
@ -49,26 +24,11 @@ services:
traefik.docker.network: "atlas_{{.TraefikNetwork}}" traefik.docker.network: "atlas_{{.TraefikNetwork}}"
traefik.http.routers.customer-{{.ID}}-web.rule: "Host(`{{.Domain}}`) && PathPrefix(`/i/{{.Subdomain}}`)" 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.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.tls: "true"
traefik.http.routers.customer-{{.ID}}-web.middlewares: "strip-customer-{{.ID}}@swarm,authelia-auth@swarm" 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.middlewares.strip-customer-{{.ID}}.stripprefix.prefixes: "/i/{{.Subdomain}}"
traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3000" traefik.http.services.customer-{{.ID}}-web.loadbalancer.server.port: "3001"
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
restart_policy: restart_policy:
condition: on-failure condition: on-failure
@ -76,13 +36,7 @@ networks:
traefik_net: traefik_net:
external: true external: true
name: "atlas_{{.TraefikNetwork}}" name: "atlas_{{.TraefikNetwork}}"
backend:
driver: overlay
volumes: volumes:
gitea_data: app_data:
driver: local
gitea_config:
driver: local
db_data:
driver: local driver: local

View File

@ -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_HEADSCALE` | Headscale VPN OIDC client | `./generate-secrets.sh` |
| `CLIENT_SECRET_HEADADMIN` | Headscale admin 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_PORTAINER` | Portainer OAuth client | `./scripts/generate-oauth-secrets.sh` |
| `CLIENT_SECRET_GITEA` | Gitea OAuth client | `./scripts/generate-oauth-secrets.sh` |
## 🚀 Setup Process ## 🚀 Setup Process
@ -64,7 +63,6 @@ export WOODPECKER_TOKEN=your-api-token
# Update all secrets (example commands) # 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_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 ## 🔄 Secret Rotation
@ -85,7 +83,7 @@ woodpecker secret update --repository your-repo --name CLIENT_SECRET_GITEA --val
# Regenerate OAuth client secrets only # Regenerate OAuth client secrets only
./scripts/generate-oauth-secrets.sh ./scripts/generate-oauth-secrets.sh
# Update CLIENT_SECRET_PORTAINER and CLIENT_SECRET_GITEA in vault # Update CLIENT_SECRET_PORTAINER in vault
# Deploy when convenient # Deploy when convenient
``` ```

View File

@ -1,6 +1,6 @@
# OAuth/OIDC Client Setup Guide # 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 ## 🔧 Overview
@ -27,10 +27,6 @@ Add these to your Woodpecker CI vault:
- **Variable**: `CLIENT_SECRET_PORTAINER` - **Variable**: `CLIENT_SECRET_PORTAINER`
- **Value**: Generated from `secrets/clients/portainer-secret.txt` - **Value**: Generated from `secrets/clients/portainer-secret.txt`
#### Gitea OAuth
- **Variable**: `CLIENT_SECRET_GITEA`
- **Value**: Generated from `secrets/clients/gitea-secret.txt`
## 📱 Client Configurations ## 📱 Client Configurations
### Portainer OAuth Setup ### Portainer OAuth Setup
@ -75,39 +71,6 @@ Once OAuth is working, remove middleware protection:
# traefik.http.routers.portainer.middlewares: authelia_authelia # 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**: `<from CI vault>`
- **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 ## 🔄 Deployment Process
### 1. Generate Secrets ### 1. Generate Secrets
@ -118,7 +81,6 @@ Configure in Gitea → Site Administration → Authentication Sources:
### 2. Update CI/CD Vault ### 2. Update CI/CD Vault
Add the generated secrets to your Woodpecker CI vault: Add the generated secrets to your Woodpecker CI vault:
- `CLIENT_SECRET_PORTAINER` - `CLIENT_SECRET_PORTAINER`
- `CLIENT_SECRET_GITEA`
### 3. Deploy Authelia ### 3. Deploy Authelia
Push changes to trigger CI/CD deployment with new OAuth clients. Push changes to trigger CI/CD deployment with new OAuth clients.

View File

@ -5,7 +5,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
## 📚 Available Guides ## 📚 Available Guides
### 🔧 Setup & Configuration ### 🔧 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 - **[CI/CD Vault Setup](CI_CD_VAULT_SETUP.md)** - Secret management and Woodpecker CI vault configuration
### 🚀 Getting Started ### 🚀 Getting Started
@ -18,7 +18,7 @@ This directory contains comprehensive guides for Authelia deployment and configu
2. **OAuth Integration** 2. **OAuth Integration**
- Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh` - Generate OAuth client secrets with `./scripts/generate-oauth-secrets.sh`
- Follow [OAuth Setup Guide](OAUTH_SETUP.md) for service configuration - 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** 3. **Production Deployment**
- Commit changes to trigger CI/CD pipeline - Commit changes to trigger CI/CD pipeline
@ -55,7 +55,7 @@ docker compose -f docker-compose.dev.yml up -d
### Required Secrets (12 Total) ### Required Secrets (12 Total)
- **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP - **Core Secrets (5)**: LDAP, JWT, encryption, session, SMTP
- **OIDC Secrets (3)**: HMAC, private key, JWKS key - **OIDC Secrets (3)**: HMAC, private key, JWKS key
- **Client Secrets (4)**: Headscale (2), Portainer, Gitea - **Client Secrets (3)**: Headscale (2), Portainer
## 🔍 Troubleshooting ## 🔍 Troubleshooting

View File

@ -106,11 +106,6 @@ Add these secrets to your Woodpecker CI vault:
- **Secret File**: `secrets/clients/portainer-secret.txt` - **Secret File**: `secrets/clients/portainer-secret.txt`
- **Value**: (copy content from the file above) - **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 ## Important Notes
1. **Never commit these files** - they are automatically gitignored 1. **Never commit these files** - they are automatically gitignored
@ -124,9 +119,6 @@ If using Woodpecker CLI:
```bash ```bash
# Update Portainer secret # Update Portainer secret
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_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 ## Verification
@ -149,17 +141,15 @@ print_summary() {
echo "${YELLOW}📁 Generated Files:${NC}" echo "${YELLOW}📁 Generated Files:${NC}"
echo " • secrets/oauth-secrets.env" echo " • secrets/oauth-secrets.env"
echo " • secrets/clients/portainer-secret.txt" echo " • secrets/clients/portainer-secret.txt"
echo " • secrets/clients/gitea-secret.txt"
echo " • secrets/VAULT_SECRETS.md" echo " • secrets/VAULT_SECRETS.md"
echo echo
echo "${YELLOW}🔑 Required CI/CD Vault Updates:${NC}" echo "${YELLOW}🔑 Required CI/CD Vault Updates:${NC}"
echo " • CLIENT_SECRET_PORTAINER" echo " • CLIENT_SECRET_PORTAINER"
echo " • CLIENT_SECRET_GITEA"
echo echo
echo "${RED}⚠️ NEXT STEPS:${NC}" echo "${RED}⚠️ NEXT STEPS:${NC}"
echo " 1. Update your CI/CD vault with new secrets" echo " 1. Update your CI/CD vault with new secrets"
echo " 2. Deploy Authelia to use new client configurations" 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 " 4. Test authentication flows"
echo echo
echo "${BLUE}📖 Full setup guide: docs/OAUTH_SETUP.md${NC}" echo "${BLUE}📖 Full setup guide: docs/OAUTH_SETUP.md${NC}"
@ -195,8 +185,7 @@ main() {
# Generate client secrets # Generate client secrets
generate_client_secret "portainer" "portainer-secret.txt" generate_client_secret "portainer" "portainer-secret.txt"
generate_client_secret "gitea" "gitea-secret.txt"
create_vault_instructions create_vault_instructions
print_summary print_summary
} }

View File

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

View File

@ -78,8 +78,7 @@ services:
echo "$${CLIENT_SECRET_HEADSCALE}" > /run/secrets/CLIENT_SECRET_HEADSCALE echo "$${CLIENT_SECRET_HEADSCALE}" > /run/secrets/CLIENT_SECRET_HEADSCALE
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 '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
{ 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
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 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:
AUTHELIA_SERVER_ADDRESS: tcp://0.0.0.0:9091/login AUTHELIA_SERVER_ADDRESS: tcp://0.0.0.0:9091/login
@ -127,7 +126,6 @@ services:
CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA= CLIENT_SECRET_HEADSCALE: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8= CLIENT_SECRET_HEADADMIN: RAxwkJxwMBSYkaA0r+D5qZdEFIrVEZJbigOPtkCBED8=
CLIENT_SECRET_PORTAINER: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA= CLIENT_SECRET_PORTAINER: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
CLIENT_SECRET_GITEA: t4Hvp6DnpA0T+0ePbdx8lPIAujFMrkjEnx5aMQkMFiA=
volumes: volumes:
- authelia_data:/data - authelia_data:/data
networks: networks:
@ -182,6 +180,8 @@ services:
- "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik" - "traefik.http.middlewares.strip-traefik.stripprefix.prefixes=/admin/traefik"
- "traefik.http.services.traefik-api.loadbalancer.server.port=8080" - "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: ss-atlas:
image: atlas-ss-atlas:latest image: atlas-ss-atlas:latest
environment: environment:
@ -212,6 +212,7 @@ services:
- ARCHIVE_PATH=/archives - ARCHIVE_PATH=/archives
- LANDING_TAGLINE=${LANDING_TAGLINE:-Your own workspace, ready in minutes.} - 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} - LANDING_FEATURES=${LANDING_FEATURES:-Dedicated environment|Secure single sign-on|Automatic provisioning|Manage subscription anytime}
- ADMIN_SECRET=${ADMIN_SECRET:-}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro - /var/run/docker.sock:/var/run/docker.sock:ro
- atlas_archives:/archives - atlas_archives:/archives