Subnets served per interface, pools, reservations, options at three levels, leases in SQLite saved before the ACK, ping and decline conflict checks, validated changes with history and undo, users and tokens, an audit log, a Kea importer, a sandboxed systemd unit and Debian packages. Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
150 lines
4.8 KiB
Go
150 lines
4.8 KiB
Go
package server
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"steward/internal/api"
|
|
"steward/internal/auth"
|
|
"steward/internal/engine"
|
|
"steward/internal/kea"
|
|
"steward/internal/model"
|
|
)
|
|
|
|
// KeaImport is the body of POST /import/kea.
|
|
type KeaImport struct {
|
|
// Config is the kea-dhcp4.conf text; Leases the lease files' texts
|
|
// (kea-leases4.csv, and kea-leases4.csv.2 if there is one, older
|
|
// first).
|
|
Config string `json:"config"`
|
|
Leases []string `json:"leases,omitempty"`
|
|
// Interfaces gives subnets' interfaces by prefix, for subnets Kea
|
|
// doesn't tie to one.
|
|
Interfaces map[string]string `json:"interfaces,omitempty"`
|
|
// Replace replaces Steward's configuration when it already has
|
|
// subnets or reservations.
|
|
Replace bool `json:"replace,omitempty"`
|
|
}
|
|
|
|
// importKea: POST /import/kea makes Steward's configuration Kea's, and
|
|
// adds Kea's current leases (?dry_run=1 shows what it would do).
|
|
func (s *Server) importKea(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
|
if err := require(p, auth.ResConfig, true); err != nil {
|
|
return err
|
|
}
|
|
if err := require(p, auth.ResLeases, true); err != nil {
|
|
return err
|
|
}
|
|
var in KeaImport
|
|
if err := decodeJSON(r, &in); err != nil {
|
|
return err
|
|
}
|
|
host, _ := s.ifaces.All()
|
|
res, err := kea.ParseConfig([]byte(in.Config), kea.Options{HostInterfaces: host, Interfaces: in.Interfaces})
|
|
if err != nil {
|
|
return badRequest("%v", err)
|
|
}
|
|
var missing []string
|
|
for _, sn := range res.State.Subnets {
|
|
if sn.Interface == "" {
|
|
missing = append(missing, sn.Prefix.String())
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
return badRequest("Kea doesn't say which interface serves %s, and no interface here has an address in it; give it: --interface %s=IFACE", strings.Join(missing, ", "), missing[0])
|
|
}
|
|
cur := s.eng.State()
|
|
if (len(cur.Subnets) > 0 || len(cur.Reservations) > 0) && !in.Replace {
|
|
return conflict("Steward already has %d subnets and %d reservations; import with replace (--replace) to replace them with Kea's", len(cur.Subnets), len(cur.Reservations))
|
|
}
|
|
// A subnet with the same prefix, or a reservation for the same address,
|
|
// keeps its ID, so importing again shows only what differs. Kea has no
|
|
// subnet names: Steward's stay.
|
|
ids, err := s.store.AllocateIDs(r.Context(), len(res.State.Subnets)+len(res.State.Reservations))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, sn := range res.State.Subnets {
|
|
sn.ID = ids[i]
|
|
for _, old := range cur.Subnets {
|
|
if old.Prefix == sn.Prefix {
|
|
sn.ID, sn.Name = old.ID, old.Name
|
|
if sn.Comment == "" {
|
|
sn.Comment = old.Comment
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for i, rv := range res.State.Reservations {
|
|
rv.ID = ids[len(res.State.Subnets)+i]
|
|
for _, old := range cur.Reservations {
|
|
if old.IP == rv.IP {
|
|
rv.ID = old.ID
|
|
if rv.Comment == "" {
|
|
rv.Comment = old.Comment
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Steward's own settings that Kea has no word for stay.
|
|
res.State.Settings.PingCheck = cur.Settings.PingCheck
|
|
res.State.Settings.PingTimeout = cur.Settings.PingTimeout
|
|
res.State.Settings.NotAuthoritative = cur.Settings.NotAuthoritative
|
|
var leases []*model.Lease
|
|
skippedLeases := 0
|
|
now := time.Now()
|
|
for i, text := range in.Leases {
|
|
ls, err := kea.ParseLeases(strings.NewReader(text), now)
|
|
if err != nil {
|
|
return badRequest("lease file %d: %v", i+1, err)
|
|
}
|
|
leases = append(leases, ls...)
|
|
}
|
|
// A later file's lease for an address replaces an earlier one's.
|
|
byIP := map[string]*model.Lease{}
|
|
for _, l := range leases {
|
|
sn := res.State.SubnetFor(l.IP)
|
|
if sn == nil {
|
|
skippedLeases++
|
|
continue
|
|
}
|
|
l.SubnetID, l.Interface = sn.ID, sn.Interface
|
|
byIP[l.IP.String()] = l
|
|
}
|
|
leases = leases[:0]
|
|
for _, l := range byIP {
|
|
leases = append(leases, l)
|
|
}
|
|
sort.Slice(leases, func(i, j int) bool { return leases[i].IP.Less(leases[j].IP) })
|
|
if skippedLeases > 0 {
|
|
res.Notes = append(res.Notes, fmt.Sprintf("%d leases are for addresses outside the imported subnets and were left out", skippedLeases))
|
|
}
|
|
result, err := s.eng.Replace(r.Context(), p, "import from Kea", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, res.State)
|
|
if err != nil {
|
|
s.writeErr(w, r, err, result)
|
|
return nil
|
|
}
|
|
if result.Applied && len(leases) > 0 {
|
|
if err := s.dhcp.ImportLeases(r.Context(), leases); err != nil {
|
|
return fmt.Errorf("the configuration was imported, but saving the leases failed: %w", err)
|
|
}
|
|
s.eng.Audit(r.Context(), p, "lease.import", "kea", "success", fmt.Sprintf("%d leases", len(leases)))
|
|
}
|
|
out := api.ImportResult{MutationResponse: api.MutationResponse{Applied: result.Applied, DryRun: result.DryRun, HistoryID: result.HistoryID, Changes: result.Changes, Warnings: result.Warnings},
|
|
Leases: len(leases), Notes: res.Notes, Skipped: res.Skipped}
|
|
if out.Changes == nil {
|
|
out.Changes = []model.Change{}
|
|
}
|
|
if out.Notes == nil {
|
|
out.Notes = []string{}
|
|
}
|
|
if out.Skipped == nil {
|
|
out.Skipped = []string{}
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
return nil
|
|
}
|