- 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>
222 lines
6.1 KiB
Go
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,
|
|
})
|
|
}
|