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

@ -9,25 +9,32 @@ import (
func loadConfig() (Config, error) { func loadConfig() (Config, error) {
cfg := Config{ cfg := Config{
DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"), DatabaseURL: getenv("DATABASE_URL", "postgres://postgres:postgres@localhost:5432/box?sslmode=disable"),
ListenAddr: getenv("LISTEN_ADDR", ":8080"), ListenAddr: getenv("LISTEN_ADDR", ":8080"),
UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""), UnixSocketPath: getenv("UNIX_SOCKET_PATH", ""),
DevMode: getenvBool("DEV_MODE", true), DevMode: getenvBool("DEV_MODE", true),
DemoUser: getenv("DEV_DEMO_USER", "demo"), DemoUser: getenv("DEV_DEMO_USER", "demo"),
DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"), DemoEmail: getenv("DEV_DEMO_EMAIL", "demo@example.local"),
AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"), AuthHeaderUser: getenv("AUTH_HEADER_USER", "X-authentik-username"),
AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"), AuthHeaderEmail: getenv("AUTH_HEADER_EMAIL", "X-authentik-email"),
ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"), ForgejoBaseURL: strings.TrimRight(getenv("FORGEJO_BASE_URL", ""), "/"),
ForgejoToken: getenv("FORGEJO_TOKEN", ""), ForgejoToken: getenv("FORGEJO_TOKEN", ""),
ForgejoGitUsername: getenv("FORGEJO_GIT_USERNAME", "oauth2"), ForgejoGitUsername: getenv("FORGEJO_GIT_USERNAME", "oauth2"),
ForgejoOrg: getenv("FORGEJO_ORG", ""), ForgejoOrg: getenv("FORGEJO_ORG", ""),
DBProvisionAdminURL: getenv("DB_PROVISION_ADMIN_URL", ""), DBProvisionAdminURL: getenv("DB_PROVISION_ADMIN_URL", ""),
DBProvisionEnabled: getenvBool("DB_PROVISION_ENABLED", true), DBProvisionEnabled: getenvBool("DB_PROVISION_ENABLED", true),
CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"), CaddyAdminURL: strings.TrimRight(getenv("CADDY_ADMIN_URL", "http://localhost:2019"), "/"),
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),
WebhookBaseURL: strings.TrimRight(getenv("WEBHOOK_BASE_URL", ""), "/"), 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 == "" { if cfg.DatabaseURL == "" {
return cfg, errors.New("DATABASE_URL is required") return cfg, errors.New("DATABASE_URL is required")

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
servers, ok := httpApp["servers"].(map[string]any) if err := rows.Scan(&p.ID, &p.UserID, &p.Name, &p.Slug, &p.RouteHost, &p.WorkspaceID, &p.Workspace, &p.UnixUser); err != nil {
if !ok { return nil, err
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
} }
if rid, ok := rm["@id"].(string); ok && rid == desiredID { out = append(out, p)
if !seenDesired { }
out = append(out, desired) if err := rows.Err(); err != nil {
seenDesired = true return nil, err
} }
continue 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) return out[i].RouteHost < out[j].RouteHost
} })
if !seenDesired {
out = append(out, desired)
}
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

@ -6,25 +6,32 @@ import (
) )
type Config struct { type Config struct {
DatabaseURL string DatabaseURL string
ListenAddr string ListenAddr string
UnixSocketPath string UnixSocketPath string
DevMode bool DevMode bool
DemoUser string DemoUser string
DemoEmail string DemoEmail string
AuthHeaderUser string AuthHeaderUser string
AuthHeaderEmail string AuthHeaderEmail string
ForgejoBaseURL string ForgejoBaseURL string
ForgejoToken string ForgejoToken string
ForgejoGitUsername string ForgejoGitUsername string
ForgejoOrg string ForgejoOrg string
DBProvisionAdminURL string DBProvisionAdminURL string
DBProvisionEnabled bool DBProvisionEnabled bool
CaddyAdminURL string CaddyAdminURL string
CaddyServerID string CaddyServerID string
CaddyDomainSuffix string CaddyDomainSuffix string
CaddyRouteEnabled bool CaddyRouteEnabled bool
WebhookBaseURL string CaddyAuthEnabled bool
CaddyAuthUpstream string
CaddyAuthURI string
CaddyPersistConfigPath string
CaddyFragmentPath string
CaddyReloadCommand string
CaddyAuthImportName string
WebhookBaseURL string
} }
type App struct { type App struct {

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