Files
DDI/server/internal/httpapi/api.go
T
beug.k@beugsiud.deandClaude Opus 5.5 00b9ffb65b feat(server): operating modes, push deployments, rollback, diff, pending changes and SSE
- PATCH /agents/{id} switches the mode and reconnects the agent
- POST /revisions/{n}/deploy creates pending push deployments, sends
  ApplyRevisionCommand with the deployment ID, re-sends open pushes on
  connect and announces them in GetDesiredState; reports with the ID
  complete the deployment
- POST /revisions/{n}/rollback re-renders the stored generator input
  with the next revision number
- GET /revisions/{n}/diff and GET /config/pending with unified diffs
- GET /events streams agent, deployment, revision and pending events

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:23:47 +02:00

222 lines
6.1 KiB
Go

// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
// Package httpapi is the REST API of the management server
// (server/api/openapi.yaml, ADR 0012/0019). All routes are declared in one
// table with their permission and whether they write; the middleware and the
// contract tests work from that table.
package httpapi
import (
"errors"
"log/slog"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/server/internal/auth"
"git.beugsiud.de/beug.k/DDI/server/internal/httpserver"
"git.beugsiud.de/beug.k/DDI/server/internal/service"
)
// SessionCookie is the name of the session cookie.
const SessionCookie = "ddi_session"
// CSRFHeader carries the per-session CSRF token.
const CSRFHeader = "X-CSRF-Token"
// Options configures the API.
type Options struct {
Service *service.Service
// PublicURL is the external base URL (OIDC redirect, origin check).
PublicURL string
// SecureCookies sets the Secure flag (TLS or https public URL).
SecureCookies bool
ProductName string
Version string
Logger *slog.Logger
Now func() time.Time
// OIDCHTTPClient is used for discovery and token requests (tests).
OIDCHTTPClient *http.Client
// EventKeepalive is the keepalive and session check interval of the
// event stream (default DefaultEventKeepalive; shorter in tests).
EventKeepalive time.Duration
}
// API serves /api/v1.
type API struct {
opts Options
svc *service.Service
log *slog.Logger
oidc *oidcFlow
}
// New creates the API.
func New(opts Options) *API {
if opts.Logger == nil {
opts.Logger = slog.New(slog.DiscardHandler)
}
if opts.Now == nil {
opts.Now = time.Now
}
a := &API{opts: opts, svc: opts.Service, log: opts.Logger}
a.oidc = newOIDCFlow(a)
return a
}
// access describes who may call a route.
type access int
const (
// public routes need no session.
public access = iota
// session routes need any complete session.
session
// permission routes need a session and Route.Perm.
permission
)
// Route is one API operation.
type Route struct {
Method string
Path string
Access access
Perm auth.Permission
// Write marks routes blocked in license read-only mode.
Write bool
// MFAPending allows the route before the second factor is verified.
MFAPending bool
// PasswordChange allows the route while a password change is pending.
PasswordChange bool
Handler handlerFunc
}
// reqCtx carries the authenticated caller.
type reqCtx struct {
Session *service.Session
Actor *service.Actor
}
type handlerFunc func(w http.ResponseWriter, r *http.Request, rc *reqCtx) error
// Handler returns the routed API handler.
func (a *API) Handler() http.Handler {
mux := http.NewServeMux()
for _, rt := range a.Routes() {
mux.Handle(rt.Method+" "+rt.Path, a.wrap(rt))
}
// Unknown API paths: JSON 404 instead of the SPA fallback.
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) {
a.writeError(w, r, service.ErrNotFound)
})
return mux
}
func clientIP(r *http.Request) *netip.Addr {
ap, err := netip.ParseAddrPort(r.RemoteAddr)
if err != nil {
return nil
}
ip := ap.Addr().Unmap()
return &ip
}
func (a *API) wrap(rt Route) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
rc := &reqCtx{}
if err := a.authorize(w, r, rt, rc); err != nil {
a.writeError(w, r, err)
return
}
if err := rt.Handler(w, r, rc); err != nil {
a.writeError(w, r, err)
}
})
}
// authorize runs the per-route checks in order: origin, session, MFA,
// password change, permission, CSRF, license write lock.
func (a *API) authorize(w http.ResponseWriter, r *http.Request, rt Route, rc *reqCtx) error {
unsafe := r.Method != http.MethodGet && r.Method != http.MethodHead && r.Method != http.MethodOptions
if unsafe && !a.sameOrigin(r) {
return errCrossOrigin
}
if rt.Access == public {
return nil
}
c, err := r.Cookie(SessionCookie)
if err != nil {
return service.ErrUnauthenticated
}
sess, err := a.svc.Authenticate(r.Context(), c.Value)
if err != nil {
if errors.Is(err, service.ErrUnauthenticated) {
a.clearCookie(w)
}
return err
}
rc.Session = sess
rc.Actor = &service.Actor{
UserID: sess.User.ID, Username: sess.User.Username, Role: auth.Role(sess.User.Role),
IP: clientIP(r), RequestID: httpserver.RequestIDFromContext(r.Context()), SessionHash: sess.TokenHash,
}
if sess.MFAPending && !rt.MFAPending {
return service.ErrMFARequired
}
if sess.User.MustChangePassword && !rt.PasswordChange && !rt.MFAPending {
return service.ErrPasswordChange
}
if rt.Access == permission && !auth.Allowed(rc.Actor.Role, rt.Perm) {
return service.ErrForbidden
}
if unsafe && r.Header.Get(CSRFHeader) != sess.CSRFToken {
return errCSRF
}
if rt.Write && !a.svc.License().Writable() {
return service.ErrReadOnly
}
return nil
}
// sameOrigin rejects cross-site state-changing requests (defense in depth
// next to SameSite cookies and the CSRF token).
func (a *API) sameOrigin(r *http.Request) bool {
if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" {
return false
}
origin := r.Header.Get("Origin")
if origin == "" {
return true // non-browser clients
}
u, err := url.Parse(origin)
if err != nil {
return false
}
if strings.EqualFold(u.Host, r.Host) {
return true
}
if a.opts.PublicURL != "" {
if pu, err := url.Parse(a.opts.PublicURL); err == nil && strings.EqualFold(pu.Host, u.Host) && pu.Scheme == u.Scheme {
return true
}
}
return false
}
func (a *API) setSessionCookie(w http.ResponseWriter, s *service.Session) {
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure follows the TLS setup (off only for plain-HTTP development)
Name: SessionCookie, Value: s.Token, Path: "/", Expires: s.ExpiresAt,
HttpOnly: true, Secure: a.opts.SecureCookies, SameSite: http.SameSiteLaxMode,
})
}
func (a *API) clearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{ //nolint:gosec // Secure follows the TLS setup (off only for plain-HTTP development)
Name: SessionCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.opts.SecureCookies, SameSite: http.SameSiteLaxMode,
})
}