Files
steward/internal/model/access.go
T
abrown38andClaude Opus 5.5 3ad2f981d4 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>
2026-10-18 20:27:51 +01:00

94 lines
2.9 KiB
Go

package model
import (
"fmt"
"strings"
)
// Subnet access modes: who a subnet answers.
const (
// AccessDenyList answers every client except those on the deny list
// (the default, stored as "").
AccessDenyList = "deny-list"
// AccessAllowList answers only clients on the allow list or with a
// reservation in the subnet, and not on the deny list.
AccessAllowList = "allow-list"
)
// ClientMatch is an entry of a subnet's allow or deny list: a MAC address
// ("aa:bb:cc:dd:ee:ff"), a MAC address prefix for a maker's devices
// ("aa:bb:cc:*"), or a client identifier ("id 01aabbccddeeff").
type ClientMatch struct {
Match string `json:"match"`
Comment string `json:"comment,omitempty"`
}
// ParseClientMatch reads and normalizes an allow or deny list entry.
func ParseClientMatch(s string) (string, error) {
s = strings.ToLower(strings.TrimSpace(s))
if rest, ok := strings.CutPrefix(s, "id"); ok && (strings.HasPrefix(rest, " ") || strings.HasPrefix(rest, ":") || strings.HasPrefix(rest, "=")) {
id, err := NormalizeClientID(rest[1:])
if err != nil {
return "", err
}
return "id " + id, nil
}
if prefix, ok := strings.CutSuffix(s, "*"); ok {
prefix = strings.TrimRight(strings.ReplaceAll(prefix, "-", ":"), ":")
parts := strings.Split(prefix, ":")
if prefix == "" || len(parts) > 5 {
return "", fmt.Errorf("%q: a MAC address prefix has 1 to 5 bytes, as aa:bb:cc:*", s)
}
for i, p := range parts {
if len(p) == 1 {
p = "0" + p
}
if len(p) != 2 || strings.Trim(p, "0123456789abcdef") != "" {
return "", fmt.Errorf("%q: a MAC address prefix is written as aa:bb:cc:*", s)
}
parts[i] = p
}
return strings.Join(parts, ":") + ":*", nil
}
mac, err := NormalizeMAC(s)
if err != nil {
return "", fmt.Errorf("%q is not a MAC address (aa:bb:cc:dd:ee:ff), a prefix (aa:bb:cc:*) or a client ID (id 01aabbccddeeff)", s)
}
return mac, nil
}
// Matches reports whether the entry is the client's: mac as
// "aa:bb:cc:dd:ee:ff", id the client identifier in hex ("" for none).
func (m ClientMatch) Matches(mac, id string) bool {
switch {
case strings.HasPrefix(m.Match, "id "):
return id != "" && m.Match[3:] == id
case strings.HasSuffix(m.Match, "*"):
return mac != "" && strings.HasPrefix(mac, m.Match[:len(m.Match)-1])
}
return mac != "" && m.Match == mac
}
// Admits says whether the subnet answers a client, and if not why. resv
// tells whether the client has an (enabled) reservation in the subnet.
func (s *Subnet) Admits(mac, id string, resv bool) (bool, string) {
for _, m := range s.Deny {
if m.Matches(mac, id) {
why := "on the deny list of " + s.Label()
if m.Comment != "" {
why += " (" + m.Comment + ")"
}
return false, why
}
}
if s.Access != AccessAllowList || resv {
return true, ""
}
for _, m := range s.Allow {
if m.Matches(mac, id) {
return true, ""
}
}
return false, "not on the allow list of " + s.Label()
}