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

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