Implements the M5 endpoints of the contract (ADR 0023): clusters with member validation, transfer keys and HA credentials, TSIG keys with audited secret access and rotation, explicit zones and typed records, the DHCP cluster of networks, the cluster of agents and the default clusters and HA password file of the generator settings. Revisions are stored per target with sealed secret files and sealed source secrets (restored for rollbacks); bundles carry an agent's own target or the default group. Active leases are adopted as dynamic addresses. The derivation is called through a temporary adapter until derive implements the M5 contract. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
416 lines
12 KiB
Go
416 lines
12 KiB
Go
// SPDX-License-Identifier: LicenseRef-DDI-Proprietary
|
|
|
|
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/ipam"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/service"
|
|
"git.beugsiud.de/beug.k/DDI/server/internal/store"
|
|
)
|
|
|
|
// fieldParser collects parse problems for string-typed request fields, so
|
|
// that malformed IPs are reported per field instead of as a JSON error.
|
|
type fieldParser struct{ problems []service.Problem }
|
|
|
|
func (p *fieldParser) fail(code, field string) {
|
|
p.problems = append(p.problems, service.Problem{Code: code, Field: field})
|
|
}
|
|
|
|
func (p *fieldParser) addr(field, s string) netip.Addr {
|
|
a, err := netip.ParseAddr(strings.TrimSpace(s))
|
|
if err != nil {
|
|
p.fail("invalid_ip", field)
|
|
return netip.Addr{}
|
|
}
|
|
return a.Unmap()
|
|
}
|
|
|
|
func (p *fieldParser) optAddr(field string, s *string) *netip.Addr {
|
|
if s == nil || strings.TrimSpace(*s) == "" {
|
|
return nil
|
|
}
|
|
a := p.addr(field, *s)
|
|
return &a
|
|
}
|
|
|
|
func (p *fieldParser) addrs(field string, ss []string) []netip.Addr {
|
|
out := make([]netip.Addr, 0, len(ss))
|
|
for _, s := range ss {
|
|
out = append(out, p.addr(field, s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *fieldParser) prefix(field, s string) netip.Prefix {
|
|
pf, err := netip.ParsePrefix(strings.TrimSpace(s))
|
|
if err != nil {
|
|
p.fail("invalid_cidr", field)
|
|
}
|
|
return pf
|
|
}
|
|
|
|
func (p *fieldParser) err() error {
|
|
if len(p.problems) == 0 {
|
|
return nil
|
|
}
|
|
return &service.Error{Kind: service.KindInvalid, Code: "validation_failed", Message: "validation failed", Problems: p.problems}
|
|
}
|
|
|
|
type poolRequest struct {
|
|
Start string `json:"start"`
|
|
End string `json:"end"`
|
|
}
|
|
|
|
type networkRequest struct {
|
|
CIDR string `json:"cidr"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
VLAN *int `json:"vlan"`
|
|
Gateway *string `json:"gateway"`
|
|
Domain string `json:"domain"`
|
|
SearchDomains []string `json:"search_domains"`
|
|
Tags []string `json:"tags"`
|
|
DHCPEnabled bool `json:"dhcp_enabled"`
|
|
LeaseTime *int `json:"lease_time"`
|
|
DNSServers []string `json:"dns_servers"`
|
|
NTPServers []string `json:"ntp_servers"`
|
|
Pools []poolRequest `json:"pools"`
|
|
DHCPClusterID *string `json:"dhcp_cluster_id"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
func (req *networkRequest) input(withCIDR bool) (service.NetworkInput, error) {
|
|
var p fieldParser
|
|
in := service.NetworkInput{
|
|
Name: req.Name, Description: req.Description, VLAN: req.VLAN, Gateway: p.optAddr("gateway", req.Gateway),
|
|
Domain: req.Domain, SearchDomains: req.SearchDomains, Tags: req.Tags, DHCPEnabled: req.DHCPEnabled,
|
|
LeaseTime: req.LeaseTime, DNSServers: p.addrs("dns_servers", req.DNSServers),
|
|
NTPServers: p.addrs("ntp_servers", req.NTPServers), DHCPClusterID: req.DHCPClusterID, Version: req.Version,
|
|
}
|
|
if withCIDR {
|
|
in.CIDR = p.prefix("cidr", req.CIDR)
|
|
}
|
|
for _, pr := range req.Pools {
|
|
in.Pools = append(in.Pools, service.PoolDTO{Start: p.addr("pools", pr.Start), End: p.addr("pools", pr.End)})
|
|
}
|
|
return in, p.err()
|
|
}
|
|
|
|
func (a *API) listNetworks(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
nets, err := a.svc.ListNetworks(r.Context())
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, listResponse[service.NetworkDTO]{Items: nets})
|
|
}
|
|
|
|
func (a *API) createNetwork(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
var req networkRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
in, err := req.input(true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := a.svc.CreateNetwork(r.Context(), rc.Actor, in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusCreated, n)
|
|
}
|
|
|
|
func (a *API) getNetwork(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
n, err := a.svc.GetNetwork(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, n)
|
|
}
|
|
|
|
func (a *API) updateNetwork(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
var req networkRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
in, err := req.input(false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
n, err := a.svc.UpdateNetwork(r.Context(), rc.Actor, r.PathValue("id"), in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, n)
|
|
}
|
|
|
|
func (a *API) deleteNetwork(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
force := r.URL.Query().Get("force") == "true"
|
|
if err := a.svc.DeleteNetwork(r.Context(), rc.Actor, r.PathValue("id"), force); err != nil {
|
|
return err
|
|
}
|
|
return noContent(w)
|
|
}
|
|
|
|
func (a *API) networkMap(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
cells, err := a.svc.AddressMap(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, map[string][]ipam.MapCell{"cells": cells})
|
|
}
|
|
|
|
// addressFilter parses the table query parameters.
|
|
func addressFilter(r *http.Request) (store.AddressFilter, error) {
|
|
q := r.URL.Query()
|
|
f := store.AddressFilter{NetworkID: q.Get("network_id"), Tag: q.Get("tag"), LeaseState: q.Get("lease_state"),
|
|
Query: q.Get("q"), Sort: q.Get("sort"), Desc: q.Get("order") == "desc"}
|
|
for _, t := range q["type"] {
|
|
for _, part := range strings.Split(t, ",") {
|
|
typ := ipam.AddressType(strings.TrimSpace(part))
|
|
if !typ.Valid() {
|
|
return f, errBadRequest("invalid_parameter", "unknown address type "+strconv.Quote(part))
|
|
}
|
|
f.Types = append(f.Types, typ)
|
|
}
|
|
}
|
|
if f.Sort != "" {
|
|
if _, ok := store.AddressSortFields[f.Sort]; !ok {
|
|
return f, errBadRequest("invalid_parameter", "unknown sort field "+strconv.Quote(f.Sort))
|
|
}
|
|
}
|
|
if o := q.Get("order"); o != "" && o != "asc" && o != "desc" {
|
|
return f, errBadRequest("invalid_parameter", "order must be asc or desc")
|
|
}
|
|
for name, dst := range map[string]*int{"limit": &f.Limit, "offset": &f.Offset} {
|
|
if v := q.Get(name); v != "" {
|
|
n, err := strconv.Atoi(v)
|
|
if err != nil || n < 0 || (name == "limit" && (n < 1 || n > store.MaxAddressPage)) {
|
|
return f, errBadRequest("invalid_parameter", "invalid "+name)
|
|
}
|
|
*dst = n
|
|
}
|
|
}
|
|
if len(f.Query) > 200 {
|
|
return f, errBadRequest("invalid_parameter", "q is too long")
|
|
}
|
|
return f, nil
|
|
}
|
|
|
|
func (a *API) listAddresses(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
f, err := addressFilter(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
page, err := a.svc.ListAddresses(r.Context(), f)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, page)
|
|
}
|
|
|
|
type addressRequest struct {
|
|
NetworkID string `json:"network_id"`
|
|
IP string `json:"ip"`
|
|
Type string `json:"type"`
|
|
MAC string `json:"mac"`
|
|
Hostname string `json:"hostname"`
|
|
Description string `json:"description"`
|
|
Owner string `json:"owner"`
|
|
Tags []string `json:"tags"`
|
|
Version int `json:"version"`
|
|
}
|
|
|
|
func (req *addressRequest) input() (service.AddressInput, error) {
|
|
var p fieldParser
|
|
in := service.AddressInput{
|
|
NetworkID: req.NetworkID, IP: p.addr("ip", req.IP), Type: ipam.AddressType(req.Type), MAC: req.MAC,
|
|
Hostname: req.Hostname, Description: req.Description, Owner: req.Owner, Tags: req.Tags, Version: req.Version,
|
|
}
|
|
return in, p.err()
|
|
}
|
|
|
|
func (a *API) createAddress(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
var req addressRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
in, err := req.input()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := a.svc.CreateAddress(r.Context(), rc.Actor, in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusCreated, out)
|
|
}
|
|
|
|
func (a *API) getAddress(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
out, err := a.svc.GetAddress(r.Context(), r.PathValue("id"))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (a *API) updateAddress(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
var req addressRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
in, err := req.input()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
out, err := a.svc.UpdateAddress(r.Context(), rc.Actor, r.PathValue("id"), in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func (a *API) deleteAddress(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
if err := a.svc.DeleteAddress(r.Context(), rc.Actor, r.PathValue("id")); err != nil {
|
|
return err
|
|
}
|
|
return noContent(w)
|
|
}
|
|
|
|
type previewRequest struct {
|
|
ID string `json:"id"`
|
|
Address *addressRequest `json:"address"`
|
|
}
|
|
|
|
func (a *API) previewAddress(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
var req previewRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
var in *service.AddressInput
|
|
if req.Address != nil {
|
|
v, err := req.Address.input()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
in = &v
|
|
}
|
|
effects, err := a.svc.Preview(r.Context(), req.ID, in)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, map[string][]ipam.Effect{"effects": effects})
|
|
}
|
|
|
|
type bulkRequest struct {
|
|
IDs []string `json:"ids"`
|
|
Delete bool `json:"delete"`
|
|
SetType *string `json:"set_type"`
|
|
SetOwner *string `json:"set_owner"`
|
|
AddTags []string `json:"add_tags"`
|
|
RemoveTags []string `json:"remove_tags"`
|
|
}
|
|
|
|
type bulkResponse struct {
|
|
Changed int `json:"changed"`
|
|
Problems []service.BulkItemProblem `json:"problems"`
|
|
}
|
|
|
|
func (a *API) bulkAddresses(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
var req bulkRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
act := service.BulkAction{IDs: req.IDs, Delete: req.Delete, SetOwner: req.SetOwner, AddTags: req.AddTags, RemoveTags: req.RemoveTags}
|
|
if req.SetType != nil {
|
|
t := ipam.AddressType(*req.SetType)
|
|
act.SetType = &t
|
|
}
|
|
n, problems, err := a.svc.Bulk(r.Context(), rc.Actor, act)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if problems == nil {
|
|
problems = []service.BulkItemProblem{}
|
|
}
|
|
status := http.StatusOK
|
|
if len(problems) > 0 {
|
|
status = http.StatusUnprocessableEntity
|
|
}
|
|
return writeJSON(w, status, bulkResponse{Changed: n, Problems: problems})
|
|
}
|
|
|
|
func (a *API) exportAddresses(w http.ResponseWriter, r *http.Request, _ *reqCtx) error {
|
|
f, err := addressFilter(r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="addresses-`+time.Now().UTC().Format("20060102-150405")+`.csv"`)
|
|
if err := a.svc.ExportAddresses(r.Context(), f, w); err != nil {
|
|
a.log.Error("address export failed", "error", err)
|
|
panic(http.ErrAbortHandler)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (a *API) importAddresses(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
ct := r.Header.Get("Content-Type")
|
|
if !strings.HasPrefix(ct, "text/csv") {
|
|
return errBadRequest("unsupported_media_type", "expected text/csv")
|
|
}
|
|
dryRun := r.URL.Query().Get("dry_run") == "true"
|
|
body := http.MaxBytesReader(w, r.Body, service.MaxImportBytes)
|
|
res, err := a.svc.ImportAddresses(r.Context(), rc.Actor, body, dryRun)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if res.Problems == nil {
|
|
res.Problems = []service.ImportProblem{}
|
|
}
|
|
status := http.StatusOK
|
|
if len(res.Problems) > 0 {
|
|
status = http.StatusUnprocessableEntity
|
|
}
|
|
return writeJSON(w, status, res)
|
|
}
|
|
|
|
func (a *API) listSavedFilters(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
items, err := a.svc.SavedFilters(r.Context(), rc.Actor)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusOK, listResponse[store.SavedFilter]{Items: items})
|
|
}
|
|
|
|
type savedFilterRequest struct {
|
|
Name string `json:"name"`
|
|
Query json.RawMessage `json:"query"`
|
|
}
|
|
|
|
func (a *API) createSavedFilter(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
var req savedFilterRequest
|
|
if err := decode(w, r, &req); err != nil {
|
|
return err
|
|
}
|
|
f, err := a.svc.CreateSavedFilter(r.Context(), rc.Actor, req.Name, req.Query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return writeJSON(w, http.StatusCreated, f)
|
|
}
|
|
|
|
func (a *API) deleteSavedFilter(w http.ResponseWriter, r *http.Request, rc *reqCtx) error {
|
|
if err := a.svc.DeleteSavedFilter(r.Context(), rc.Actor, r.PathValue("id")); err != nil {
|
|
return err
|
|
}
|
|
return noContent(w)
|
|
}
|