mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-09 01:46:46 +08:00
* fix(vcs): report untrusted provider TLS certificates on connect (MUL-7639) ConnectVCS reported every non-token failure as "could not reach the provider instance" and logged nothing, so a Gitea behind a private CA looked like a network problem. Log the underlying validation error and tell certificate failures (untrusted CA, host name mismatch, other verification failures such as expiry) apart from unreachable instances. Status codes are unchanged. Co-authored-by: multica-agent <[email protected]> * feat(helm): trust extra CA certificates in the backend (MUL-7639) backend.extraCACerts.configMap mounts an existing ConfigMap of PEM certificates read-only and points SSL_CERT_DIR at the system directory plus the mount, so the backend trusts an internal CA without dropping public CAs or disabling TLS verification. Unset renders unchanged. Co-authored-by: multica-agent <[email protected]> * docs(self-host): document trusting a private CA (MUL-7639) Co-authored-by: multica-agent <[email protected]> * fix(helm): render when values predate extraCACerts (MUL-7639) helm upgrade --reuse-values from an older chart carries no backend.extraCACerts key, and reading .configMap on it failed the whole render with a nil pointer even when no CA was wanted. Fall back to an empty dict, and cover the missing-key, default and enabled renders in the chart test. Co-authored-by: multica-agent <[email protected]> * docs(self-host): restart Compose backend after replacing a CA (MUL-7639) up -d keeps the running container when only a mounted CA file changed, so the backend kept its old trust store. Use restart instead. Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: multica-agent <[email protected]>
367 lines
12 KiB
Go
367 lines
12 KiB
Go
package handler
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
"github.com/multica-ai/multica/server/internal/integrations/vcs"
|
|
"github.com/multica-ai/multica/server/internal/middleware"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
"github.com/multica-ai/multica/server/pkg/protocol"
|
|
)
|
|
|
|
// ── Response shapes ─────────────────────────────────────────────────────────
|
|
|
|
// VCSConnectionResponse is the JSON shape for a stored Git provider connection.
|
|
// Secrets are never included; the webhook secret is returned exactly once at
|
|
// create time via VCSConnectResponse.
|
|
type VCSConnectionResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Provider string `json:"provider"`
|
|
InstanceURL string `json:"instance_url"`
|
|
AccountLogin string `json:"account_login"`
|
|
WebhookURL string `json:"webhook_url"`
|
|
WebhookPath string `json:"webhook_path"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// VCSConnectResponse embeds the stored connection plus the one-time plaintext
|
|
// webhook secret the user must paste into the provider (the HMAC secret for
|
|
// Forgejo/Gitea, the X-Gitlab-Token value for GitLab). Not retrievable after.
|
|
type VCSConnectResponse struct {
|
|
VCSConnectionResponse
|
|
WebhookSecret string `json:"webhook_secret"`
|
|
}
|
|
|
|
const vcsWebhookPathPrefix = "/api/webhooks/vcs/"
|
|
|
|
// isVCSAvailable reports whether this deployment offers the self-hosted Git
|
|
// provider integration at all. It is the product boundary (self-host only) and
|
|
// is independent of isVCSConfigured (whether the encryption key is set): the
|
|
// managed cloud leaves it off, so connect/rotate/webhook reject and the UI
|
|
// hides the section rather than surfacing an operator-only "missing key" hint.
|
|
func (h *Handler) isVCSAvailable() bool { return h.cfg.VCSIntegrationEnabled }
|
|
|
|
func (h *Handler) isVCSConfigured() bool { return h.VCSSecretBox != nil }
|
|
|
|
func (h *Handler) vcsWebhookPath(connID string) string { return vcsWebhookPathPrefix + connID }
|
|
|
|
func (h *Handler) vcsWebhookURL(connID string) string {
|
|
base := strings.TrimRight(h.cfg.PublicURL, "/")
|
|
if base == "" {
|
|
return ""
|
|
}
|
|
return base + h.vcsWebhookPath(connID)
|
|
}
|
|
|
|
func (h *Handler) vcsConnectionToResponse(c db.VcsConnection) VCSConnectionResponse {
|
|
id := uuidToString(c.ID)
|
|
return VCSConnectionResponse{
|
|
ID: id,
|
|
WorkspaceID: uuidToString(c.WorkspaceID),
|
|
Provider: c.Provider,
|
|
InstanceURL: c.InstanceUrl,
|
|
AccountLogin: c.AccountLogin,
|
|
WebhookURL: h.vcsWebhookURL(id),
|
|
WebhookPath: h.vcsWebhookPath(id),
|
|
CreatedAt: timestampToString(c.CreatedAt),
|
|
}
|
|
}
|
|
|
|
func (h *Handler) sealVCSSecret(plaintext string) (string, error) {
|
|
sealed, err := h.VCSSecretBox.Seal([]byte(plaintext))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return base64.StdEncoding.EncodeToString(sealed), nil
|
|
}
|
|
|
|
func (h *Handler) openVCSSecret(enc string) (string, error) {
|
|
if enc == "" {
|
|
return "", nil
|
|
}
|
|
ciphertext, err := base64.StdEncoding.DecodeString(enc)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
plaintext, err := h.VCSSecretBox.Open(ciphertext)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(plaintext), nil
|
|
}
|
|
|
|
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
|
|
// ListVCSConnections (GET /workspaces/{id}/vcs/connections) is member-visible;
|
|
// connect/disconnect are admin-gated by the router. No secrets returned.
|
|
func (h *Handler) ListVCSConnections(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
member, _ := middleware.MemberFromContext(r.Context())
|
|
canManage := roleAllowed(member.Role, "owner", "admin")
|
|
|
|
// Deployments where the integration is off (the managed cloud) report
|
|
// available=false and nothing else, so the UI hides the whole section
|
|
// instead of showing an operator-only "missing key" hint. No connections
|
|
// can exist there anyway (connect is rejected), so skip the query.
|
|
if !h.isVCSAvailable() {
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"connections": []VCSConnectionResponse{},
|
|
"available": false,
|
|
"configured": false,
|
|
"can_manage": false,
|
|
})
|
|
return
|
|
}
|
|
|
|
rows, err := h.Queries.ListVCSConnectionsByWorkspace(r.Context(), wsUUID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list connections")
|
|
return
|
|
}
|
|
out := make([]VCSConnectionResponse, 0, len(rows))
|
|
for _, row := range rows {
|
|
out = append(out, h.vcsConnectionToResponse(row))
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"connections": out,
|
|
"available": true,
|
|
"configured": h.isVCSConfigured(),
|
|
"can_manage": canManage,
|
|
})
|
|
}
|
|
|
|
type connectVCSRequest struct {
|
|
Provider string `json:"provider"`
|
|
InstanceURL string `json:"instance_url"`
|
|
AccessToken string `json:"access_token"`
|
|
}
|
|
|
|
// ConnectVCS (POST /workspaces/{id}/vcs/connections) validates the supplied
|
|
// instance URL + token against the live instance for the chosen provider, mints a
|
|
// webhook secret, stores both secrets encrypted, and returns the connection
|
|
// plus the one-time webhook secret. Reconnecting the same instance rotates it.
|
|
func (h *Handler) ConnectVCS(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.isVCSAvailable() {
|
|
writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment")
|
|
return
|
|
}
|
|
if !h.isVCSConfigured() {
|
|
writeFeatureDisabled(w, "vcs_not_configured", "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
|
|
return
|
|
}
|
|
|
|
var req connectVCSRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
provider, ok := vcs.For(req.Provider)
|
|
if !ok {
|
|
writeError(w, http.StatusBadRequest, "unsupported provider")
|
|
return
|
|
}
|
|
instanceURL := vcs.NormalizeInstanceURL(req.InstanceURL)
|
|
token := strings.TrimSpace(req.AccessToken)
|
|
if instanceURL == "" || token == "" {
|
|
writeError(w, http.StatusBadRequest, "instance_url and access_token are required")
|
|
return
|
|
}
|
|
parsed, err := url.Parse(instanceURL)
|
|
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" {
|
|
writeError(w, http.StatusBadRequest, "instance_url must be an absolute http(s) URL")
|
|
return
|
|
}
|
|
|
|
account, err := provider.ValidateToken(r.Context(), instanceURL, token)
|
|
if err != nil {
|
|
if errors.Is(err, vcs.ErrUnauthorized) {
|
|
writeError(w, http.StatusBadRequest, "the provider rejected the access token")
|
|
return
|
|
}
|
|
slog.Warn("vcs: connect validation failed",
|
|
"workspace_id", workspaceID,
|
|
"provider", provider.Kind(),
|
|
"host", parsed.Host,
|
|
"err", err)
|
|
writeError(w, http.StatusBadGateway, vcsValidationFailureMessage(err))
|
|
return
|
|
}
|
|
|
|
webhookSecret, err := newVCSWebhookSecret()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to mint webhook secret")
|
|
return
|
|
}
|
|
tokenEnc, err := h.sealVCSSecret(token)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt token")
|
|
return
|
|
}
|
|
secretEnc, err := h.sealVCSSecret(webhookSecret)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt webhook secret")
|
|
return
|
|
}
|
|
|
|
var connectedBy pgtype.UUID
|
|
if member, ok := middleware.MemberFromContext(r.Context()); ok {
|
|
connectedBy = member.UserID
|
|
}
|
|
|
|
conn, err := h.Queries.UpsertVCSConnection(r.Context(), db.UpsertVCSConnectionParams{
|
|
WorkspaceID: wsUUID,
|
|
Provider: string(provider.Kind()),
|
|
InstanceUrl: instanceURL,
|
|
AccountLogin: account.Login,
|
|
AccessTokenEncrypted: tokenEnc,
|
|
WebhookSecretEncrypted: secretEnc,
|
|
ConnectedByID: connectedBy,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to save connection")
|
|
return
|
|
}
|
|
|
|
resp := h.vcsConnectionToResponse(conn)
|
|
h.publish(protocol.EventVCSConnectionCreated, workspaceID, "system", "", map[string]any{"id": resp.ID})
|
|
writeJSON(w, http.StatusOK, VCSConnectResponse{
|
|
VCSConnectionResponse: resp,
|
|
WebhookSecret: webhookSecret,
|
|
})
|
|
}
|
|
|
|
// vcsValidationFailureMessage explains a ValidateToken failure other than a
|
|
// rejected token. A certificate failure means the instance answered but this
|
|
// server did not trust its TLS certificate, so it gets its own message instead
|
|
// of "could not reach", which sends the admin chasing the network. An
|
|
// untrusted CA is fixable by trusting the CA; a hostname or expiry failure
|
|
// means the certificate itself needs fixing.
|
|
func vcsValidationFailureMessage(err error) string {
|
|
var unknownCA x509.UnknownAuthorityError
|
|
var hostname x509.HostnameError
|
|
var verification *tls.CertificateVerificationError
|
|
switch {
|
|
case errors.As(err, &unknownCA):
|
|
return "the provider's TLS certificate is signed by a certificate authority this server does not trust; add that CA to the Multica server's trust store"
|
|
case errors.As(err, &hostname):
|
|
return "the provider's TLS certificate does not match the instance URL's host name"
|
|
case errors.As(err, &verification):
|
|
return "the provider's TLS certificate failed verification (for example, it has expired)"
|
|
default:
|
|
return "could not reach the provider instance"
|
|
}
|
|
}
|
|
|
|
// DeleteVCSConnection (DELETE /workspaces/{id}/vcs/connections/{connectionId}).
|
|
func (h *Handler) DeleteVCSConnection(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
idUUID, ok := parseUUIDOrBadRequest(w, chi.URLParam(r, "connectionId"), "connection id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if err := h.Queries.DeleteVCSConnection(r.Context(), db.DeleteVCSConnectionParams{
|
|
ID: idUUID,
|
|
WorkspaceID: wsUUID,
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to remove connection")
|
|
return
|
|
}
|
|
h.publish(protocol.EventVCSConnectionDeleted, workspaceID, "system", "", map[string]any{
|
|
"id": chi.URLParam(r, "connectionId"),
|
|
})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// RotateVCSConnectionWebhook (POST /workspaces/{id}/vcs/connections/{connectionId}/rotate-webhook)
|
|
// generates a fresh webhook secret for an existing VCS connection, stores it encrypted,
|
|
// and returns the connection plus the one-time plaintext secret.
|
|
func (h *Handler) RotateVCSConnectionWebhook(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := chi.URLParam(r, "id")
|
|
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace id")
|
|
if !ok {
|
|
return
|
|
}
|
|
connID := chi.URLParam(r, "connectionId")
|
|
connUUID, ok := parseUUIDOrBadRequest(w, connID, "connection id")
|
|
if !ok {
|
|
return
|
|
}
|
|
if !h.isVCSAvailable() {
|
|
writeError(w, http.StatusNotFound, "vcs integration is not available on this deployment")
|
|
return
|
|
}
|
|
if !h.isVCSConfigured() {
|
|
writeFeatureDisabled(w, "vcs_not_configured", "vcs integration not configured (MULTICA_VCS_SECRET_KEY unset)")
|
|
return
|
|
}
|
|
|
|
conn, err := h.Queries.GetVCSConnectionByID(r.Context(), connUUID)
|
|
if err != nil || uuidToString(conn.WorkspaceID) != uuidToString(wsUUID) {
|
|
writeError(w, http.StatusNotFound, "vcs connection not found")
|
|
return
|
|
}
|
|
|
|
webhookSecret, err := newVCSWebhookSecret()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to mint webhook secret")
|
|
return
|
|
}
|
|
secretEnc, err := h.sealVCSSecret(webhookSecret)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to encrypt webhook secret")
|
|
return
|
|
}
|
|
|
|
updated, err := h.Queries.RotateVCSConnectionWebhookSecret(r.Context(), db.RotateVCSConnectionWebhookSecretParams{
|
|
ID: connUUID,
|
|
WorkspaceID: wsUUID,
|
|
WebhookSecretEncrypted: secretEnc,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to rotate webhook secret")
|
|
return
|
|
}
|
|
|
|
resp := h.vcsConnectionToResponse(updated)
|
|
h.publish(protocol.EventVCSConnectionCreated, workspaceID, "system", "", map[string]any{"id": resp.ID})
|
|
writeJSON(w, http.StatusOK, VCSConnectResponse{
|
|
VCSConnectionResponse: resp,
|
|
WebhookSecret: webhookSecret,
|
|
})
|
|
}
|
|
|
|
// newVCSWebhookSecret returns a 32-byte random secret as hex (64 chars).
|
|
func newVCSWebhookSecret() (string, error) {
|
|
b := make([]byte, 32)
|
|
if _, err := rand.Read(b); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(b), nil
|
|
}
|