diff --git a/.env.example b/.env.example index 5289a16..42a32af 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,13 @@ FORGEJO_TOKEN= FORGEJO_GIT_USERNAME=oauth2 FORGEJO_ORG= CADDY_ROUTE_ENABLED=false +CADDY_AUTH_ENABLED=true +CADDY_AUTH_UPSTREAM=localhost:9000 +CADDY_AUTH_URI=/outpost.goauthentik.io/auth/caddy +CADDY_AUTH_IMPORT_NAME=auth +CADDY_FRAGMENT_PATH=/etc/caddy/fragments/project-manager.caddy +CADDY_RELOAD_COMMAND=sudo -n systemctl reload caddy +CADDY_PERSIST_CONFIG_PATH= CADDY_ADMIN_URL=http://localhost:2019 CADDY_SERVER_ID=srv0 CADDY_DOMAIN_SUFFIX= diff --git a/README.md b/README.md index 1ced087..d1aa6f1 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Features: - Workspace model mapping each app-user workspace to a host Unix account - Create projects per authenticated user - Auto-create Forgejo repository or onboard an existing repository URL -- Auto-create Caddy reverse-proxy route via Admin API (optional) +- Auto-generate Caddyfile route fragment and reload Caddy (optional) - Manage project env vars - Auto-generate user-scoped systemd service per project - PostgreSQL persistence with startup migration @@ -43,20 +43,33 @@ Important: - `FORGEJO_ORG` switches creation from personal repos to org repos. - `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`. - `CADDY_ROUTE_ENABLED=true` enables route provisioning. +- `CADDY_AUTH_ENABLED=true` enables authentik-style forward auth on provisioned app routes. +- `CADDY_AUTH_UPSTREAM` sets auth upstream dial (default `localhost:9000`). +- `CADDY_AUTH_URI` sets auth check URI (default `/outpost.goauthentik.io/auth/caddy`). +- `CADDY_FRAGMENT_PATH` points to generated Caddyfile fragment path (default `/etc/caddy/fragments/project-manager.caddy`). +- `CADDY_RELOAD_COMMAND` command run after fragment write (default `sudo -n systemctl reload caddy`). +- `CADDY_AUTH_IMPORT_NAME` snippet import name used per app host block (default `auth`). +- `CADDY_PERSIST_CONFIG_PATH` is only useful if you still use Caddy Admin API externally. - `CADDY_ADMIN_URL` points to the Caddy Admin API (default `http://localhost:2019`). - `CADDY_SERVER_ID` is the HTTP server object id under `apps.http.servers` (default `srv0`). - `CADDY_DOMAIN_SUFFIX` controls generated host as `.` (if empty: `-.local`). - `WEBHOOK_BASE_URL` sets absolute webhook URLs returned by API/UI (recommended behind reverse proxy). -## Caddy API Behavior +## Caddyfile Fragment Behavior -On project creation, when Caddy routing is enabled, the app appends a route to: -- `POST /config/apps/http/servers//routes` +On provisioning/reprovision, when `CADDY_ROUTE_ENABLED=true`, the app regenerates a managed fragment containing all project routes and then runs `CADDY_RELOAD_COMMAND`. -Route shape: -- `match.host = []` -- `handle[0].handler = reverse_proxy` -- `handle[0].upstreams[0].dial = unix//projects//app.sock` +Each route block is: +- `. {` +- `import auth` (name controlled by `CADDY_AUTH_IMPORT_NAME`, optional when auth enabled) +- `log` +- `reverse_proxy unix///projects//app.sock` +- `}` + +In your static `/etc/caddy/Caddyfile`, include: +```caddyfile +import /etc/caddy/fragments/project-manager.caddy +``` ## Workspaces and Unix Users diff --git a/internal/app/config.go b/internal/app/config.go index f51c430..39af1d2 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -9,25 +9,32 @@ import ( func loadConfig() (Config, error) { cfg := Config{ - DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), - ListenAddr: getenv("LISTEN_ADDR", ":8080"), - UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""), - DevMode: getenvBool("DEV_MODE", true), - DemoUser: getenv("DEV_DEMO_USER", "demo"), - DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), - AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), - AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), - ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), - ForgejoToken: getenv("FORGEJO_TOKEN", ""), - ForgejoGitUsername: getenv("FORGEJO_GIT_USERNAME", "oauth2"), - ForgejoOrg: getenv("FORGEJO_ORG", ""), - DBProvisionAdminURL: getenv("DB_PROVISION_ADMIN_URL", ""), - DBProvisionEnabled: getenvBool("DB_PROVISION_ENABLED", true), - CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), - CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), - CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), - CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false), - WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"), + DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), + ListenAddr: getenv("LISTEN_ADDR", ":8080"), + UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""), + DevMode: getenvBool("DEV_MODE", true), + DemoUser: getenv("DEV_DEMO_USER", "demo"), + DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), + AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), + AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), + ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), + ForgejoToken: getenv("FORGEJO_TOKEN", ""), + ForgejoGitUsername: getenv("FORGEJO_GIT_USERNAME", "oauth2"), + ForgejoOrg: getenv("FORGEJO_ORG", ""), + DBProvisionAdminURL: getenv("DB_PROVISION_ADMIN_URL", ""), + DBProvisionEnabled: getenvBool("DB_PROVISION_ENABLED", true), + CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), + CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), + CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), + CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false), + CaddyAuthEnabled: getenvBool("CADDY_AUTH_ENABLED", true), + CaddyAuthUpstream: getenv("CADDY_AUTH_UPSTREAM", "localhost:9000"), + CaddyAuthURI: getenv("CADDY_AUTH_URI", "/outpost.goauthentik.io/auth/caddy"), + CaddyPersistConfigPath: strings.TrimSpace(getenv("CADDY_PERSIST_CONFIG_PATH", "")), + CaddyFragmentPath: strings.TrimSpace(getenv("CADDY_FRAGMENT_PATH", "/etc/caddy/fragments/project-manager.caddy")), + CaddyReloadCommand: strings.TrimSpace(getenv("CADDY_RELOAD_COMMAND", "sudo -n systemctl reload caddy")), + CaddyAuthImportName: strings.TrimSpace(getenv("CADDY_AUTH_IMPORT_NAME", "auth")), + WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"), } if cfg.DatabaseURL == "" { return cfg, errors.New("DATABASE_URL is required") diff --git a/internal/app/integrations.go b/internal/app/integrations.go index 787f124..749c9e8 100644 --- a/internal/app/integrations.go +++ b/internal/app/integrations.go @@ -8,7 +8,10 @@ import ( "io" "net/http" "net/url" + "os" + "os/exec" "path/filepath" + "sort" "strings" ) @@ -57,90 +60,109 @@ func (a *App) ensureCaddyRoute(ctx context.Context, p Project) error { if !a.cfg.CaddyRouteEnabled { return nil } - projectDir, err := a.projectDirFor(p) - if err != nil { - return err - } - socketPath := filepath.Join(projectDir, "app.sock") - route := map[string]any{ - "@id": "project-route-" + p.UserID + "-" + p.Slug, - "match": []any{map[string]any{"host": []string{p.RouteHost}}}, - "handle": []any{map[string]any{"handler": "reverse_proxy", "upstreams": []any{map[string]any{"dial": "unix/" + socketPath}}}}, - } + return a.syncCaddyConfigFragment(ctx) +} - cfg, err := a.fetchCaddyConfig(ctx) +func (a *App) syncCaddyConfigFragment(ctx context.Context) error { + path := strings.TrimSpace(a.cfg.CaddyFragmentPath) + if path == "" { + return errors.New("CADDY_FRAGMENT_PATH is required when CADDY_ROUTE_ENABLED=true") + } + routes, err := a.listCaddyProjectRoutes(ctx) if err != nil { return err } - newRoutes, err := mergedDedupedRoutes(cfg, a.cfg.CaddyServerID, route) + content, err := a.renderCaddyRoutesFragment(routes) if err != nil { return err } - body, _ := json.Marshal(newRoutes) - url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID) - req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, strings.NewReader(string(body))) - if err != nil { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } - req.Header.Set("Content-Type", "application/json") - resp, err := http.DefaultClient.Do(req) - if err != nil { - return fmt.Errorf("caddy routes rewrite failed: %w", err) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil { + return err } - defer resp.Body.Close() - if resp.StatusCode >= 300 { - respBody, _ := io.ReadAll(resp.Body) - return fmt.Errorf("caddy routes rewrite error: status=%d body=%s", resp.StatusCode, string(respBody)) + if err := os.Rename(tmp, path); err != nil { + return err + } + return a.reloadCaddy() +} + +func (a *App) reloadCaddy() error { + reloadCmd := strings.TrimSpace(a.cfg.CaddyReloadCommand) + if reloadCmd == "" { + return nil + } + cmd := exec.Command("bash", "-lc", reloadCmd) + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("caddy reload failed: %w output=%s", err, strings.TrimSpace(string(out))) } return nil } -func mergedDedupedRoutes(cfg map[string]any, serverID string, desired map[string]any) ([]any, error) { - apps, ok := cfg["apps"].(map[string]any) - if !ok { - return nil, errors.New("caddy config missing apps") +func (a *App) listCaddyProjectRoutes(ctx context.Context) ([]Project, error) { + rows, err := a.db.QueryContext(ctx, ` + SELECT p.id, p.user_id, p.name, p.slug, p.route_host, p.workspace_id, w.name, w.unix_user + FROM projects p + JOIN workspaces w ON w.id = p.workspace_id + WHERE p.route_host <> '' + `) + if err != nil { + return nil, err } - httpApp, ok := apps["http"].(map[string]any) - if !ok { - return nil, errors.New("caddy config missing apps.http") - } - servers, ok := httpApp["servers"].(map[string]any) - if !ok { - return nil, errors.New("caddy config missing apps.http.servers") - } - server, ok := servers[serverID].(map[string]any) - if !ok { - return nil, fmt.Errorf("caddy config missing server %q", serverID) - } - routes, ok := server["routes"].([]any) - if !ok { - routes = []any{} - } - desiredID, _ := desired["@id"].(string) - var out []any - seenDesired := false - for i, r := range routes { - _ = i - rm, ok := r.(map[string]any) - if !ok { - out = append(out, r) - continue + defer rows.Close() + var out []Project + for rows.Next() { + var p Project + if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.RouteHost, &p.WorkspaceID, &p.Workspace, &p.UnixUser); err != nil { + return nil, err } - if rid, ok := rm["@id"].(string); ok && rid == desiredID { - if !seenDesired { - out = append(out, desired) - seenDesired = true - } - continue + out = append(out, p) + } + if err := rows.Err(); err != nil { + return nil, err + } + sort.Slice(out, func(i, j int) bool { + if out[i].RouteHost == out[j].RouteHost { + return out[i].ID < out[j].ID } - out = append(out, r) - } - if !seenDesired { - out = append(out, desired) - } + return out[i].RouteHost < out[j].RouteHost + }) return out, nil } +func (a *App) renderCaddyRoutesFragment(projects []Project) (string, error) { + var b strings.Builder + b.WriteString("# Managed by project-manager. Do not edit manually.\n") + for _, p := range projects { + projectDir, err := a.projectDirFor(p) + if err != nil { + return "", err + } + socketPath := filepath.Join(projectDir, "app.sock") + b.WriteString("\n") + b.WriteString(p.RouteHost) + b.WriteString(" {\n") + if a.cfg.CaddyAuthEnabled { + importName := strings.TrimSpace(a.cfg.CaddyAuthImportName) + if importName == "" { + importName = "auth" + } + b.WriteString(" import ") + b.WriteString(importName) + b.WriteString("\n") + } + b.WriteString(" log\n") + b.WriteString(" reverse_proxy unix//") + b.WriteString(socketPath) + b.WriteString("\n") + b.WriteString("}\n") + } + return b.String(), nil +} + func (a *App) fetchCaddyConfig(ctx context.Context) (map[string]any, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil) if err != nil { @@ -162,151 +184,35 @@ func (a *App) fetchCaddyConfig(ctx context.Context) (map[string]any, error) { return out, nil } -func (a *App) ensureCaddyTLSSubject(ctx context.Context, host string) error { - host = strings.TrimSpace(host) - if host == "" { +func (a *App) persistCaddyConfigToDisk(ctx context.Context) error { + path := strings.TrimSpace(a.cfg.CaddyPersistConfigPath) + if path == "" { return nil } - cfg, err := a.fetchCaddyConfig(ctx) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil) if err != nil { return err } - - idx, subjects, found, err := findTLSPolicyForHost(cfg, host) - if err != nil { - return err - } - if found { - return nil - } - if idx < 0 { - return errors.New("no suitable Caddy TLS automation policy found") - } - - // Preferred: append one subject. - appendURL := fmt.Sprintf("%s/config/apps/tls/automation/policies/%d/subjects", a.cfg.CaddyAdminURL, idx) - body, _ := json.Marshal(host) - req, err := http.NewRequestWithContext(ctx, http.MethodPost, appendURL, strings.NewReader(string(body))) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/json") resp, err := http.DefaultClient.Do(req) - if err == nil { - defer resp.Body.Close() - if resp.StatusCode < 300 { - return nil - } - } - - // Fallback: replace the subjects array with host appended. - subjects = append(subjects, host) - replaceBody, _ := json.Marshal(subjects) - putReq, err := http.NewRequestWithContext(ctx, http.MethodPut, appendURL, strings.NewReader(string(replaceBody))) if err != nil { + return fmt.Errorf("caddy config fetch for persist failed: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode >= 300 { + return fmt.Errorf("caddy config fetch for persist error: status=%d body=%s", resp.StatusCode, string(body)) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, body, 0o640); err != nil { return err } - putReq.Header.Set("Content-Type", "application/json") - putResp, err := http.DefaultClient.Do(putReq) - if err != nil { - return fmt.Errorf("caddy tls subjects update failed: %w", err) - } - defer putResp.Body.Close() - if putResp.StatusCode >= 300 { - b, _ := io.ReadAll(putResp.Body) - return fmt.Errorf("caddy tls subjects update error: status=%d body=%s", putResp.StatusCode, string(b)) - } + return os.Rename(tmp, path) +} + +func (a *App) ensureCaddyTLSSubject(ctx context.Context, host string) error { return nil } -func findTLSPolicyForHost(cfg map[string]any, host string) (policyIdx int, subjects []string, alreadyPresent bool, err error) { - policyIdx = -1 - apps, ok := cfg["apps"].(map[string]any) - if !ok { - return -1, nil, false, errors.New("caddy config missing apps") - } - tlsObj, ok := apps["tls"].(map[string]any) - if !ok { - return -1, nil, false, errors.New("caddy config missing apps.tls") - } - automation, ok := tlsObj["automation"].(map[string]any) - if !ok { - return -1, nil, false, errors.New("caddy config missing apps.tls.automation") - } - policiesAny, ok := automation["policies"].([]any) - if !ok || len(policiesAny) == 0 { - return -1, nil, false, errors.New("caddy config missing tls automation policies") - } - - // Prefer a non-internal policy with subjects list. - for i, pAny := range policiesAny { - pol, ok := pAny.(map[string]any) - if !ok { - continue - } - subjectsRaw, ok := pol["subjects"].([]any) - if !ok { - continue - } - cur := make([]string, 0, len(subjectsRaw)) - for _, s := range subjectsRaw { - if sv, ok := s.(string); ok { - cur = append(cur, sv) - if strings.EqualFold(strings.TrimSpace(sv), host) { - return i, cur, true, nil - } - } - } - if !policyHasInternalIssuer(pol) && policyIdx < 0 { - policyIdx = i - subjects = cur - } - } - // Fallback to first policy that has subjects. - if policyIdx < 0 { - for i, pAny := range policiesAny { - pol, ok := pAny.(map[string]any) - if !ok { - continue - } - subjectsRaw, ok := pol["subjects"].([]any) - if !ok { - continue - } - cur := make([]string, 0, len(subjectsRaw)) - for _, s := range subjectsRaw { - if sv, ok := s.(string); ok { - cur = append(cur, sv) - if strings.EqualFold(strings.TrimSpace(sv), host) { - return i, cur, true, nil - } - } - } - policyIdx = i - subjects = cur - break - } - } - return policyIdx, subjects, false, nil -} - -func policyHasInternalIssuer(pol map[string]any) bool { - issuersAny, ok := pol["issuers"].([]any) - if !ok { - return false - } - for _, iAny := range issuersAny { - iss, ok := iAny.(map[string]any) - if !ok { - continue - } - if mod, ok := iss["module"].(string); ok && strings.EqualFold(strings.TrimSpace(mod), "internal") { - return true - } - } - return false -} - func (a *App) ensureForgejoDeployWebhook(ctx context.Context, p Project) error { if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" || p.RepoURL == "" { return nil diff --git a/internal/app/project_service.go b/internal/app/project_service.go index 36e4a44..cf115c1 100644 --- a/internal/app/project_service.go +++ b/internal/app/project_service.go @@ -132,12 +132,6 @@ func (a *App) provisionProject(ctx context.Context, p *Project) error { p.ProvisionError = err.Error() return err } - if err := a.ensureCaddyTLSSubject(ctx, p.RouteHost); err != nil { - _ = a.updateProvisioningState(ctx, p.ID, "failed", err.Error()) - p.ProvisionState = "failed" - p.ProvisionError = err.Error() - return err - } if err := a.updateProvisioningState(ctx, p.ID, "provisioned", ""); err != nil { p.ProvisionState = "failed" p.ProvisionError = err.Error() diff --git a/internal/app/routes_handlers.go b/internal/app/routes_handlers.go index fac0f36..cf3a4ea 100644 --- a/internal/app/routes_handlers.go +++ b/internal/app/routes_handlers.go @@ -3,6 +3,7 @@ package app import ( "encoding/json" "net/http" + "os" osuser "os/user" "strconv" "strings" @@ -275,10 +276,25 @@ func (a *App) handleCaddyConfig(w http.ResponseWriter, r *http.Request, _ User) http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } + if a.cfg.CaddyRouteEnabled && strings.TrimSpace(a.cfg.CaddyFragmentPath) != "" { + content, err := os.ReadFile(a.cfg.CaddyFragmentPath) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "format": "caddyfile", + "content": string(content), + }) + return + } cfg, err := a.fetchCaddyConfig(r.Context()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } - writeJSON(w, http.StatusOK, cfg) + writeJSON(w, http.StatusOK, map[string]any{ + "format": "json", + "content": mustJSONPretty(cfg), + }) } diff --git a/internal/app/types.go b/internal/app/types.go index 5711a9a..3c9638b 100644 --- a/internal/app/types.go +++ b/internal/app/types.go @@ -6,25 +6,32 @@ import ( ) type Config struct { - DatabaseURL string - ListenAddr string - UnixSocketPath string - DevMode bool - DemoUser string - DemoEmail string - AuthHeaderUser string - AuthHeaderEmail string - ForgejoBaseURL string - ForgejoToken string - ForgejoGitUsername string - ForgejoOrg string - DBProvisionAdminURL string - DBProvisionEnabled bool - CaddyAdminURL string - CaddyServerID string - CaddyDomainSuffix string - CaddyRouteEnabled bool - WebhookBaseURL string + DatabaseURL string + ListenAddr string + UnixSocketPath string + DevMode bool + DemoUser string + DemoEmail string + AuthHeaderUser string + AuthHeaderEmail string + ForgejoBaseURL string + ForgejoToken string + ForgejoGitUsername string + ForgejoOrg string + DBProvisionAdminURL string + DBProvisionEnabled bool + CaddyAdminURL string + CaddyServerID string + CaddyDomainSuffix string + CaddyRouteEnabled bool + CaddyAuthEnabled bool + CaddyAuthUpstream string + CaddyAuthURI string + CaddyPersistConfigPath string + CaddyFragmentPath string + CaddyReloadCommand string + CaddyAuthImportName string + WebhookBaseURL string } type App struct { diff --git a/internal/app/util.go b/internal/app/util.go index bf1cb1d..d993d31 100644 --- a/internal/app/util.go +++ b/internal/app/util.go @@ -39,6 +39,14 @@ func writeJSON(w http.ResponseWriter, status int, data any) { _ = json.NewEncoder(w).Encode(data) } +func mustJSONPretty(data any) string { + b, err := json.MarshalIndent(data, "", " ") + if err != nil { + return "{}" + } + return string(b) +} + func (a *App) deployWebhookURL(projectID int64, token string) string { if token == "" { return "" diff --git a/web/app.js b/web/app.js index f958d91..390cf27 100644 --- a/web/app.js +++ b/web/app.js @@ -202,7 +202,7 @@ async function loadCaddyConfig() { out.textContent = 'Loading Caddy config...'; try { const cfg = await api('/api/caddy/config'); - out.textContent = JSON.stringify(cfg, null, 2); + out.textContent = cfg.content || JSON.stringify(cfg, null, 2); } catch (err) { out.textContent = `Error: ${err.message}`; }