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_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=

View file

@ -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 `<slug>.<suffix>` (if empty: `<user>-<slug>.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/<CADDY_SERVER_ID>/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 = [<project host>]`
- `handle[0].handler = reverse_proxy`
- `handle[0].upstreams[0].dial = unix/<unix-user-home>/projects/<app>/app.sock`
Each route block is:
- `<slug>.<domain> {`
- `import auth` (name controlled by `CADDY_AUTH_IMPORT_NAME`, optional when auth enabled)
- `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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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}`;
}