Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
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>
This commit is contained in:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -0,0 +1,123 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Grant gives access to one resource (see internal/auth).
|
||||
type Grant struct {
|
||||
Resource string `json:"resource"` // config, leases, audit, *
|
||||
Access string `json:"access"` // read or write (write implies read)
|
||||
}
|
||||
|
||||
// User is a person or a service account.
|
||||
type User struct {
|
||||
ID int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
PasswordHash string `json:"-"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
Admin bool `json:"admin"`
|
||||
Grants []Grant `json:"grants"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Comment string `json:"comment"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
}
|
||||
|
||||
// Token is an API token. The secret is shown once, when it is made; only
|
||||
// its hash is stored.
|
||||
type Token struct {
|
||||
ID int64 `json:"id"`
|
||||
UserID int64 `json:"user_id"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Name string `json:"name"`
|
||||
PublicID string `json:"public_id"`
|
||||
SecretHash string `json:"-"`
|
||||
// Grants restrict the token to part of its user's permissions; nil
|
||||
// means all of them.
|
||||
Grants []Grant `json:"grants"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
|
||||
LastUsedIP string `json:"last_used_ip,omitempty"`
|
||||
RevokedAt *time.Time `json:"revoked_at,omitempty"`
|
||||
}
|
||||
|
||||
// Session is a web UI login.
|
||||
type Session struct {
|
||||
IDHash string `json:"-"`
|
||||
UserID int64 `json:"user_id"`
|
||||
CSRFToken string `json:"-"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
LastSeenAt time.Time `json:"last_seen_at"`
|
||||
IP string `json:"ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
}
|
||||
|
||||
// AuditEvent records an action: logins, users and tokens, changes, lease
|
||||
// deletions.
|
||||
type AuditEvent struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Auth string `json:"auth,omitempty"` // session, token, socket
|
||||
TokenID int64 `json:"token_id,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Action string `json:"action"`
|
||||
Object string `json:"object,omitempty"`
|
||||
Result string `json:"result"` // success, failure, denied
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Object kinds of changes.
|
||||
const (
|
||||
KindSettings = "settings"
|
||||
KindSubnet = "subnet"
|
||||
KindReservation = "reservation"
|
||||
)
|
||||
|
||||
// Change is one object's change: Before is nil for a create, After is nil
|
||||
// for a delete. Settings have ID 0.
|
||||
type Change struct {
|
||||
Kind string `json:"kind"`
|
||||
ID int64 `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Before json.RawMessage `json:"before,omitempty"`
|
||||
After json.RawMessage `json:"after,omitempty"`
|
||||
}
|
||||
|
||||
// Action describes a change in a word.
|
||||
func (c Change) Action() string {
|
||||
switch {
|
||||
case c.Before == nil:
|
||||
return "create"
|
||||
case c.After == nil:
|
||||
return "delete"
|
||||
}
|
||||
return "update"
|
||||
}
|
||||
|
||||
// Inverse is the change that undoes c.
|
||||
func (c Change) Inverse() Change {
|
||||
c.Before, c.After = c.After, c.Before
|
||||
return c
|
||||
}
|
||||
|
||||
// HistoryEntry is a saved configuration change.
|
||||
type HistoryEntry struct {
|
||||
ID int64 `json:"id"`
|
||||
Time time.Time `json:"time"`
|
||||
UserID int64 `json:"user_id,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Via string `json:"via"` // web, cli, api
|
||||
Description string `json:"description"`
|
||||
Changes []Change `json:"changes"`
|
||||
// RevertOf is the entry this one undid; RevertedBy the entry that
|
||||
// undid this one.
|
||||
RevertOf int64 `json:"revert_of,omitempty"`
|
||||
RevertedBy int64 `json:"reverted_by,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
// Package model holds Steward's data: the configuration (State), leases,
|
||||
// users and tokens, the history of changes and the audit log.
|
||||
package model
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Defaults for settings that are 0 or unset.
|
||||
const (
|
||||
DefaultLeaseTime = 12 * 60 * 60 // seconds
|
||||
DefaultPingTimeout = 500 // milliseconds
|
||||
DefaultDeclineHold = 24 * 60 * 60 // seconds
|
||||
MinLeaseTime = 60
|
||||
MaxLeaseTime = 365 * 24 * 60 * 60
|
||||
)
|
||||
|
||||
// State is the whole configuration. It is small and changed as a whole
|
||||
// (internal/engine).
|
||||
type State struct {
|
||||
Settings Settings `json:"settings"`
|
||||
Subnets []*Subnet `json:"subnets"`
|
||||
Reservations []*Reservation `json:"reservations"`
|
||||
}
|
||||
|
||||
// Settings are the server-wide settings and the options every subnet
|
||||
// starts from.
|
||||
type Settings struct {
|
||||
// LeaseTime is the default lease time in seconds (0: DefaultLeaseTime).
|
||||
LeaseTime int `json:"lease_time"`
|
||||
// Options apply to every subnet; a subnet's or a reservation's own
|
||||
// options of the same code win.
|
||||
Options []OptionValue `json:"options"`
|
||||
// PingCheck pings an address before offering it, and skips it if
|
||||
// something answers. PingTimeout is how long to wait, in ms.
|
||||
PingCheck bool `json:"ping_check"`
|
||||
PingTimeout int `json:"ping_timeout_ms"`
|
||||
// DeclineHold is how long an address a client declined (or that
|
||||
// answered a ping) is held back, in seconds.
|
||||
DeclineHold int `json:"decline_hold"`
|
||||
// NotAuthoritative stops Steward from refusing (DHCPNAK) requests for
|
||||
// addresses that don't belong on the interface, for networks where
|
||||
// another DHCP server shares the segment. Off by default.
|
||||
NotAuthoritative bool `json:"not_authoritative"`
|
||||
}
|
||||
|
||||
// DefaultSettings are the settings of a new database.
|
||||
func DefaultSettings() Settings {
|
||||
return Settings{LeaseTime: DefaultLeaseTime, PingCheck: true, PingTimeout: DefaultPingTimeout, DeclineHold: DefaultDeclineHold, Options: []OptionValue{}}
|
||||
}
|
||||
|
||||
// EffectiveLeaseTime is the lease time with its default.
|
||||
func (s Settings) EffectiveLeaseTime() int {
|
||||
if s.LeaseTime > 0 {
|
||||
return s.LeaseTime
|
||||
}
|
||||
return DefaultLeaseTime
|
||||
}
|
||||
|
||||
// EffectivePingTimeout is the ping timeout with its default.
|
||||
func (s Settings) EffectivePingTimeout() time.Duration {
|
||||
ms := s.PingTimeout
|
||||
if ms <= 0 {
|
||||
ms = DefaultPingTimeout
|
||||
}
|
||||
return time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
|
||||
// EffectiveDeclineHold is the decline hold with its default.
|
||||
func (s Settings) EffectiveDeclineHold() time.Duration {
|
||||
sec := s.DeclineHold
|
||||
if sec <= 0 {
|
||||
sec = DefaultDeclineHold
|
||||
}
|
||||
return time.Duration(sec) * time.Second
|
||||
}
|
||||
|
||||
// OptionValue is an option set in the configuration: its code and its
|
||||
// value as text ("10.0.10.1, 10.0.10.2"). Name is the catalog's name,
|
||||
// filled in for display. Type is only stored for options the catalog
|
||||
// doesn't know (default hex).
|
||||
type OptionValue struct {
|
||||
Code int `json:"code"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Subnet is an IPv4 network served on one interface.
|
||||
type Subnet struct {
|
||||
ID int64 `json:"id"`
|
||||
// Name is a label for people ("office", "iot"); optional, unique.
|
||||
Name string `json:"name"`
|
||||
// Interface is the network interface the subnet is served on.
|
||||
Interface string `json:"interface"`
|
||||
Prefix netip.Prefix `json:"prefix"`
|
||||
Pools []Pool `json:"pools"`
|
||||
// LeaseTime overrides the settings' lease time (0: inherit).
|
||||
LeaseTime int `json:"lease_time"`
|
||||
Options []OptionValue `json:"options"`
|
||||
// Disabled subnets keep their configuration and leases but get no
|
||||
// answers.
|
||||
Disabled bool `json:"disabled"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// Label names the subnet in messages: "office (10.0.10.0/24)".
|
||||
func (s *Subnet) Label() string {
|
||||
if s.Name != "" {
|
||||
return s.Name + " (" + s.Prefix.String() + ")"
|
||||
}
|
||||
return s.Prefix.String()
|
||||
}
|
||||
|
||||
// Pool is a range of addresses handed out to any client.
|
||||
type Pool struct {
|
||||
Start netip.Addr `json:"start"`
|
||||
End netip.Addr `json:"end"`
|
||||
}
|
||||
|
||||
func (p Pool) String() string { return p.Start.String() + "-" + p.End.String() }
|
||||
|
||||
// Contains reports whether a is in the pool.
|
||||
func (p Pool) Contains(a netip.Addr) bool {
|
||||
return a.Is4() && p.Start.Compare(a) <= 0 && a.Compare(p.End) <= 0
|
||||
}
|
||||
|
||||
// Size is the number of addresses in the pool.
|
||||
func (p Pool) Size() int {
|
||||
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
|
||||
return 0
|
||||
}
|
||||
return int(U32(p.End)-U32(p.Start)) + 1
|
||||
}
|
||||
|
||||
// ParsePool reads "10.0.10.100-10.0.10.199" (spaces around the dash are
|
||||
// fine) or a prefix, "10.0.10.128/25".
|
||||
func ParsePool(s string) (Pool, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if a, b, ok := strings.Cut(s, "-"); ok {
|
||||
start, err1 := netip.ParseAddr(strings.TrimSpace(a))
|
||||
end, err2 := netip.ParseAddr(strings.TrimSpace(b))
|
||||
if err1 != nil || err2 != nil || !start.Is4() || !end.Is4() {
|
||||
return Pool{}, fmt.Errorf("pool %q: write it as FIRST-LAST, with IPv4 addresses", s)
|
||||
}
|
||||
if end.Less(start) {
|
||||
return Pool{}, fmt.Errorf("pool %q ends before it starts", s)
|
||||
}
|
||||
return Pool{Start: start, End: end}, nil
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(s)
|
||||
if err != nil || !pfx.Addr().Is4() {
|
||||
return Pool{}, fmt.Errorf("pool %q: write it as FIRST-LAST (10.0.10.100-10.0.10.199) or as a prefix", s)
|
||||
}
|
||||
pfx = pfx.Masked()
|
||||
return Pool{Start: pfx.Addr(), End: Last(pfx)}, nil
|
||||
}
|
||||
|
||||
// U32 is an IPv4 address as a number.
|
||||
func U32(a netip.Addr) uint32 {
|
||||
b := a.As4()
|
||||
return uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3])
|
||||
}
|
||||
|
||||
// FromU32 is the address of a number.
|
||||
func FromU32(v uint32) netip.Addr {
|
||||
return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
|
||||
}
|
||||
|
||||
// Last is the last address of a prefix (its broadcast address).
|
||||
func Last(p netip.Prefix) netip.Addr {
|
||||
p = p.Masked()
|
||||
return FromU32(U32(p.Addr()) | (1<<(32-p.Bits()) - 1))
|
||||
}
|
||||
|
||||
// Mask is the prefix's netmask.
|
||||
func Mask(p netip.Prefix) netip.Addr {
|
||||
return FromU32(^uint32(0) << (32 - p.Bits()))
|
||||
}
|
||||
|
||||
// Reservation gives a client a fixed address. The client is known by its
|
||||
// MAC address, or by its client identifier (option 61) when ClientID is
|
||||
// set. The subnet is the one whose prefix holds the address.
|
||||
type Reservation struct {
|
||||
ID int64 `json:"id"`
|
||||
MAC string `json:"mac,omitempty"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
IP netip.Addr `json:"ip"`
|
||||
// Hostname is sent to the client (option 12).
|
||||
Hostname string `json:"hostname"`
|
||||
Options []OptionValue `json:"options"`
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
|
||||
// Label names the reservation in messages.
|
||||
func (r *Reservation) Label() string {
|
||||
who := r.MAC
|
||||
if r.ClientID != "" {
|
||||
who = "client id " + r.ClientID
|
||||
}
|
||||
if r.Hostname != "" {
|
||||
return r.Hostname + " (" + r.IP.String() + ", " + who + ")"
|
||||
}
|
||||
return r.IP.String() + " (" + who + ")"
|
||||
}
|
||||
|
||||
// NormalizeMAC writes a MAC address as aa:bb:cc:dd:ee:ff. It accepts
|
||||
// colons, dashes, dots (Cisco) or no separators.
|
||||
func NormalizeMAC(s string) (string, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) == 12 && !strings.ContainsAny(s, ":-.") {
|
||||
var parts []string
|
||||
for i := 0; i < 12; i += 2 {
|
||||
parts = append(parts, s[i:i+2])
|
||||
}
|
||||
s = strings.Join(parts, ":")
|
||||
}
|
||||
hw, err := net.ParseMAC(s)
|
||||
if err != nil || len(hw) != 6 {
|
||||
return "", fmt.Errorf("%q is not a MAC address (write it as aa:bb:cc:dd:ee:ff)", s)
|
||||
}
|
||||
return hw.String(), nil
|
||||
}
|
||||
|
||||
// NormalizeClientID writes a client identifier as lower-case hex without
|
||||
// separators. It accepts hex with colons or dashes.
|
||||
func NormalizeClientID(s string) (string, error) {
|
||||
h := strings.ToLower(strings.NewReplacer(":", "", "-", "", " ", "").Replace(strings.TrimSpace(s)))
|
||||
if h == "" || len(h)%2 != 0 || strings.Trim(h, "0123456789abcdef") != "" {
|
||||
return "", fmt.Errorf("%q is not a client identifier in hex (01:aa:bb:cc:dd:ee:ff)", s)
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// SubnetByID finds a subnet.
|
||||
func (st *State) SubnetByID(id int64) *Subnet {
|
||||
for _, s := range st.Subnets {
|
||||
if s.ID == id {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SubnetFor returns the subnet whose prefix holds a (the first, in
|
||||
// order), or nil.
|
||||
func (st *State) SubnetFor(a netip.Addr) *Subnet {
|
||||
for _, s := range st.Subnets {
|
||||
if s.Prefix.Contains(a) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReservationByID finds a reservation.
|
||||
func (st *State) ReservationByID(id int64) *Reservation {
|
||||
for _, r := range st.Reservations {
|
||||
if r.ID == id {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindSubnet finds a subnet by ID, name or prefix.
|
||||
func (st *State) FindSubnet(ref string) *Subnet {
|
||||
ref = strings.TrimSpace(ref)
|
||||
var id int64
|
||||
if _, err := fmt.Sscan(ref, &id); err == nil && fmt.Sprint(id) == ref {
|
||||
if s := st.SubnetByID(id); s != nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
for _, s := range st.Subnets {
|
||||
if strings.EqualFold(s.Name, ref) && s.Name != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
if p, err := netip.ParsePrefix(ref); err == nil {
|
||||
for _, s := range st.Subnets {
|
||||
if s.Prefix == p.Masked() {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clone returns a deep copy.
|
||||
func (st *State) Clone() *State {
|
||||
b, err := json.Marshal(st)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
var out State
|
||||
if err := json.Unmarshal(b, &out); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// Sort puts subnets and reservations in display order: subnets by
|
||||
// interface then address, reservations by address.
|
||||
func (st *State) Sort() {
|
||||
sort.SliceStable(st.Subnets, func(i, j int) bool {
|
||||
a, b := st.Subnets[i], st.Subnets[j]
|
||||
if a.Interface != b.Interface {
|
||||
return a.Interface < b.Interface
|
||||
}
|
||||
return a.Prefix.Addr().Less(b.Prefix.Addr())
|
||||
})
|
||||
sort.SliceStable(st.Reservations, func(i, j int) bool {
|
||||
return st.Reservations[i].IP.Less(st.Reservations[j].IP)
|
||||
})
|
||||
}
|
||||
|
||||
// Interfaces lists the interfaces subnets are served on (enabled ones).
|
||||
func (st *State) Interfaces() []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, s := range st.Subnets {
|
||||
if !s.Disabled && s.Interface != "" && !seen[s.Interface] {
|
||||
seen[s.Interface] = true
|
||||
out = append(out, s.Interface)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Lease states.
|
||||
const (
|
||||
// LeaseActive: the client holds the address until End.
|
||||
LeaseActive = "active"
|
||||
// LeaseReleased: the client gave the address back. The row stays so
|
||||
// the client gets the same address next time, if it is still free.
|
||||
LeaseReleased = "released"
|
||||
// LeaseExpired: the lease ran out (the state is computed: an active
|
||||
// lease past its End).
|
||||
LeaseExpired = "expired"
|
||||
// LeaseDeclined: a client said the address is in use by someone else
|
||||
// (DHCPDECLINE), or it answered a ping. It isn't handed out until End.
|
||||
LeaseDeclined = "declined"
|
||||
)
|
||||
|
||||
// Lease is an address handed out, or held back.
|
||||
type Lease struct {
|
||||
IP netip.Addr `json:"ip"`
|
||||
SubnetID int64 `json:"subnet_id"`
|
||||
MAC string `json:"mac"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
// Hostname is the name the client sent (option 12 or 81).
|
||||
Hostname string `json:"hostname"`
|
||||
State string `json:"state"`
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
// Interface is where the client was last seen.
|
||||
Interface string `json:"interface"`
|
||||
Updated time.Time `json:"updated"`
|
||||
}
|
||||
|
||||
// StateAt is the lease's state at a time (an active lease past its end
|
||||
// has expired).
|
||||
func (l *Lease) StateAt(now time.Time) string {
|
||||
if l.State == LeaseActive && !now.Before(l.End) {
|
||||
return LeaseExpired
|
||||
}
|
||||
return l.State
|
||||
}
|
||||
|
||||
// ActiveAt reports whether the lease holds its address at a time.
|
||||
func (l *Lease) ActiveAt(now time.Time) bool {
|
||||
return l.State == LeaseActive && now.Before(l.End)
|
||||
}
|
||||
|
||||
// HeldAt reports whether the address can't be handed out to another
|
||||
// client at a time: an active or declined lease that hasn't ended.
|
||||
func (l *Lease) HeldAt(now time.Time) bool {
|
||||
return (l.State == LeaseActive || l.State == LeaseDeclined) && now.Before(l.End)
|
||||
}
|
||||
|
||||
// Owns reports whether the lease belongs to a client: by client
|
||||
// identifier when both have one, else by MAC address.
|
||||
func (l *Lease) Owns(mac, clientID string) bool {
|
||||
if l.ClientID != "" && clientID != "" {
|
||||
return l.ClientID == clientID
|
||||
}
|
||||
return l.MAC != "" && l.MAC == mac
|
||||
}
|
||||
Reference in new issue
Block a user