Allow and deny lists per subnet; every option of a DHCP message in words
- A subnet is a deny-list subnet (the default: everyone not denied) or
an allow-list subnet (only clients allowed or with a reservation);
the deny list counts in both. Entries are MAC addresses, prefixes
(aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
in the activity log, with an Allow button) or a NAK on renewal.
steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
decoded into words (dhcp4.Describe): the parameter request list by
name, client IDs, lease times. ▸ on an Activity row, steward
activity show N / --details, GET /events/{id}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b3c8329ae8
commit
3ad2f981d4
30 files changed
+1092
-37
No files matched your search
@@ -109,6 +109,12 @@ func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation {
|
||||
return m["mac "+cl.mac]
|
||||
}
|
||||
|
||||
// admits says whether the subnet answers the client (its allow and deny
|
||||
// lists), and if not why.
|
||||
func (c *config) admits(sub *subnet, cl client) (bool, string) {
|
||||
return sub.Admits(cl.mac, cl.id, c.reservationFor(sub.ID, cl) != nil)
|
||||
}
|
||||
|
||||
// subnetFor finds the interface's subnet holding a.
|
||||
func (c *config) subnetFor(iface string, a netip.Addr) *subnet {
|
||||
for _, s := range c.byIface[iface] {
|
||||
|
||||
@@ -41,7 +41,8 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
|
||||
}
|
||||
cl := client{mac: p.MAC().String(), id: p.ClientID()}
|
||||
t := p.Type()
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName()}
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: t.String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "in", ClientID: cl.id}
|
||||
ev.Fields, ev.Options = p.Describe()
|
||||
if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
|
||||
ev.IP = a.String()
|
||||
} else if !dhcp4.IsZero(p.CIAddr) {
|
||||
@@ -64,7 +65,13 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
|
||||
return nil
|
||||
}
|
||||
if out != nil {
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()}
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName(), Direction: "out", ClientID: cl.id}
|
||||
ev.Fields, ev.Options = out.Packet.Describe()
|
||||
if out.Dst.IsValid() {
|
||||
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: out.Dst.String() + " (unicast)"})
|
||||
} else {
|
||||
ev.Fields = append(ev.Fields, dhcp4.Field{Name: "sent to", Value: "255.255.255.255 (broadcast on " + iface + ")"})
|
||||
}
|
||||
if !dhcp4.IsZero(out.Packet.YIAddr) {
|
||||
ev.IP = out.Packet.YIAddr.String()
|
||||
}
|
||||
@@ -79,6 +86,22 @@ func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Out
|
||||
// ---- DISCOVER
|
||||
|
||||
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound {
|
||||
// Only the subnets whose access lists let the client in.
|
||||
var allowed []*subnet
|
||||
var refusedBy *subnet
|
||||
why := ""
|
||||
for _, sub := range subs {
|
||||
if ok, reason := cfg.admits(sub, cl); ok {
|
||||
allowed = append(allowed, sub)
|
||||
} else {
|
||||
refusedBy, why = sub, reason
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
s.denied(iface, refusedBy, cl, why)
|
||||
return nil
|
||||
}
|
||||
subs = allowed
|
||||
requested, _ := p.IPOption(dhcp4.OptRequestedIP)
|
||||
for try := 0; try < 8; try++ {
|
||||
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested)
|
||||
@@ -238,6 +261,11 @@ func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *s
|
||||
s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"})
|
||||
}
|
||||
|
||||
// denied records a client a subnet's access lists refused.
|
||||
func (s *Server) denied(iface string, sub *subnet, cl client, why string) {
|
||||
s.events.add(Event{Time: s.now(), Interface: iface, Type: "DENIED", MAC: cl.mac, ClientID: cl.id, SubnetID: sub.ID, Detail: "no answer: the client is " + why})
|
||||
}
|
||||
|
||||
// ---- REQUEST
|
||||
|
||||
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
|
||||
@@ -277,6 +305,11 @@ func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Pa
|
||||
if sub == nil {
|
||||
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface))
|
||||
}
|
||||
// A client refused since it got its lease: a NAK makes it start over
|
||||
// at once (its DISCOVER then gets no answer).
|
||||
if ok, why := cfg.admits(sub, cl); !ok {
|
||||
return s.nak(cfg, iface, p, sub, "this client is "+why)
|
||||
}
|
||||
now := s.now()
|
||||
own := s.ownAddrs(iface)
|
||||
s.mu.Lock()
|
||||
@@ -403,6 +436,10 @@ func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *
|
||||
if sub == nil {
|
||||
return nil
|
||||
}
|
||||
if ok, why := cfg.admits(sub, cl); !ok {
|
||||
s.denied(iface, sub, cl, why)
|
||||
return nil
|
||||
}
|
||||
sid, err := s.serverAddr(iface, sub)
|
||||
if err != nil {
|
||||
return nil
|
||||
|
||||
@@ -612,3 +612,51 @@ func TestReservationLeaseTimeAndClientID(t *testing.T) {
|
||||
t.Errorf("stranger: %s %d", ack.YIAddr, u32(ack))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessLists(t *testing.T) {
|
||||
// A deny-list subnet: everyone but the denied.
|
||||
h := newHarness(t, func(st *model.State) {
|
||||
st.Subnets[0].Deny = []model.ClientMatch{{Match: "aa:00:00:00:00:02"}, {Match: "aa:00:00:00:00:9*"}}
|
||||
})
|
||||
if h.discover("eth0", mac(1)) == nil {
|
||||
t.Error("an unlisted client was refused")
|
||||
}
|
||||
for _, m := range []byte{2, 0x95} {
|
||||
if out := h.discover("eth0", mac(m)); out != nil {
|
||||
t.Errorf("denied %s got %s", mac(m), out.Packet.YIAddr)
|
||||
}
|
||||
}
|
||||
if evs := h.s.Events(1, nil); evs[0].Type != "DENIED" || !strings.Contains(evs[0].Detail, "deny list of office") {
|
||||
t.Errorf("event %+v", evs[0])
|
||||
}
|
||||
// The deny list wins over a reservation (the printer, aa:...:98).
|
||||
if out := h.discover("eth0", mac(0x98)); out != nil {
|
||||
t.Errorf("a denied reserved client got %s", out.Packet.YIAddr)
|
||||
}
|
||||
|
||||
// An allow-list subnet: the allowed, by MAC, prefix or client ID, and
|
||||
// clients with a reservation.
|
||||
h = newHarness(t, func(st *model.State) {
|
||||
st.Subnets[0].Access = model.AccessAllowList
|
||||
st.Subnets[0].Allow = []model.ClientMatch{{Match: "aa:00:00:00:00:01"}, {Match: "id ff07"}}
|
||||
})
|
||||
ip := h.lease("eth0", mac(1)).YIAddr
|
||||
if h.discover("eth0", mac(3)) != nil {
|
||||
t.Error("an unlisted client got an address")
|
||||
}
|
||||
if h.discover("eth0", mac(7), dhcp4.Option{Code: dhcp4.OptClientID, Data: []byte{0xff, 7}}) == nil {
|
||||
t.Error("a client allowed by client ID was refused")
|
||||
}
|
||||
if h.discover("eth0", mac(0x99)) == nil {
|
||||
t.Error("a reserved client was refused")
|
||||
}
|
||||
// Taken off the list: its renewal gets a NAK.
|
||||
st := h.s.cfg.Load().state
|
||||
st.Subnets[0].Allow = st.Subnets[0].Allow[1:]
|
||||
h.s.SetConfig(st)
|
||||
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 9)
|
||||
ren.CIAddr = ip
|
||||
if out := h.send("eth0", ren); out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "allow list") {
|
||||
t.Errorf("renewal of a refused client: %+v", out)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
@@ -324,6 +325,18 @@ type Event struct {
|
||||
IP string `json:"ip,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// ID numbers the events since stewardd started.
|
||||
ID uint64 `json:"id"`
|
||||
// Direction is "in" for a client's message, "out" for Steward's
|
||||
// answer, "" for an event of Steward's own (CONFLICT, NO ADDRESS).
|
||||
Direction string `json:"direction,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
// SubnetID is the subnet that refused a client (DENIED).
|
||||
SubnetID int64 `json:"subnet_id,omitempty"`
|
||||
// Fields and Options are the message, in words (GET /events/{id}, or
|
||||
// ?details=1).
|
||||
Fields []dhcp4.Field `json:"fields,omitempty"`
|
||||
Options []dhcp4.Field `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
type events struct {
|
||||
@@ -331,11 +344,14 @@ type events struct {
|
||||
max int
|
||||
list []Event
|
||||
counts map[string]uint64
|
||||
lastID uint64
|
||||
}
|
||||
|
||||
func (e *events) add(ev Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.lastID++
|
||||
ev.ID = e.lastID
|
||||
e.list = append(e.list, ev)
|
||||
if len(e.list) > e.max {
|
||||
e.list = append([]Event(nil), e.list[len(e.list)-e.max:]...)
|
||||
@@ -362,6 +378,18 @@ func (s *Server) Events(limit int, keep func(Event) bool) []Event {
|
||||
return out
|
||||
}
|
||||
|
||||
// Event returns one event, with its details.
|
||||
func (s *Server) Event(id uint64) (Event, bool) {
|
||||
s.events.mu.Lock()
|
||||
defer s.events.mu.Unlock()
|
||||
for i := len(s.events.list) - 1; i >= 0; i-- {
|
||||
if s.events.list[i].ID == id {
|
||||
return s.events.list[i], true
|
||||
}
|
||||
}
|
||||
return Event{}, false
|
||||
}
|
||||
|
||||
// Counts returns how many messages of each type were seen and sent since
|
||||
// the start.
|
||||
func (s *Server) Counts() map[string]uint64 {
|
||||
|
||||
Reference in new issue
Block a user