483 lines
14 KiB
Go
483 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
func (a *App) createForgejoRepo(ctx context.Context, name, description string, private bool) (string, error) {
|
|
if a.cfg.ForgejoBaseURL == "" || a.cfg.ForgejoToken == "" {
|
|
return "", nil
|
|
}
|
|
payload := map[string]any{"name": name, "description": description, "private": private, "auto_init": true}
|
|
body, _ := json.Marshal(payload)
|
|
endpoint := a.cfg.ForgejoBaseURL + "/api/v1/user/repos"
|
|
if a.cfg.ForgejoOrg != "" {
|
|
endpoint = fmt.Sprintf("%s/api/v1/orgs/%s/repos", a.cfg.ForgejoBaseURL, a.cfg.ForgejoOrg)
|
|
}
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("forgejo request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return "", fmt.Errorf("forgejo error: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
var parsed struct {
|
|
HTMLURL string `json:"html_url"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
|
return "", nil
|
|
}
|
|
return parsed.HTMLURL, nil
|
|
}
|
|
|
|
func (a *App) buildRouteHost(slug, username string) string {
|
|
if a.cfg.CaddyDomainSuffix == "" {
|
|
return fmt.Sprintf("%s-%s.local", slugify(username), slug)
|
|
}
|
|
return fmt.Sprintf("%s.%s", slug, strings.TrimPrefix(a.cfg.CaddyDomainSuffix, "."))
|
|
}
|
|
|
|
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}}}},
|
|
}
|
|
|
|
cfg, err := a.fetchCaddyConfig(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
indexes := findCaddyRouteIndexesByID(cfg, a.cfg.CaddyServerID, route["@id"].(string))
|
|
if len(indexes) > 0 {
|
|
// Update first matching route in place.
|
|
if err := a.putCaddyRouteAtIndex(ctx, indexes[0], route); err != nil {
|
|
return err
|
|
}
|
|
// Remove stale duplicates from highest index to lowest.
|
|
for i := len(indexes) - 1; i >= 1; i-- {
|
|
if err := a.deleteCaddyRouteAtIndex(ctx, indexes[i]); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
if err := a.appendCaddyRoute(ctx, route); err != nil {
|
|
// If duplicate race happened, retry once through dedupe/update path.
|
|
if strings.Contains(err.Error(), "duplicate ID") {
|
|
cfg2, ferr := a.fetchCaddyConfig(ctx)
|
|
if ferr != nil {
|
|
return err
|
|
}
|
|
indexes2 := findCaddyRouteIndexesByID(cfg2, a.cfg.CaddyServerID, route["@id"].(string))
|
|
if len(indexes2) == 0 {
|
|
return err
|
|
}
|
|
if uerr := a.putCaddyRouteAtIndex(ctx, indexes2[0], route); uerr != nil {
|
|
return uerr
|
|
}
|
|
for i := len(indexes2) - 1; i >= 1; i-- {
|
|
if derr := a.deleteCaddyRouteAtIndex(ctx, indexes2[i]); derr != nil {
|
|
return derr
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) appendCaddyRoute(ctx context.Context, route map[string]any) error {
|
|
body, _ := json.Marshal(route)
|
|
url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, 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 {
|
|
return fmt.Errorf("caddy request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("caddy error: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) putCaddyRouteAtIndex(ctx context.Context, idx int, route map[string]any) error {
|
|
body, _ := json.Marshal(route)
|
|
url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes/%d", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID, idx)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, 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 {
|
|
return fmt.Errorf("caddy update request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("caddy update error: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *App) deleteCaddyRouteAtIndex(ctx context.Context, idx int) error {
|
|
url := fmt.Sprintf("%s/config/apps/http/servers/%s/routes/%d", a.cfg.CaddyAdminURL, a.cfg.CaddyServerID, idx)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("caddy delete request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode >= 300 {
|
|
respBody, _ := io.ReadAll(resp.Body)
|
|
return fmt.Errorf("caddy delete error: status=%d body=%s", resp.StatusCode, string(respBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func findCaddyRouteIndexesByID(cfg map[string]any, serverID, routeID string) []int {
|
|
var out []int
|
|
apps, ok := cfg["apps"].(map[string]any)
|
|
if !ok {
|
|
return out
|
|
}
|
|
httpApp, ok := apps["http"].(map[string]any)
|
|
if !ok {
|
|
return out
|
|
}
|
|
servers, ok := httpApp["servers"].(map[string]any)
|
|
if !ok {
|
|
return out
|
|
}
|
|
server, ok := servers[serverID].(map[string]any)
|
|
if !ok {
|
|
return out
|
|
}
|
|
routes, ok := server["routes"].([]any)
|
|
if !ok {
|
|
return out
|
|
}
|
|
for i, r := range routes {
|
|
rm, ok := r.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if rid, ok := rm["@id"].(string); ok && rid == routeID {
|
|
out = append(out, i)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
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 {
|
|
return nil, err
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("caddy config request failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return nil, fmt.Errorf("caddy config error: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
var out map[string]any
|
|
if err := json.Unmarshal(body, &out); err != nil {
|
|
return nil, fmt.Errorf("caddy config parse failed: %w", err)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (a *App) ensureCaddyTLSSubject(ctx context.Context, host string) error {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
return nil
|
|
}
|
|
cfg, err := a.fetchCaddyConfig(ctx)
|
|
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 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 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
|
|
}
|
|
if a.cfg.WebhookBaseURL == "" {
|
|
return errors.New("WEBHOOK_BASE_URL is required for automatic Forgejo webhook provisioning")
|
|
}
|
|
|
|
owner, repo, err := parseForgejoRepoOwnerRepo(a.cfg.ForgejoBaseURL, p.RepoURL)
|
|
if err != nil {
|
|
// Existing repo is not on configured Forgejo host; skip auto-provision.
|
|
return nil
|
|
}
|
|
|
|
hooksURL := fmt.Sprintf("%s/api/v1/repos/%s/%s/hooks", a.cfg.ForgejoBaseURL, owner, repo)
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, hooksURL, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Authorization", "token "+a.cfg.ForgejoToken)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return fmt.Errorf("forgejo hooks list failed: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode >= 300 {
|
|
return fmt.Errorf("forgejo hooks list error: status=%d body=%s", resp.StatusCode, string(body))
|
|
}
|
|
var hooks []struct {
|
|
Config struct {
|
|
URL string `json:"url"`
|
|
} `json:"config"`
|
|
}
|
|
_ = json.Unmarshal(body, &hooks)
|
|
for _, h := range hooks {
|
|
if strings.TrimSpace(h.Config.URL) == strings.TrimSpace(p.WebhookURL) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
payload := map[string]any{
|
|
"type": "gitea",
|
|
"active": true,
|
|
"events": []string{"push"},
|
|
"config": map[string]any{
|
|
"url": p.WebhookURL,
|
|
"content_type": "json",
|
|
},
|
|
}
|
|
bodyBytes, _ := json.Marshal(payload)
|
|
createReq, err := http.NewRequestWithContext(ctx, http.MethodPost, hooksURL, strings.NewReader(string(bodyBytes)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
createReq.Header.Set("Authorization", "token "+a.cfg.ForgejoToken)
|
|
createReq.Header.Set("Content-Type", "application/json")
|
|
createResp, err := http.DefaultClient.Do(createReq)
|
|
if err != nil {
|
|
return fmt.Errorf("forgejo hook create failed: %w", err)
|
|
}
|
|
defer createResp.Body.Close()
|
|
createBody, _ := io.ReadAll(createResp.Body)
|
|
if createResp.StatusCode >= 300 {
|
|
return fmt.Errorf("forgejo hook create error: status=%d body=%s", createResp.StatusCode, string(createBody))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func parseForgejoRepoOwnerRepo(forgejoBaseURL, repoURL string) (string, string, error) {
|
|
fBase, err := url.Parse(forgejoBaseURL)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
normalized := strings.TrimSpace(repoURL)
|
|
if strings.HasPrefix(normalized, "git@") {
|
|
// git@host:owner/repo(.git)
|
|
at := strings.Index(normalized, "@")
|
|
colon := strings.Index(normalized, ":")
|
|
if at < 0 || colon < 0 || colon <= at+1 {
|
|
return "", "", errors.New("invalid ssh repo url")
|
|
}
|
|
host := normalized[at+1 : colon]
|
|
if !strings.EqualFold(host, fBase.Hostname()) {
|
|
return "", "", errors.New("repo host does not match configured forgejo host")
|
|
}
|
|
path := strings.TrimPrefix(normalized[colon+1:], "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 {
|
|
return "", "", errors.New("repo path does not include owner/repo")
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|
|
|
|
u, err := url.Parse(normalized)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if !strings.EqualFold(u.Hostname(), fBase.Hostname()) {
|
|
return "", "", errors.New("repo host does not match configured forgejo host")
|
|
}
|
|
path := strings.TrimPrefix(u.Path, "/")
|
|
path = strings.TrimSuffix(path, ".git")
|
|
parts := strings.Split(path, "/")
|
|
if len(parts) < 2 {
|
|
return "", "", errors.New("repo path does not include owner/repo")
|
|
}
|
|
return parts[0], parts[1], nil
|
|
}
|