forked from Nixius/authelia
bump
This commit is contained in:
parent
2e8979d4d8
commit
630bd3d3f4
|
|
@ -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.
|
||||||
|
|
@ -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.
|
|
@ -1,5 +1,3 @@
|
||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mariadb:
|
mariadb:
|
||||||
build:
|
build:
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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})
|
||||||
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue