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>
338 lines
13 KiB
Go
338 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/cookiejar"
|
|
"net/http/httptest"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
|
|
"steward/internal/api"
|
|
"steward/internal/auth"
|
|
"steward/internal/dhcp4"
|
|
"steward/internal/dhcpd"
|
|
"steward/internal/engine"
|
|
"steward/internal/model"
|
|
"steward/internal/store"
|
|
)
|
|
|
|
type hostIfaces map[string][]netip.Prefix
|
|
|
|
func (h hostIfaces) Addrs(name string) ([]netip.Prefix, error) {
|
|
a, ok := h[name]
|
|
if !ok {
|
|
return nil, errors.New("no interface " + name)
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil }
|
|
|
|
type env struct {
|
|
t *testing.T
|
|
ts *httptest.Server
|
|
srv *Server
|
|
dhcp *dhcpd.Server
|
|
eng *engine.Engine
|
|
token string // the admin's API token
|
|
}
|
|
|
|
func newEnv(t *testing.T) *env {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
st, err := store.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hash, _ := auth.HashPassword("secret-pass-1")
|
|
admin := &model.User{Username: "admin", PasswordHash: hash, Admin: true}
|
|
if err := st.CreateUser(ctx, admin); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tok, pid, sh := auth.NewToken()
|
|
st.CreateToken(ctx, &model.Token{UserID: admin.ID, Name: "test", PublicID: pid, SecretHash: sh})
|
|
eng, err := engine.New(ctx, st, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ifs := hostIfaces{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}, "eth0.20": {netip.MustParsePrefix("10.0.20.1/24")}}
|
|
d := dhcpd.New(nil, st, ifs, nil)
|
|
eng.OnChange = d.SetConfig
|
|
srv := New(Config{}, eng, d, ifs, nil, nil)
|
|
ts := httptest.NewServer(srv.Handler())
|
|
t.Cleanup(ts.Close)
|
|
return &env{t: t, ts: ts, srv: srv, dhcp: d, eng: eng, token: tok}
|
|
}
|
|
|
|
// call makes a request with a token; it returns the status and decodes
|
|
// the body into out (if given).
|
|
func (e *env) call(token, method, path string, body any, out any) int {
|
|
e.t.Helper()
|
|
var rd io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
rd = bytes.NewReader(b)
|
|
}
|
|
req, _ := http.NewRequest(method, e.ts.URL+api.Prefix+path, rd)
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
e.t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
if out != nil && len(b) > 0 {
|
|
if err := json.Unmarshal(b, out); err != nil {
|
|
e.t.Fatalf("%s %s: %v: %s", method, path, err, b)
|
|
}
|
|
}
|
|
return resp.StatusCode
|
|
}
|
|
|
|
func ptr[T any](v T) *T { return &v }
|
|
|
|
func TestLoginSessionAndCSRF(t *testing.T) {
|
|
e := newEnv(t)
|
|
jar, _ := cookiejar.New(nil)
|
|
c := &http.Client{Jar: jar}
|
|
post := func(path, body, csrf string) *http.Response {
|
|
req, _ := http.NewRequest("POST", e.ts.URL+api.Prefix+path, strings.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if csrf != "" {
|
|
req.Header.Set("X-CSRF-Token", csrf)
|
|
}
|
|
resp, err := c.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
if r := post("/auth/login", `{"username":"admin","password":"wrong"}`, ""); r.StatusCode != 401 {
|
|
t.Errorf("wrong password: %d", r.StatusCode)
|
|
}
|
|
r := post("/auth/login", `{"username":"admin","password":"secret-pass-1"}`, "")
|
|
var me api.Whoami
|
|
json.NewDecoder(r.Body).Decode(&me)
|
|
if r.StatusCode != 200 || me.CSRFToken == "" || !me.Admin {
|
|
t.Fatalf("login: %d %+v", r.StatusCode, me)
|
|
}
|
|
body := `{"prefix":"10.0.10.0/24","interface":"eth0","pools":["10.0.10.100-10.0.10.199"]}`
|
|
if r := post("/subnets", body, ""); r.StatusCode != 403 {
|
|
t.Errorf("change without CSRF token: %d", r.StatusCode)
|
|
}
|
|
if r := post("/subnets", body, me.CSRFToken); r.StatusCode != 201 {
|
|
b, _ := io.ReadAll(r.Body)
|
|
t.Errorf("change with CSRF token: %d %s", r.StatusCode, b)
|
|
}
|
|
h, _ := e.srv.store.ListHistory(context.Background(), 1, 0)
|
|
if len(h) != 1 || h[0].Via != "web" || h[0].Username != "admin" {
|
|
t.Errorf("history %+v", h)
|
|
}
|
|
}
|
|
|
|
func TestConfigFlow(t *testing.T) {
|
|
e := newEnv(t)
|
|
var mr api.MutationResponse
|
|
// A dry run changes nothing.
|
|
sub := api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0"), Pools: &[]string{"10.0.10.100-10.0.10.199"},
|
|
Options: &[]api.OptionInput{{Option: "routers", Value: "10.0.10.1"}, {Option: "domain-name-servers", Value: "10.0.10.2,10.0.10.3"}}}
|
|
if code := e.call(e.token, "POST", "/subnets?dry_run=1", sub, &mr); code != 200 || mr.Applied || len(mr.Changes) != 1 {
|
|
t.Fatalf("dry run: %d %+v", code, mr)
|
|
}
|
|
if code := e.call(e.token, "POST", "/subnets", sub, &mr); code != 201 || !mr.Applied {
|
|
t.Fatalf("create: %d %+v", code, mr)
|
|
}
|
|
var sn model.Subnet
|
|
json.Unmarshal(mr.Object, &sn)
|
|
if sn.ID == 0 || sn.Options[1].Value != "10.0.10.2, 10.0.10.3" {
|
|
t.Errorf("created %+v", sn)
|
|
}
|
|
// An invalid change is refused with the problem.
|
|
var eb api.ErrorBody
|
|
bad := api.SubnetInput{Prefix: ptr("10.0.0.0/16"), Interface: ptr("eth1")}
|
|
if code := e.call(e.token, "POST", "/subnets", bad, &eb); code != 422 || eb.Error.Problems[0].Code != "subnet_overlap" {
|
|
t.Errorf("overlap: %d %+v", code, eb)
|
|
}
|
|
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "dhcp-lease-time", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "unknown option") {
|
|
t.Errorf("unknown option: %d %+v", code, eb)
|
|
}
|
|
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.30.0/24"), Interface: ptr("eth0"), Options: &[]api.OptionInput{{Option: "51", Value: "1"}}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "set the lease time instead") {
|
|
t.Errorf("reserved option: %d %+v", code, eb)
|
|
}
|
|
// Reservations; by name, address or MAC.
|
|
res := api.ReservationInput{MAC: ptr("AA-BB-CC-00-00-01"), IP: ptr("10.0.10.20"), Hostname: ptr("nas")}
|
|
if code := e.call(e.token, "POST", "/reservations", res, &mr); code != 201 {
|
|
t.Fatalf("reservation: %d %+v", code, mr)
|
|
}
|
|
var rv api.Reservation
|
|
if code := e.call(e.token, "GET", "/reservations/aa:bb:cc:00:00:01", nil, &rv); code != 200 || rv.IP.String() != "10.0.10.20" || rv.SubnetName != "office" {
|
|
t.Errorf("get reservation: %d %+v", code, rv)
|
|
}
|
|
if code := e.call(e.token, "PATCH", "/subnets/office", api.SubnetInput{LeaseTime: ptr(3600)}, &mr); code != 200 || !mr.Applied {
|
|
t.Errorf("update by name: %d %+v", code, mr)
|
|
}
|
|
var subs []api.Subnet
|
|
e.call(e.token, "GET", "/subnets", nil, &subs)
|
|
if len(subs) != 1 || subs[0].LeaseTime != 3600 || subs[0].Usage.PoolSize != 100 || subs[0].Usage.Reservations != 1 {
|
|
t.Errorf("subnets %+v", subs[0])
|
|
}
|
|
// Undo the lease time change.
|
|
var hs []model.HistoryEntry
|
|
e.call(e.token, "GET", "/history", nil, &hs)
|
|
var rr api.MutationResponse
|
|
if code := e.call(e.token, "POST", "/history/"+itoa(hs[0].ID)+"/revert", nil, &rr); code != 200 || !rr.Applied || rr.DryRun {
|
|
t.Fatalf("revert: %d %+v", code, rr)
|
|
}
|
|
if e.eng.State().Subnets[0].LeaseTime != 0 {
|
|
t.Error("revert didn't undo the lease time")
|
|
}
|
|
// Deleting the subnet takes its reservation along.
|
|
var dr api.MutationResponse
|
|
if code := e.call(e.token, "DELETE", "/subnets/10.0.10.0_24", nil, &dr); code != 200 || len(dr.Changes) != 2 {
|
|
t.Errorf("delete: %d %+v", code, dr)
|
|
}
|
|
}
|
|
|
|
func itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
|
|
|
|
func TestLeasesThroughDHCP(t *testing.T) {
|
|
e := newEnv(t)
|
|
var mr api.MutationResponse
|
|
e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("iot"), Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr)
|
|
// A client gets an address.
|
|
mac := net.HardwareAddr{0xaa, 0, 0, 0, 0, 1}
|
|
disc := dhcp4.NewRequest(dhcp4.Discover, mac, 1)
|
|
off := e.dhcp.Handle(context.Background(), "eth0.20", disc)
|
|
if off == nil {
|
|
t.Fatal("no offer")
|
|
}
|
|
req := dhcp4.NewRequest(dhcp4.Request, mac, 2)
|
|
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.20.1")))
|
|
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
|
|
req.Set(dhcp4.OptHostName, []byte("sensor"))
|
|
if ack := e.dhcp.Handle(context.Background(), "eth0.20", req); ack == nil || ack.Packet.Type() != dhcp4.Ack {
|
|
t.Fatal("no ack")
|
|
}
|
|
var leases []api.Lease
|
|
if code := e.call(e.token, "GET", "/leases?q=sensor", nil, &leases); code != 200 || len(leases) != 1 || leases[0].SubnetName != "iot" || leases[0].State != "active" {
|
|
t.Fatalf("leases: %d %+v", code, leases)
|
|
}
|
|
ip := leases[0].IP.String()
|
|
// Make it a reservation.
|
|
if code := e.call(e.token, "POST", "/leases/"+ip+"/reserve", nil, &mr); code != 201 || !mr.Applied {
|
|
t.Fatalf("reserve: %d %+v", code, mr)
|
|
}
|
|
var rv api.Reservation
|
|
json.Unmarshal(mr.Object, &rv)
|
|
if rv.MAC != mac.String() || rv.Hostname != "sensor" || rv.Lease == nil {
|
|
t.Errorf("reservation from lease %+v", rv)
|
|
}
|
|
var st api.Status
|
|
if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Leases["active"] != 1 || st.Subnets[0].Usage.ReservationsLeased != 1 || st.Messages["DHCPACK"] != 1 {
|
|
t.Errorf("status: %d %+v", code, st)
|
|
}
|
|
var evs []api.Event
|
|
e.call(e.token, "GET", "/events?mac="+mac.String(), nil, &evs)
|
|
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
|
|
t.Errorf("events %+v", evs)
|
|
}
|
|
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 204 {
|
|
t.Errorf("delete lease: %d", code)
|
|
}
|
|
if code := e.call(e.token, "DELETE", "/leases/"+ip, nil, nil); code != 404 {
|
|
t.Errorf("delete again: %d", code)
|
|
}
|
|
var audit []model.AuditEvent
|
|
e.call(e.token, "GET", "/audit?action=lease.", nil, &audit)
|
|
if len(audit) != 1 || audit[0].Object != "lease "+ip {
|
|
t.Errorf("audit %+v", audit)
|
|
}
|
|
}
|
|
|
|
func TestPermissions(t *testing.T) {
|
|
e := newEnv(t)
|
|
var tok api.Token
|
|
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "monitor", Role: "leases"}, &tok); code != 201 || tok.Secret == "" {
|
|
t.Fatalf("token: %d %+v", code, tok)
|
|
}
|
|
if code := e.call(tok.Secret, "GET", "/leases", nil, nil); code != 200 {
|
|
t.Errorf("leases with a leases:read token: %d", code)
|
|
}
|
|
var eb api.ErrorBody
|
|
if code := e.call(tok.Secret, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, &eb); code != 403 || !strings.Contains(eb.Error.Message, "config:write") {
|
|
t.Errorf("change with a read token: %d %+v", code, eb)
|
|
}
|
|
if code := e.call(tok.Secret, "GET", "/users", nil, nil); code != 403 {
|
|
t.Errorf("users with a restricted token: %d", code)
|
|
}
|
|
// A restricted token can't make a wider one.
|
|
if code := e.call(tok.Secret, "POST", "/me/tokens", api.TokenInput{Name: "wider", Role: "operator"}, nil); code != 403 {
|
|
t.Errorf("wider token: %d", code)
|
|
}
|
|
if code := e.call("", "GET", "/status", nil, nil); code != 401 {
|
|
t.Errorf("no token: %d", code)
|
|
}
|
|
if code := e.call("steward_aaaaaaaaaaaaaaaa_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "GET", "/status", nil, nil); code != 401 {
|
|
t.Errorf("bad token: %d", code)
|
|
}
|
|
// The last administrator stays.
|
|
var users []api.User
|
|
e.call(e.token, "GET", "/users", nil, &users)
|
|
if code := e.call(e.token, "PATCH", "/users/"+itoa(users[0].ID), api.UserInput{Admin: ptr(false)}, &eb); code != 409 {
|
|
t.Errorf("demoting the last admin: %d", code)
|
|
}
|
|
}
|
|
|
|
func TestImportKea(t *testing.T) {
|
|
e := newEnv(t)
|
|
cfg := `{"Dhcp4": {
|
|
"interfaces-config": {"interfaces": ["eth0", "eth0.20"]},
|
|
"subnet4": [{"subnet": "10.0.10.0/24", "pools": [{"pool": "10.0.10.100 - 10.0.10.199"}],
|
|
"option-data": [{"name": "routers", "data": "10.0.10.1"}],
|
|
"reservations": [{"hw-address": "aa:bb:cc:00:00:01", "ip-address": "10.0.10.20", "hostname": "nas"}]},
|
|
{"subnet": "172.16.0.0/24", "pools": [{"pool": "172.16.0.10-172.16.0.20"}]}],
|
|
"hooks-libraries": []}}`
|
|
leases := "address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context\n" +
|
|
"10.0.10.150,aa:bb:cc:00:00:09,,43200,4000000000,1,0,0,tv,0,\n10.9.9.9,aa:bb:cc:00:00:0a,,43200,4000000000,9,0,0,x,0,\n"
|
|
var eb api.ErrorBody
|
|
if code := e.call(e.token, "POST", "/import/kea", KeaImport{Config: cfg, Leases: []string{leases}}, &eb); code != 400 || !strings.Contains(eb.Error.Message, "172.16.0.0/24") {
|
|
t.Fatalf("subnet without an interface: %d %+v", code, eb)
|
|
}
|
|
in := KeaImport{Config: cfg, Leases: []string{leases}, Interfaces: map[string]string{"172.16.0.0/24": "eth0.20"}}
|
|
var ir api.ImportResult
|
|
if code := e.call(e.token, "POST", "/import/kea", in, &ir); code != 200 || !ir.Applied || ir.Leases != 1 || len(ir.Changes) != 3 {
|
|
t.Fatalf("import: %d %+v", code, ir)
|
|
}
|
|
if !strings.Contains(strings.Join(ir.Skipped, " "), "hooks-libraries") || !strings.Contains(strings.Join(ir.Notes, " "), "outside the imported subnets") {
|
|
t.Errorf("notes %v skipped %v", ir.Notes, ir.Skipped)
|
|
}
|
|
if l := e.dhcp.Lease(netip.MustParseAddr("10.0.10.150")); l == nil || l.Hostname != "tv" || l.Interface != "eth0" {
|
|
t.Errorf("imported lease %+v", l)
|
|
}
|
|
// Importing again needs replace.
|
|
if code := e.call(e.token, "POST", "/import/kea", in, &eb); code != 409 {
|
|
t.Errorf("second import: %d", code)
|
|
}
|
|
in.Replace = true
|
|
var again api.ImportResult
|
|
if code := e.call(e.token, "POST", "/import/kea?dry_run=1", in, &again); code != 200 || again.Applied || len(again.Changes) != 0 {
|
|
t.Errorf("the same import again changes something: %d %+v", code, again)
|
|
}
|
|
// A name given in Steward survives a new import.
|
|
e.call(e.token, "PATCH", "/subnets/10.0.10.0_24", api.SubnetInput{Name: ptr("office")}, nil)
|
|
var third api.ImportResult
|
|
if code := e.call(e.token, "POST", "/import/kea", in, &third); code != 200 || len(third.Changes) != 0 || e.eng.State().FindSubnet("office") == nil {
|
|
t.Errorf("import over a named subnet: %d %+v", code, third)
|
|
}
|
|
}
|