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

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