box/internal/app/integrations.go
2026-05-14 22:59:25 +02:00

358 lines
10 KiB
Go

package app
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"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
}
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": fmt.Sprintf("127.0.0.1:%d", p.TargetPort)}}}},
}
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) 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
}