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:
abrown38andClaude Opus 5.5 committed 2026-10-18 20:27:51 +01:00
1 parent b3c8329ae8
commit 3ad2f981d4
30 files changed
+1092 -37

No files matched your search

+24 -6
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"time"
"steward/internal/dhcp4"
"steward/internal/model"
"steward/internal/validate"
"steward/internal/version"
@@ -81,12 +82,17 @@ type SubnetInput struct {
Pools *[]string `json:"pools,omitempty"`
// PoolOrder is listed or random; AddressOrder sequential, reverse,
// random or "" (the settings').
PoolOrder *string `json:"pool_order,omitempty"`
AddressOrder *string `json:"address_order,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
PoolOrder *string `json:"pool_order,omitempty"`
AddressOrder *string `json:"address_order,omitempty"`
// Access is deny-list or allow-list; Allow and Deny replace the
// lists (entries: a MAC address, aa:bb:cc:* or "id HEX").
Access *string `json:"access,omitempty"`
Allow *[]model.ClientMatch `json:"allow,omitempty"`
Deny *[]model.ClientMatch `json:"deny,omitempty"`
LeaseTime *int `json:"lease_time,omitempty"`
Options *[]OptionInput `json:"options,omitempty"`
Disabled *bool `json:"disabled,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// ReservationInput creates or changes a reservation (unset fields stay).
@@ -255,6 +261,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"`
}
// ImportResult reports a Kea import.
+167
View File
@@ -0,0 +1,167 @@
package dhcp4
import (
"encoding/binary"
"fmt"
"strconv"
"strings"
"time"
)
// Field is a message's header field or option, in words: for the activity
// log's details.
type Field struct {
// Code is the option's number (0 for a header field).
Code int `json:"code,omitempty"`
Name string `json:"name"`
Value string `json:"value"`
}
// protocol are the options the server and clients set themselves, which
// the catalog leaves out because they can't be configured.
var protocol = map[byte]Def{
1: {Name: "subnet-mask", Type: TypeIP},
12: {Name: "host-name", Type: TypeString},
50: {Name: "requested-address", Type: TypeIP},
51: {Name: "lease-time", Type: "seconds"},
52: {Name: "option-overload", Type: TypeU8},
53: {Name: "message-type", Type: "message-type"},
54: {Name: "server-identifier", Type: TypeIP},
55: {Name: "parameter-request-list", Type: "codes"},
56: {Name: "message", Type: TypeString},
57: {Name: "max-message-size", Type: TypeU16},
58: {Name: "renewal-time", Type: "seconds"},
59: {Name: "rebinding-time", Type: "seconds"},
61: {Name: "client-identifier", Type: "client-id"},
77: {Name: "user-class", Type: TypeHex},
81: {Name: "client-fqdn", Type: "fqdn"},
82: {Name: "relay-agent-information", Type: TypeHex},
116: {Name: "auto-configure", Type: TypeU8},
118: {Name: "subnet-selection", Type: TypeIP},
}
// optionDef is an option's definition for display.
func optionDef(code byte) Def {
if d, ok := protocol[code]; ok {
d.Code = code
return d
}
return ByCode(code)
}
// OptionName is an option's name: "routers", "parameter-request-list",
// "option-224".
func OptionName(code byte) string { return optionDef(code).Name }
// DescribeOption is an option's value in words.
func DescribeOption(code byte, b []byte) string {
d := optionDef(code)
switch d.Type {
case "seconds":
if len(b) == 4 {
s := binary.BigEndian.Uint32(b)
return fmt.Sprintf("%d s (%s)", s, shortDuration(time.Duration(s)*time.Second))
}
case "message-type":
if len(b) == 1 {
return MessageType(b[0]).String()
}
case "codes":
names := make([]string, len(b))
for i, c := range b {
names[i] = OptionName(c) + " (" + strconv.Itoa(int(c)) + ")"
}
return strings.Join(names, ", ")
case "client-id":
hexed := Decode(TypeHex, b)
switch {
case len(b) == 7 && b[0] == 1:
return hexed + " (type 1: the MAC address " + Decode(TypeHex, b[1:]) + ")"
case len(b) > 5 && b[0] == 255:
return hexed + " (RFC 4361: IAID " + Decode(TypeHex, b[1:5]) + " and a DUID)"
}
return hexed
case "fqdn":
// Flags, two obsolete RCODEs, then the name: in DNS wire format
// when the E flag is set, else as text.
if len(b) >= 3 {
name := string(b[3:])
if b[0]&0x04 != 0 {
if names, err := decodeNames(b[3:]); err == nil && len(names) > 0 {
name = names[0]
}
}
var flags []string
if b[0]&0x01 != 0 {
flags = append(flags, "server updates the A record")
}
if b[0]&0x08 != 0 {
flags = append(flags, "no DNS updates")
}
if len(flags) == 0 {
flags = append(flags, "client updates the A record")
}
return name + " (" + strings.Join(flags, ", ") + ")"
}
default:
return Decode(d.Type, b)
}
return Decode(TypeHex, b)
}
// shortDuration writes 43200s as "12h", 90s as "1m30s", a week as "7d".
func shortDuration(d time.Duration) string {
if d == 0 {
return "0s"
}
var out string
for _, u := range []struct {
d time.Duration
name string
}{{24 * time.Hour, "d"}, {time.Hour, "h"}, {time.Minute, "m"}, {time.Second, "s"}} {
if n := d / u.d; n > 0 {
out += strconv.Itoa(int(n)) + u.name
d -= n * u.d
}
}
return out
}
// Describe lists a message's header fields that are set, and its options
// in the order they are in the message.
func (p *Packet) Describe() (fields, options []Field) {
add := func(name, value string) { fields = append(fields, Field{Name: name, Value: value}) }
add("transaction ID", fmt.Sprintf("0x%08x", p.XID))
add("client hardware address", p.MAC().String())
if !IsZero(p.CIAddr) {
add("client address (ciaddr)", p.CIAddr.String())
}
if !IsZero(p.YIAddr) {
add("your address (yiaddr)", p.YIAddr.String())
}
if !IsZero(p.SIAddr) {
add("next server (siaddr)", p.SIAddr.String())
}
if !IsZero(p.GIAddr) {
add("relay agent (giaddr)", p.GIAddr.String())
}
if p.Broadcast() {
add("flags", "broadcast (the client wants replies broadcast)")
}
if p.Secs != 0 {
add("seconds since the client began", strconv.Itoa(int(p.Secs)))
}
if p.Hops != 0 {
add("hops", strconv.Itoa(int(p.Hops)))
}
if p.SName != "" {
add("server name (sname)", p.SName)
}
if p.File != "" {
add("boot file (file)", p.File)
}
for _, o := range p.Options {
options = append(options, Field{Code: int(o.Code), Name: OptionName(o.Code), Value: DescribeOption(o.Code, o.Data)})
}
return fields, options
}
+36
View File
@@ -0,0 +1,36 @@
package dhcp4
import (
"net"
"strings"
"testing"
)
func TestDescribe(t *testing.T) {
p := NewRequest(Discover, net.HardwareAddr{0xaa, 0, 0, 0, 0, 1}, 0x1234)
p.Set(OptParamRequest, []byte{1, 3, 6, 224})
p.Set(OptClientID, []byte{1, 0xaa, 0, 0, 0, 0, 1})
p.Set(OptLeaseTime, U32(5400))
p.Set(81, []byte{0x05, 0, 0, 6, 'l', 'a', 'p', 't', 'o', 'p', 0})
fields, opts := p.Describe()
got := map[string]string{}
for _, f := range append(fields, opts...) {
got[f.Name] = f.Value
}
for name, want := range map[string]string{
"transaction ID": "0x00001234",
"message-type": "DHCPDISCOVER",
"parameter-request-list": "subnet-mask (1), routers (3), domain-name-servers (6), option-224 (224)",
"client-identifier": "01:aa:00:00:00:00:01 (type 1: the MAC address aa:00:00:00:00:01)",
"lease-time": "5400 s (1h30m)",
"client-fqdn": "laptop (server updates the A record)",
"client hardware address": "aa:00:00:00:00:01",
} {
if !strings.EqualFold(got[name], want) {
t.Errorf("%s: %q, want %q", name, got[name], want)
}
}
if shortDuration(7*24*3600e9) != "7d" || shortDuration(90e9) != "1m30s" || shortDuration(43200e9) != "12h" {
t.Error("shortDuration")
}
}
+6
View File
@@ -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] {
+39 -2
View File
@@ -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
+48
View File
@@ -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)
}
}
+28
View File
@@ -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 {
+9
View File
@@ -436,6 +436,15 @@ func Normalize(st *model.State) {
if s.PoolOrder == model.PoolsListed {
s.PoolOrder = ""
}
if s.Access == model.AccessDenyList {
s.Access = ""
}
if len(s.Allow) == 0 {
s.Allow = nil
}
if len(s.Deny) == 0 {
s.Deny = nil
}
}
for _, r := range st.Reservations {
r.Options = normOptions(r.Options)
+93
View File
@@ -0,0 +1,93 @@
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()
}
+6
View File
@@ -149,6 +149,12 @@ type Subnet struct {
// LeaseTime overrides the settings' lease time (0: inherit).
LeaseTime int `json:"lease_time"`
Options []OptionValue `json:"options"`
// Access is who the subnet answers: everyone not on Deny ("", a
// deny-list subnet) or, as an allow-list subnet, only clients on
// Allow or with a reservation, and not on Deny (access.go).
Access string `json:"access,omitempty"`
Allow []ClientMatch `json:"allow,omitempty"`
Deny []ClientMatch `json:"deny,omitempty"`
// Disabled subnets keep their configuration and leases but get no
// answers.
Disabled bool `json:"disabled"`
+20
View File
@@ -238,6 +238,26 @@ func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
if in.AddressOrder != nil {
sn.AddressOrder = orderInput(*in.AddressOrder)
}
if in.Access != nil {
sn.Access = strings.ToLower(strings.TrimSpace(*in.Access))
}
for _, l := range []struct {
in *[]model.ClientMatch
out *[]model.ClientMatch
}{{in.Allow, &sn.Allow}, {in.Deny, &sn.Deny}} {
if l.in == nil {
continue
}
list := []model.ClientMatch{}
for _, m := range *l.in {
norm, err := model.ParseClientMatch(m.Match)
if err != nil {
return badRequest("%v", err)
}
list = append(list, model.ClientMatch{Match: norm, Comment: strings.TrimSpace(m.Comment)})
}
*l.out = list
}
if in.LeaseTime != nil {
sn.LeaseTime = *in.LeaseTime
}
+22
View File
@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
@@ -201,14 +202,35 @@ func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Prin
}
return true
})
details := q.Get("details") == "1"
out := make([]api.Event, len(evs))
for i, e := range evs {
if !details {
e.Fields, e.Options = nil, nil
}
out[i] = api.Event(e)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// getEvent: GET /events/{id}, one message with its fields and options.
func (s *Server) getEvent(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLeases, false); err != nil {
return err
}
id, err := strconv.ParseUint(r.PathValue("id"), 10, 64)
if err != nil {
return badRequest("%q is not an event number", r.PathValue("id"))
}
e, ok := s.dhcp.Event(id)
if !ok {
return notFound("no event %d: only the last 1000 are kept, and none from before stewardd started", id)
}
writeJSON(w, http.StatusOK, api.Event(e))
return nil
}
// ---- status and audit
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
+1
View File
@@ -60,6 +60,7 @@ func (s *Server) routes() {
m.HandleFunc("DELETE "+p+"/leases/{ip}", s.authed(s.deleteLease))
m.HandleFunc("POST "+p+"/leases/{ip}/reserve", s.authed(s.reserveLease))
m.HandleFunc("GET "+p+"/events", s.authed(s.listEvents))
m.HandleFunc("GET "+p+"/events/{id}", s.authed(s.getEvent))
// Status and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
+51
View File
@@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
@@ -247,6 +248,28 @@ func TestLeasesThroughDHCP(t *testing.T) {
if len(evs) != 4 || evs[0].Type != "DHCPACK" {
t.Errorf("events %+v", evs)
}
// Details: on their own, or with ?details=1.
if len(evs[0].Options) != 0 {
t.Errorf("list with options: %+v", evs[0])
}
var ev api.Event
if code := e.call(e.token, "GET", "/events/"+fmt.Sprint(evs[0].ID), nil, &ev); code != 200 || ev.Direction != "out" || len(ev.Options) == 0 {
t.Errorf("event: %d %+v", code, ev)
}
found := false
for _, o := range ev.Options {
found = found || o.Name == "lease-time" && o.Value == "43200 s (12h)"
}
if !found {
t.Errorf("no lease time in %+v", ev.Options)
}
e.call(e.token, "GET", "/events?details=1&mac="+mac.String(), nil, &evs)
if len(evs[3].Options) == 0 || evs[3].Direction != "in" {
t.Errorf("details=1: %+v", evs[3])
}
if code := e.call(e.token, "GET", "/events/99999", nil, nil); code != 404 {
t.Errorf("missing event: %d", code)
}
// By subnet: its addresses, and DISCOVERs on its interface.
e.call(e.token, "POST", "/subnets", api.SubnetInput{Name: ptr("office"), Prefix: ptr("10.0.10.0/24"), Interface: ptr("eth0")}, nil)
for ref, want := range map[string]int{"iot": 4, "10.0.20.0/24": 4, "office": 0} {
@@ -476,3 +499,31 @@ func TestReserveByClientID(t *testing.T) {
t.Errorf("lease time: %d", code)
}
}
func TestAccessAPI(t *testing.T) {
e := newEnv(t)
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/subnets", api.SubnetInput{Prefix: ptr("10.0.20.0/24"), Interface: ptr("eth0.20"), Access: ptr("allow-list"), Pools: &[]string{"10.0.20.100-10.0.20.110"}}, &mr); code != 201 || len(mr.Warnings) == 0 || mr.Warnings[0].Code != "allow_list_empty" {
t.Fatalf("create: %d %+v", code, mr.Warnings)
}
in := api.SubnetInput{Allow: &[]model.ClientMatch{{Match: "AA-BB-CC-*", Comment: " sonos "}, {Match: "id 01:AA:BB"}}, Deny: &[]model.ClientMatch{{Match: "aabbcc000001"}}}
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", in, nil); code != 200 {
t.Fatalf("patch: %d", code)
}
sn := e.eng.State().Subnets[0]
if sn.Access != "allow-list" || sn.Allow[0] != (model.ClientMatch{Match: "aa:bb:cc:*", Comment: "sonos"}) || sn.Allow[1].Match != "id 01aabb" || sn.Deny[0].Match != "aa:bb:cc:00:00:01" {
t.Errorf("subnet %+v %+v %+v", sn.Access, sn.Allow, sn.Deny)
}
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Deny: &[]model.ClientMatch{{Match: "nonsense"}}}, nil); code != 400 {
t.Errorf("bad entry: %d", code)
}
var eb api.ErrorBody
if code := e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("whitelist")}, &eb); code != 422 || eb.Error.Problems[0].Code != "access_mode" {
t.Errorf("bad mode: %d %+v", code, eb)
}
// "deny-list" is the default, stored as "".
e.call(e.token, "PATCH", "/subnets/10.0.20.0_24", api.SubnetInput{Access: ptr("deny-list")}, nil)
if sn = e.eng.State().Subnets[0]; sn.Access != "" || len(sn.Allow) != 2 {
t.Errorf("deny-list keeps the allow list: %+v", sn)
}
}
+27 -6
View File
@@ -25,16 +25,16 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if st.Settings.Options == nil {
st.Settings.Options = []model.OptionValue{}
}
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment FROM subnets ORDER BY id`)
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, access, allow_list, deny_list, disabled, comment FROM subnets ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
sn := &model.Subnet{}
var prefix, pools, opts string
var prefix, pools, opts, allow, deny string
var disabled int
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil {
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.PoolOrder, &sn.AddressOrder, &sn.LeaseTime, &opts, &sn.Access, &allow, &deny, &disabled, &sn.Comment); err != nil {
return nil, err
}
sn.Prefix, _ = netip.ParsePrefix(prefix)
@@ -45,6 +45,18 @@ func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil {
return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err)
}
if err := json.Unmarshal([]byte(allow), &sn.Allow); err != nil {
return nil, fmt.Errorf("subnet %d allow list: %w", sn.ID, err)
}
if err := json.Unmarshal([]byte(deny), &sn.Deny); err != nil {
return nil, fmt.Errorf("subnet %d deny list: %w", sn.ID, err)
}
if len(sn.Allow) == 0 {
sn.Allow = nil
}
if len(sn.Deny) == 0 {
sn.Deny = nil
}
st.Subnets = append(st.Subnets, sn)
}
if err := rows.Err(); err != nil {
@@ -146,10 +158,12 @@ func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
if err := json.Unmarshal(c.After, &sn); err != nil {
return err
}
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?)
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, pool_order, address_order, lease_time, options, access, allow_list, deny_list, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools,
pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
pool_order = excluded.pool_order, address_order = excluded.address_order, lease_time = excluded.lease_time, options = excluded.options,
access = excluded.access, allow_list = excluded.allow_list, deny_list = excluded.deny_list, disabled = excluded.disabled, comment = excluded.comment`,
sn.ID, sn.Name, sn.Interface, sn.Prefix.String(), jsonText(nonNil(sn.Pools)), sn.PoolOrder, sn.AddressOrder, sn.LeaseTime, jsonText(nonNilOpts(sn.Options)),
sn.Access, jsonText(nonNilMatches(sn.Allow)), jsonText(nonNilMatches(sn.Deny)), boolInt(sn.Disabled), sn.Comment)
return err
case model.KindReservation:
if c.After == nil {
@@ -176,6 +190,13 @@ func nonNil(p []model.Pool) []model.Pool {
return p
}
func nonNilMatches(m []model.ClientMatch) []model.ClientMatch {
if m == nil {
return []model.ClientMatch{}
}
return m
}
func nonNilOpts(o []model.OptionValue) []model.OptionValue {
if o == nil {
return []model.OptionValue{}
+6
View File
@@ -210,6 +210,12 @@ ALTER TABLE subnets ADD COLUMN address_order TEXT NOT NULL DEFAULT '';
// 6: a reservation's own lease time.
`
ALTER TABLE reservations ADD COLUMN lease_time INTEGER NOT NULL DEFAULT 0;
`,
// 7: who a subnet answers: its access mode and allow and deny lists.
`
ALTER TABLE subnets ADD COLUMN access TEXT NOT NULL DEFAULT '';
ALTER TABLE subnets ADD COLUMN allow_list TEXT NOT NULL DEFAULT '[]';
ALTER TABLE subnets ADD COLUMN deny_list TEXT NOT NULL DEFAULT '[]';
`,
}
+35
View File
@@ -164,6 +164,7 @@ func (c *checker) subnets(st *model.State, env *Env) {
c.leaseTime(s.LeaseTime, obj)
}
c.pools(s, obj)
c.access(st, s, obj)
c.options(s.Options, obj)
c.routers(s, st.Settings.Options, obj)
if env != nil && !s.Disabled && s.Interface != "" {
@@ -206,6 +207,40 @@ func (c *checker) pools(s *model.Subnet, obj string) {
}
}
// access checks a subnet's access mode and lists.
func (c *checker) access(st *model.State, s *model.Subnet, obj string) {
if s.Access != "" && s.Access != model.AccessDenyList && s.Access != model.AccessAllowList {
c.add(Error, "access_mode", obj, "the access %q is not deny-list or allow-list", s.Access)
}
for _, list := range []struct {
name string
entries []model.ClientMatch
}{{"allow", s.Allow}, {"deny", s.Deny}} {
seen := map[string]bool{}
for _, m := range list.entries {
if norm, err := model.ParseClientMatch(m.Match); err != nil || norm != m.Match {
c.add(Error, "access_entry", obj, "the %s list entry %q is not a MAC address (aa:bb:cc:dd:ee:ff), a prefix (aa:bb:cc:*) or a client ID (id 01aabbccddeeff)", list.name, m.Match)
} else if seen[m.Match] {
c.add(Error, "access_entry_duplicate", obj, "%s is on the %s list twice", m.Match, list.name)
}
seen[m.Match] = true
}
}
reserved := 0
for _, r := range st.Reservations {
if r.Disabled || !s.Prefix.Contains(r.IP) {
continue
}
reserved++
if ok, _ := s.Admits(r.MAC, r.ClientID, true); !ok {
c.add(Warning, "reservation_denied", obj, "the client of reservation %s is on the deny list: it gets no address", r.Label())
}
}
if s.Access == model.AccessAllowList && len(s.Allow) == 0 && reserved == 0 && !s.Disabled {
c.add(Warning, "allow_list_empty", obj, "the subnet answers only clients on its allow list or with a reservation, and it has neither: nobody gets an address")
}
}
// routers warns about a gateway outside the subnet: clients can't reach it.
func (c *checker) routers(s *model.Subnet, global []model.OptionValue, obj string) {
val := ""
+43 -5
View File
@@ -1,24 +1,52 @@
// Activity: the latest DHCP messages, refreshed every few seconds.
import { h, mount, input, select, debounce } from "../ui.js";
import { get } from "../api.js";
import { h, mount, input, select, debounce, toast, modal, errorBox } from "../ui.js";
import { get, change, can } from "../api.js";
import { onLeave } from "../app.js";
import { subnetLabel } from "../widgets.js";
// allowClient puts a refused client on its subnet's allow list.
async function allowClient(e) {
try {
const s = await get("/subnets/" + e.subnet_id);
if ((s.allow || []).some((m) => m.match === e.mac)) { toast(e.mac + " is already allowed on " + subnetLabel(s)); return; }
const allow = [...(s.allow || []), { match: e.mac, comment: "" }];
await change("PATCH", "/subnets/" + e.subnet_id, { allow });
toast(e.mac + " is allowed on " + subnetLabel(s) + "; it gets an address when it next asks");
} catch (err) { modal("Not allowed", errorBox(err)); }
}
// details shows a message's header fields and options in words.
function details(e) {
if (!e) return h("div", { class: "muted small" }, "Loading…");
if (e.error) return h("div", { class: "msg err" }, e.error);
const who = e.direction === "in" ? "Received from the client" : "Sent by Steward";
return h("div", { class: "event-detail" },
h("div", { class: "muted small" }, who),
h("table", { class: "kv" }, h("tbody", null,
e.fields.map((f) => h("tr", null, h("th", null, f.name), h("td", { class: "mono" }, f.value))),
(e.options || []).map((o) => h("tr", null, h("th", null, o.name, h("span", { class: "muted small" }, " (" + o.code + ")")),
h("td", { class: "mono" }, o.value))))));
}
export async function render(el, params, query) {
const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]);
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" });
const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
const out = h("div");
// Messages opened to show their options, kept across refreshes.
const open = new Map(); // id -> the event with its details, or null while loading
let live = true;
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
const draw = async () => {
const evs = await get("/events", { mac: mac.value.trim(), subnet: subnetSel.value, interface: iface.value, limit: 300 });
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
h("thead", null, h("tr", null, ["", "Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
h("tbody", null, evs.map((e) => {
const t = e.type.replace(/^DHCP/, "");
return h("tr", null,
const row = h("tr", null,
h("td", null, e.direction ? h("button", { class: "small", title: open.has(e.id) ? "Hide the options" : "Show every option of this message",
onclick: () => toggle(e.id) }, open.has(e.id) ? "▾" : "▸") : ""),
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", { class: "mono" }, e.interface),
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)),
@@ -27,9 +55,19 @@ export async function render(el, params, query) {
mac.value.trim() ? null : h("button", { class: "small reveal", title: "Show only this client's messages", onclick: () => { mac.value = e.mac; draw(); } }, "Only this")] : ""),
h("td", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""),
h("td", { class: "detail" }, e.detail || ""));
h("td", { class: "detail" }, e.detail || "",
e.type === "DENIED" && e.subnet_id && e.mac && (e.detail || "").includes("allow list") && can("config", true)
? [" ", h("button", { class: "small", title: "Put this client on the subnet's allow list", onclick: () => allowClient(e) }, "Allow")] : null));
return open.has(e.id) ? [row, h("tr", { class: "event-details" }, h("td"), h("td", { colspan: 7 }, details(open.get(e.id))))] : row;
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : ".")));
};
const toggle = async (id) => {
if (open.has(id)) { open.delete(id); draw(); return; }
open.set(id, null);
draw();
try { open.set(id, await get("/events/" + id)); } catch (e) { open.set(id, { error: e.message }); }
draw();
};
mac.addEventListener("input", debounce(draw, 300));
subnetSel.addEventListener("change", draw);
iface.addEventListener("change", draw);
+54 -3
View File
@@ -1,12 +1,12 @@
// A subnet's page: its leases, its reservations and its settings.
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js";
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox, select, field } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js";
import { leasesTable } from "./leases.js";
import { reservationsTable, reservationDialog } from "./reservations.js";
import { rerender } from "../app.js";
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["settings", "Settings"]];
const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["access", "Access"], ["settings", "Settings"]];
export async function render(el, params) {
const id = params.id;
@@ -21,7 +21,8 @@ export async function render(el, params) {
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none",
orderText(s) ? " · " + orderText(s) : "",
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default",
s.access === "allow-list" ? [" · ", badge("allow-list", "accent")] : "")),
h("div", { class: "subnet-usage" }, usageBar(s.usage)),
can("config", true) ? h("div", { class: "actions" }, h("button", {
title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay",
@@ -31,9 +32,59 @@ export async function render(el, params) {
body);
if (tab === "leases") await leasesTab(body, s);
else if (tab === "reservations") await reservationsTab(body, s);
else if (tab === "access") accessTab(body, s);
else await settingsTab(body, s);
}
// matchList edits an allow or deny list: entries with a comment each.
function matchList(entries, placeholder) {
const rows = h("div", { class: "pool-rows" });
let list = [];
const addRow = (m) => {
const it = { match: input({ value: m ? m.match : "", placeholder, class: "mono" }), comment: input({ value: m ? m.comment || "" : "", placeholder: "what it is (optional)" }) };
it.row = h("div", { class: "pool-row" }, it.match, it.comment,
h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
list.push(it);
rows.append(it.row);
return it.match;
};
for (const m of entries || []) addRow(m);
const box = h("div", null, rows, h("button", { type: "button", class: "small", onclick: () => addRow(null).focus() }, "+ Add"));
box.value = () => list.map((it) => ({ match: it.match.value.trim(), comment: it.comment.value.trim() })).filter((m) => m.match);
return box;
}
// accessTab: who the subnet answers.
function accessTab(el, s) {
const mode = select([["deny-list", "Deny-list: every client except those denied"], ["allow-list", "Allow-list: only clients allowed or with a reservation"]], s.access || "deny-list");
const ph = "aa:bb:cc:dd:ee:ff, aa:bb:cc:* or id 01aabb…";
const allow = matchList(s.allow, ph), deny = matchList(s.deny, ph);
// The allow list only counts in an allow-list subnet.
const sync = () => allow.classList.toggle("dimmed", mode.value !== "allow-list");
mode.addEventListener("change", sync);
const err = h("div");
const save = async (dry) => {
mount(err);
try {
const r = await change("PATCH", "/subnets/" + s.id, { access: mode.value, allow: allow.value(), deny: deny.value() }, { query: dry ? { dry_run: 1 } : undefined });
if (!dry && r.applied) rerender();
} catch (e) { mount(err, errorBox(e)); }
};
const form = h("div", { class: "form-grid" },
field("Who gets an address", mode),
field("Allow list", allow, "Used by an allow-list subnet. Clients with a reservation in the subnet are allowed too."),
field("Deny list", deny, "Never answered, in either mode, even with a reservation."));
const write = can("config", true);
mount(el, h("div", { class: "panel" },
h("p", { class: "muted" }, "Entries are a MAC address, a prefix for one maker's devices (aa:bb:cc:*) or a client identifier (id 01aabb…). A refused client gets no answer, and shows as DENIED in Activity; one with a lease gets a NAK when it next renews. Phones use a private MAC address per network unless told not to."),
form, err,
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check")) : null));
if (!write) readOnly(el.querySelector(".panel"));
sync();
}
export async function toggleSubnet(s) {
if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return;
try {
+7
View File
@@ -346,6 +346,11 @@ button.chip:hover { background: var(--accent-soft); border-color: var(--accent);
/* Activity: one line per message. */
table.activity td { white-space: nowrap; }
table.activity td.detail { white-space: normal; color: var(--muted); }
table.activity tr.event-details > td { white-space: normal; background: var(--bg); }
.event-detail table.kv { border-collapse: collapse; margin: 4px 0; }
.event-detail table.kv th { text-align: left; font-weight: normal; color: var(--muted); padding: 2px 16px 2px 0; vertical-align: top; white-space: nowrap;
text-transform: none; letter-spacing: normal; font-size: inherit; background: none; border: 0; }
.event-detail table.kv td { padding: 2px 0; white-space: normal; word-break: break-word; background: none; border: 0; }
.type-OFFER, .type-ACK { color: var(--ok); }
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
.type-RELEASE { color: var(--muted); }
@@ -355,3 +360,5 @@ input:disabled, select:disabled, textarea:disabled { background: var(--info-soft
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; }
pre.wrap { white-space: pre-wrap; word-break: break-all; font-size: 12px; }
.dimmed { opacity: 0.55; }