use caddyfile instead of api
This commit is contained in:
parent
53a5e2e45a
commit
3c79a8c778
9 changed files with 206 additions and 248 deletions
|
|
@ -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=
|
||||||
|
|
|
||||||
29
README.md
29
README.md
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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}`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue