Files
DDI/server/internal/httpapi/h_ipam.go
T
sir_melonchenandClaude Opus 5.5 2b04661792 feat: clusters, TSIG keys, zones and records in service and REST API
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>
2026-10-14 14:47:37 +02:00

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)
}