use caddyfile instead of api

This commit is contained in:
pavel 2026-05-14 23:36:16 +02:00
commit 3c79a8c778
9 changed files with 206 additions and 248 deletions

View file

@ -13,6 +13,13 @@ FORGEJO_TOKEN=
FORGEJO_GIT_USERNAME=oauth2 FORGEJO_GIT_USERNAME=oauth2
FORGEJO_ORG= FORGEJO_ORG=
CADDY_ROUTE_ENABLED=false 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_ADMIN_URL=http://localhost:2019
CADDY_SERVER_ID=srv0 CADDY_SERVER_ID=srv0
CADDY_DOMAIN_SUFFIX= CADDY_DOMAIN_SUFFIX=

View file

@ -6,7 +6,7 @@ Features:
- Workspace model mapping each app-user workspace to a host Unix account - Workspace model mapping each app-user workspace to a host Unix account
- Create projects per authenticated user - Create projects per authenticated user
- Auto-create Forgejo repository or onboard an existing repository URL - 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 - Manage project env vars
- Auto-generate user-scoped systemd service per project - Auto-generate user-scoped systemd service per project
- PostgreSQL persistence with startup migration - PostgreSQL persistence with startup migration
@ -43,20 +43,33 @@ Important:
- `FORGEJO_ORG` switches creation from personal repos to org repos. - `FORGEJO_ORG` switches creation from personal repos to org repos.
- `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`. - `UNIX_SOCKET_PATH` enables unix socket listener in addition to `LISTEN_ADDR`.
- `CADDY_ROUTE_ENABLED=true` enables route provisioning. - `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_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_SERVER_ID` is the HTTP server object id under `apps.http.servers` (default `srv0`).
- `CADDY_DOMAIN_SUFFIX` controls generated host as `<slug>.<suffix>` (if empty: `<user>-<slug>.local`). - `CADDY_DOMAIN_SUFFIX` controls generated host as `<slug>.<suffix>` (if empty: `<user>-<slug>.local`).
- `WEBHOOK_BASE_URL` sets absolute webhook URLs returned by API/UI (recommended behind reverse proxy). - `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: On provisioning/reprovision, when `CADDY_ROUTE_ENABLED=true`, the app regenerates a managed fragment containing all project routes and then runs `CADDY_RELOAD_COMMAND`.
- `POST /config/apps/http/servers/<CADDY_SERVER_ID>/routes`
Route shape: Each route block is:
- `match.host = [<project host>]` - `<slug>.<domain> {`
- `handle[0].handler = reverse_proxy` - `import auth` (name controlled by `CADDY_AUTH_IMPORT_NAME`, optional when auth enabled)
- `handle[0].upstreams[0].dial = unix/<unix-user-home>/projects/<app>/app.sock` - `log`
- `reverse_proxy unix//<unix-user-home>/projects/<app>/app.sock`
- `}`
In your static `/etc/caddy/Caddyfile`, include:
```caddyfile
import /etc/caddy/fragments/project-manager.caddy
```
## Workspaces and Unix Users ## Workspaces and Unix Users

View file

@ -27,6 +27,13 @@ func loadConfig() (Config, error) {
CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"), CaddyServerID: getenv("CADDY_SERVER_ID", "srv0"),
CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""), CaddyDomainSuffix: getenv("CADDY_DOMAIN_SUFFIX", ""),
CaddyRouteEnabled: getenvBool("CADDY_ROUTE_ENABLED", false), 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", ""), "/"), WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"),
} }
if cfg.DatabaseURL == "" { if cfg.DatabaseURL == "" {

View file

@ -8,7 +8,10 @@ import (
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"os"
"os/exec"
"path/filepath" "path/filepath"
"sort"
"strings" "strings"
) )
@ -57,90 +60,109 @@ func (a *App) ensureCaddyRoute(ctx context.Context, p Project) error {
if !a.cfg.CaddyRouteEnabled { if !a.cfg.CaddyRouteEnabled {
return nil return nil
} }
projectDir, err := a.projectDirFor(p) return a.syncCaddyConfigFragment(ctx)
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}}}},
}
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 { if err != nil {
return err return err
} }
newRoutes, err := mergedDedupedRoutes(cfg, a.cfg.CaddyServerID, route) content, err := a.renderCaddyRoutesFragment(routes)
if err != nil { if err != nil {
return err return err
} }
body, _ := json.Marshal(newRoutes) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
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 {
return err return err
} }
req.Header.Set("Content-Type", "application/json") tmp := path + ".tmp"
resp, err := http.DefaultClient.Do(req) if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil {
if err != nil { return err
return fmt.Errorf("caddy routes rewrite failed: %w", err)
} }
defer resp.Body.Close() if err := os.Rename(tmp, path); err != nil {
if resp.StatusCode >= 300 { return err
respBody, _ := io.ReadAll(resp.Body) }
return fmt.Errorf("caddy routes rewrite error: status=%d body=%s", resp.StatusCode, string(respBody)) 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 return nil
} }
func mergedDedupedRoutes(cfg map[string]any, serverID string, desired map[string]any) ([]any, error) { func (a *App) listCaddyProjectRoutes(ctx context.Context) ([]Project, error) {
apps, ok := cfg["apps"].(map[string]any) rows, err := a.db.QueryContext(ctx, `
if !ok { SELECT p.id, p.user_id, p.name, p.slug, p.route_host, p.workspace_id, w.name, w.unix_user
return nil, errors.New("caddy config missing apps") 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) defer rows.Close()
if !ok { var out []Project
return nil, errors.New("caddy config missing apps.http") 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
} }
servers, ok := httpApp["servers"].(map[string]any) out = append(out, p)
if !ok {
return nil, errors.New("caddy config missing apps.http.servers")
} }
server, ok := servers[serverID].(map[string]any) if err := rows.Err(); err != nil {
if !ok { return nil, err
return nil, fmt.Errorf("caddy config missing server %q", serverID)
} }
routes, ok := server["routes"].([]any) sort.Slice(out, func(i, j int) bool {
if !ok { if out[i].RouteHost == out[j].RouteHost {
routes = []any{} return out[i].ID < out[j].ID
}
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
}
if rid, ok := rm["@id"].(string); ok && rid == desiredID {
if !seenDesired {
out = append(out, desired)
seenDesired = true
}
continue
}
out = append(out, r)
}
if !seenDesired {
out = append(out, desired)
} }
return out[i].RouteHost < out[j].RouteHost
})
return out, nil 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) { func (a *App) fetchCaddyConfig(ctx context.Context) (map[string]any, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil)
if err != nil { if err != nil {
@ -162,151 +184,35 @@ func (a *App) fetchCaddyConfig(ctx context.Context) (map[string]any, error) {
return out, nil return out, nil
} }
func (a *App) ensureCaddyTLSSubject(ctx context.Context, host string) error { func (a *App) persistCaddyConfigToDisk(ctx context.Context) error {
host = strings.TrimSpace(host) path := strings.TrimSpace(a.cfg.CaddyPersistConfigPath)
if host == "" { if path == "" {
return nil return nil
} }
cfg, err := a.fetchCaddyConfig(ctx) req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.cfg.CaddyAdminURL+"/config/", nil)
if err != nil { if err != nil {
return err 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) 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 { 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 return err
} }
putReq.Header.Set("Content-Type", "application/json") return os.Rename(tmp, path)
putResp, err := http.DefaultClient.Do(putReq) }
if err != nil {
return fmt.Errorf("caddy tls subjects update failed: %w", err) func (a *App) ensureCaddyTLSSubject(ctx context.Context, host string) error {
}
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 nil 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 { func (a *App) ensureForgejoDeployWebhook(ctx context.Context, p Project) error {
if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" || p.RepoURL == "" { if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" || p.RepoURL == "" {
return nil return nil

View file

@ -132,12 +132,6 @@ func (a *App) provisionProject(ctx context.Context, p *Project) error {
p.ProvisionError = err.Error() p.ProvisionError = err.Error()
return err 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 { if err := a.updateProvisioningState(ctx, p.ID, "provisioned", ""); err != nil {
p.ProvisionState = "failed" p.ProvisionState = "failed"
p.ProvisionError = err.Error() p.ProvisionError = err.Error()

View file

@ -3,6 +3,7 @@ package app
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"os"
osuser "os/user" osuser "os/user"
"strconv" "strconv"
"strings" "strings"
@ -275,10 +276,25 @@ func (a *App) handleCaddyConfig(w http.ResponseWriter, r *http.Request, _ User)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return 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()) cfg, err := a.fetchCaddyConfig(r.Context())
if err != nil { if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway) http.Error(w, err.Error(), http.StatusBadGateway)
return return
} }
writeJSON(w, http.StatusOK, cfg) writeJSON(w, http.StatusOK, map[string]any{
"format": "json",
"content": mustJSONPretty(cfg),
})
} }

View file

@ -24,6 +24,13 @@ type Config struct {
CaddyServerID string CaddyServerID string
CaddyDomainSuffix string CaddyDomainSuffix string
CaddyRouteEnabled bool CaddyRouteEnabled bool
CaddyAuthEnabled bool
CaddyAuthUpstream string
CaddyAuthURI string
CaddyPersistConfigPath string
CaddyFragmentPath string
CaddyReloadCommand string
CaddyAuthImportName string
WebhookBaseURL string WebhookBaseURL string
} }

View file

@ -39,6 +39,14 @@ func writeJSON(w http.ResponseWriter, status int, data any) {
_ = json.NewEncoder(w).Encode(data) _ = 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 { func (a *App) deployWebhookURL(projectID int64, token string) string {
if token == "" { if token == "" {
return "" return ""

View file

@ -202,7 +202,7 @@ async function loadCaddyConfig() {
out.textContent = 'Loading Caddy config...'; out.textContent = 'Loading Caddy config...';
try { try {
const cfg = await api('/api/caddy/config'); 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) { } catch (err) {
out.textContent = `Error: ${err.message}`; out.textContent = `Error: ${err.message}`;
} }