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,246 @@
|
||||
// Package api holds the JSON shapes of Steward's REST API, shared by the
|
||||
// server and the Go client (and so the CLI).
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"steward/internal/model"
|
||||
"steward/internal/validate"
|
||||
"steward/internal/version"
|
||||
)
|
||||
|
||||
// Prefix starts every API path.
|
||||
const Prefix = "/api/v1"
|
||||
|
||||
// ErrorBody is every error response.
|
||||
type ErrorBody struct {
|
||||
Error ErrorInfo `json:"error"`
|
||||
}
|
||||
|
||||
// ErrorInfo describes an error.
|
||||
type ErrorInfo struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
// Result is the preview of a refused change.
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// Whoami describes the caller.
|
||||
type Whoami struct {
|
||||
User *model.User `json:"user"`
|
||||
Admin bool `json:"admin"`
|
||||
Method string `json:"method"`
|
||||
Permissions []string `json:"permissions"`
|
||||
CSRFToken string `json:"csrf_token,omitempty"`
|
||||
Build version.Build `json:"build"`
|
||||
}
|
||||
|
||||
// Login is the login request.
|
||||
type Login struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// PasswordChange changes the caller's password.
|
||||
type PasswordChange struct {
|
||||
Current string `json:"current"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// OptionInput sets an option: by name ("routers") or number ("43"), with
|
||||
// a type for numbers the catalog doesn't know (default hex).
|
||||
type OptionInput struct {
|
||||
Option string `json:"option"`
|
||||
Value string `json:"value"`
|
||||
Type string `json:"type,omitempty"`
|
||||
}
|
||||
|
||||
// SettingsInput changes the settings (unset fields stay).
|
||||
type SettingsInput struct {
|
||||
LeaseTime *int `json:"lease_time,omitempty"`
|
||||
Options *[]OptionInput `json:"options,omitempty"`
|
||||
PingCheck *bool `json:"ping_check,omitempty"`
|
||||
PingTimeout *int `json:"ping_timeout_ms,omitempty"`
|
||||
DeclineHold *int `json:"decline_hold,omitempty"`
|
||||
NotAuthoritative *bool `json:"not_authoritative,omitempty"`
|
||||
}
|
||||
|
||||
// SubnetInput creates or changes a subnet (unset fields stay). Pools are
|
||||
// "FIRST-LAST" or prefixes.
|
||||
type SubnetInput struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Interface *string `json:"interface,omitempty"`
|
||||
Prefix *string `json:"prefix,omitempty"`
|
||||
Pools *[]string `json:"pools,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).
|
||||
type ReservationInput struct {
|
||||
MAC *string `json:"mac,omitempty"`
|
||||
ClientID *string `json:"client_id,omitempty"`
|
||||
IP *string `json:"ip,omitempty"`
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Options *[]OptionInput `json:"options,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// Subnet is a subnet with its usage.
|
||||
type Subnet struct {
|
||||
*model.Subnet
|
||||
Usage Usage `json:"usage"`
|
||||
}
|
||||
|
||||
// Usage counts a subnet's addresses.
|
||||
type Usage struct {
|
||||
// PoolSize is the number of addresses in the pools; Leased of them
|
||||
// are held by clients, Declined held back.
|
||||
PoolSize int `json:"pool_size"`
|
||||
Leased int `json:"leased"`
|
||||
Declined int `json:"declined"`
|
||||
// Reservations in the subnet, and how many of them have a lease now.
|
||||
Reservations int `json:"reservations"`
|
||||
ReservationsLeased int `json:"reservations_leased"`
|
||||
}
|
||||
|
||||
// Reservation is a reservation with where it is.
|
||||
type Reservation struct {
|
||||
*model.Reservation
|
||||
SubnetID int64 `json:"subnet_id"`
|
||||
SubnetName string `json:"subnet"`
|
||||
// Lease is the reservation's current lease, if any.
|
||||
Lease *Lease `json:"lease,omitempty"`
|
||||
}
|
||||
|
||||
// Lease is a lease with its state now.
|
||||
type Lease struct {
|
||||
*model.Lease
|
||||
SubnetName string `json:"subnet"`
|
||||
// Reservation is the ID of the reservation for the address, if any.
|
||||
Reservation int64 `json:"reservation,omitempty"`
|
||||
// ReservedHostname is the reservation's host name.
|
||||
ReservedHostname string `json:"reserved_hostname,omitempty"`
|
||||
}
|
||||
|
||||
// MutationResponse answers a configuration change.
|
||||
type MutationResponse struct {
|
||||
Applied bool `json:"applied"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
HistoryID int64 `json:"history_id,omitempty"`
|
||||
Changes []model.Change `json:"changes"`
|
||||
Warnings []validate.Problem `json:"warnings,omitempty"`
|
||||
// Object is the created or changed object.
|
||||
Object json.RawMessage `json:"object,omitempty"`
|
||||
}
|
||||
|
||||
// HostInterface is one of the server's network interfaces.
|
||||
type HostInterface struct {
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
// Subnets served on it.
|
||||
Subnets []int64 `json:"subnets"`
|
||||
}
|
||||
|
||||
// InterfaceStatus says whether DHCP is served on an interface.
|
||||
type InterfaceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Listening bool `json:"listening"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
Addresses []string `json:"addresses"`
|
||||
Subnets []int64 `json:"subnets"`
|
||||
}
|
||||
|
||||
// Status is the dashboard's overview.
|
||||
type Status struct {
|
||||
Build version.Build `json:"build"`
|
||||
Started time.Time `json:"started"`
|
||||
Interfaces []InterfaceStatus `json:"interfaces"`
|
||||
Subnets []Subnet `json:"subnets"`
|
||||
Problems []validate.Problem `json:"problems"`
|
||||
// Leases counts leases by state.
|
||||
Leases map[string]int `json:"leases"`
|
||||
// Messages counts DHCP messages since the start, by type.
|
||||
Messages map[string]uint64 `json:"messages"`
|
||||
// PingProblem says why addresses can't be pinged before an offer.
|
||||
PingProblem string `json:"ping_problem,omitempty"`
|
||||
Database string `json:"database"`
|
||||
}
|
||||
|
||||
// TokenInput creates a token.
|
||||
type TokenInput struct {
|
||||
Name string `json:"name"`
|
||||
// Grants ("leases:read") or a role restrict the token; none: all of
|
||||
// the user's permissions.
|
||||
Grants []string `json:"grants,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
ExpiresIn string `json:"expires_in,omitempty"` // 90d, 720h
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// Token is a token; Secret is only set when it is created.
|
||||
type Token struct {
|
||||
*model.Token
|
||||
GrantStrings []string `json:"grant_strings"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
}
|
||||
|
||||
// UserInput creates or changes a user.
|
||||
type UserInput struct {
|
||||
Username *string `json:"username,omitempty"`
|
||||
Password *string `json:"password,omitempty"`
|
||||
Admin *bool `json:"admin,omitempty"`
|
||||
Grants *[]string `json:"grants,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Disabled *bool `json:"disabled,omitempty"`
|
||||
Comment *string `json:"comment,omitempty"`
|
||||
}
|
||||
|
||||
// User is a user with grants written out.
|
||||
type User struct {
|
||||
*model.User
|
||||
GrantStrings []string `json:"grant_strings"`
|
||||
}
|
||||
|
||||
// OptionDef is an option of the catalog.
|
||||
type OptionDef struct {
|
||||
Code int `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Help string `json:"help"`
|
||||
Common bool `json:"common,omitempty"`
|
||||
}
|
||||
|
||||
// Catalog lists the options Steward knows, the value types and the codes
|
||||
// that can't be set.
|
||||
type Catalog struct {
|
||||
Options []OptionDef `json:"options"`
|
||||
Types []string `json:"types"`
|
||||
Reserved map[int]string `json:"reserved"`
|
||||
Roles map[string][]string `json:"roles"`
|
||||
}
|
||||
|
||||
// Event is a DHCP message in or out.
|
||||
type Event struct {
|
||||
Time time.Time `json:"time"`
|
||||
Interface string `json:"interface"`
|
||||
Type string `json:"type"`
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ImportResult reports a Kea import.
|
||||
type ImportResult struct {
|
||||
MutationResponse
|
||||
Leases int `json:"leases"`
|
||||
Notes []string `json:"notes"`
|
||||
Skipped []string `json:"skipped"`
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
// Package auth handles passwords, API tokens, sessions and permissions.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/subtle"
|
||||
"encoding/base32"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// Argon2id parameters (OWASP: m=19 MiB, t=2, p=1).
|
||||
const (
|
||||
argonTime = 2
|
||||
argonMemory = 19 * 1024
|
||||
argonThreads = 1
|
||||
argonKeyLen = 32
|
||||
saltLen = 16
|
||||
)
|
||||
|
||||
// MinPasswordLength is the shortest accepted password.
|
||||
const MinPasswordLength = 8
|
||||
|
||||
// HashPassword returns an argon2id hash in PHC string format.
|
||||
func HashPassword(password string) (string, error) {
|
||||
if len(password) < MinPasswordLength {
|
||||
return "", fmt.Errorf("a password needs at least %d characters", MinPasswordLength)
|
||||
}
|
||||
salt := make([]byte, saltLen)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
|
||||
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads,
|
||||
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
|
||||
}
|
||||
|
||||
// dummyHash is checked when a user doesn't exist, so a login takes as
|
||||
// long whether or not the username is right.
|
||||
var dummyHash, _ = HashPassword("not-a-real-password")
|
||||
|
||||
// CheckPassword reports whether password matches hash. An empty hash never
|
||||
// matches (in the same time).
|
||||
func CheckPassword(hash, password string) bool {
|
||||
if hash == "" {
|
||||
hash = dummyHash
|
||||
password = "\x00" + password
|
||||
}
|
||||
parts := strings.Split(hash, "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return false
|
||||
}
|
||||
var m, t uint32
|
||||
var p uint8
|
||||
for _, kv := range strings.Split(parts[3], ",") {
|
||||
k, v, _ := strings.Cut(kv, "=")
|
||||
n, err := strconv.ParseUint(v, 10, 32)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch k {
|
||||
case "m":
|
||||
m = uint32(n)
|
||||
case "t":
|
||||
t = uint32(n)
|
||||
case "p":
|
||||
p = uint8(n)
|
||||
}
|
||||
}
|
||||
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err1 != nil || err2 != nil || m == 0 || t == 0 || p == 0 {
|
||||
return false
|
||||
}
|
||||
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
var b32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
|
||||
|
||||
// RandomString returns a random string with nbytes of entropy.
|
||||
func RandomString(nbytes int) string {
|
||||
b := make([]byte, nbytes)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b32.EncodeToString(b)
|
||||
}
|
||||
|
||||
// TokenPrefix starts every API token, so leaked ones are easy to spot.
|
||||
const TokenPrefix = "steward_"
|
||||
|
||||
// NewToken makes a token: the whole string to give out, its public ID
|
||||
// (stored in clear) and the hash of its secret.
|
||||
func NewToken() (token, publicID, secretHash string) {
|
||||
publicID = RandomString(10) // 16 characters
|
||||
secret := RandomString(20) // 32 characters
|
||||
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
|
||||
}
|
||||
|
||||
// ParseToken splits a token into its public ID and secret.
|
||||
func ParseToken(token string) (publicID, secret string, err error) {
|
||||
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
|
||||
if !ok {
|
||||
return "", "", errors.New("not a Steward token (they start with " + TokenPrefix + ")")
|
||||
}
|
||||
publicID, secret, ok = strings.Cut(rest, "_")
|
||||
if !ok || len(publicID) != 16 || len(secret) != 32 {
|
||||
return "", "", errors.New("malformed token")
|
||||
}
|
||||
return publicID, secret, nil
|
||||
}
|
||||
|
||||
// HashSecret hashes a high-entropy secret (token secrets, session IDs).
|
||||
func HashSecret(secret string) string {
|
||||
h := sha256.Sum256([]byte(secret))
|
||||
return hex.EncodeToString(h[:])
|
||||
}
|
||||
|
||||
// CheckSecret compares a secret with a stored hash in constant time.
|
||||
func CheckSecret(secret, hash string) bool {
|
||||
return subtle.ConstantTimeCompare([]byte(HashSecret(secret)), []byte(hash)) == 1
|
||||
}
|
||||
|
||||
// NewSessionID returns a session ID and its CSRF token.
|
||||
func NewSessionID() (id, csrf string) {
|
||||
return RandomString(32), RandomString(32)
|
||||
}
|
||||
|
||||
// Resources grants apply to.
|
||||
const (
|
||||
ResConfig = "config" // settings, subnets, reservations, history
|
||||
ResLeases = "leases" // leases and DHCP activity
|
||||
ResAudit = "audit" // the audit log
|
||||
ResAll = "*"
|
||||
)
|
||||
|
||||
// Resources lists them in display order.
|
||||
var Resources = []string{ResConfig, ResLeases, ResAudit}
|
||||
|
||||
// Access levels.
|
||||
const (
|
||||
Read = "read"
|
||||
Write = "write"
|
||||
)
|
||||
|
||||
// Roles are ready-made sets of grants.
|
||||
var Roles = map[string][]model.Grant{
|
||||
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResLeases, Access: Read}},
|
||||
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResLeases, Access: Write}, {Resource: ResAudit, Access: Read}},
|
||||
"leases": {{Resource: ResLeases, Access: Read}},
|
||||
}
|
||||
|
||||
// RoleNames lists the roles.
|
||||
func RoleNames() []string {
|
||||
var out []string
|
||||
for n := range Roles {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ParseGrant reads "resource:access" ("leases:read", "*:write").
|
||||
func ParseGrant(s string) (model.Grant, error) {
|
||||
res, acc, ok := strings.Cut(strings.ToLower(strings.TrimSpace(s)), ":")
|
||||
g := model.Grant{Resource: res, Access: acc}
|
||||
if !ok {
|
||||
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. leases:read", s)
|
||||
}
|
||||
return g, ValidateGrant(&g)
|
||||
}
|
||||
|
||||
// ValidateGrant checks a grant.
|
||||
func ValidateGrant(g *model.Grant) error {
|
||||
g.Resource = strings.ToLower(strings.TrimSpace(g.Resource))
|
||||
g.Access = strings.ToLower(strings.TrimSpace(g.Access))
|
||||
ok := g.Resource == ResAll
|
||||
for _, r := range Resources {
|
||||
ok = ok || g.Resource == r
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown resource %q (use %s or *)", g.Resource, strings.Join(Resources, ", "))
|
||||
}
|
||||
if g.Access != Read && g.Access != Write {
|
||||
return fmt.Errorf("access must be read or write, not %q", g.Access)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatGrant writes a grant as "resource:access".
|
||||
func FormatGrant(g model.Grant) string { return g.Resource + ":" + g.Access }
|
||||
|
||||
// Principal is who makes a request.
|
||||
type Principal struct {
|
||||
User *model.User
|
||||
Token *model.Token
|
||||
Method string // session, token, socket
|
||||
IP string
|
||||
Agent string
|
||||
}
|
||||
|
||||
// Name is the user's name.
|
||||
func (p *Principal) Name() string {
|
||||
if p == nil || p.User == nil {
|
||||
return ""
|
||||
}
|
||||
return p.User.Username
|
||||
}
|
||||
|
||||
// IsAdmin reports whether the principal has full rights: an
|
||||
// administrator, not through a restricted token.
|
||||
func (p *Principal) IsAdmin() bool {
|
||||
return p.User != nil && p.User.Admin && (p.Token == nil || p.Token.Grants == nil)
|
||||
}
|
||||
|
||||
// Can reports whether the principal may read (write false) or change
|
||||
// (write true) a resource.
|
||||
func (p *Principal) Can(resource string, write bool) bool {
|
||||
if p == nil || p.User == nil {
|
||||
return false
|
||||
}
|
||||
allowed := p.User.Admin || covers(p.User.Grants, resource, write)
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
allowed = allowed && covers(p.Token.Grants, resource, write)
|
||||
}
|
||||
return allowed
|
||||
}
|
||||
|
||||
func covers(gs []model.Grant, resource string, write bool) bool {
|
||||
for _, g := range gs {
|
||||
if (g.Resource == ResAll || g.Resource == resource) && (!write || g.Access == Write) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Permissions lists what the principal may do, for display.
|
||||
func (p *Principal) Permissions() []string {
|
||||
var out []string
|
||||
for _, r := range Resources {
|
||||
switch {
|
||||
case p.Can(r, true):
|
||||
out = append(out, r+":write")
|
||||
case p.Can(r, false):
|
||||
out = append(out, r+":read")
|
||||
}
|
||||
}
|
||||
if p.IsAdmin() {
|
||||
out = append(out, "users:write")
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Narrower reports whether every grant in a is covered by b.
|
||||
func Narrower(a, b []model.Grant) bool {
|
||||
for _, g := range a {
|
||||
if !covers(b, g.Resource, g.Access == Write) {
|
||||
if g.Resource != ResAll {
|
||||
return false
|
||||
}
|
||||
for _, r := range Resources {
|
||||
if !covers(b, r, g.Access == Write) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func TestPasswords(t *testing.T) {
|
||||
h, err := HashPassword("correct horse")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !CheckPassword(h, "correct horse") || CheckPassword(h, "wrong horse") || CheckPassword("", "") {
|
||||
t.Error("password check")
|
||||
}
|
||||
if _, err := HashPassword("short"); err == nil {
|
||||
t.Error("short password accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokens(t *testing.T) {
|
||||
tok, pid, hash := NewToken()
|
||||
gotPID, secret, err := ParseToken(tok)
|
||||
if err != nil || gotPID != pid || !CheckSecret(secret, hash) {
|
||||
t.Fatalf("token %q: %v", tok, err)
|
||||
}
|
||||
if _, _, err := ParseToken("jarl_abc_def"); err == nil {
|
||||
t.Error("a Jarl token was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissions(t *testing.T) {
|
||||
op := &model.User{Username: "op", Grants: Roles["operator"]}
|
||||
p := &Principal{User: op}
|
||||
if !p.Can(ResConfig, true) || !p.Can(ResAudit, false) || p.Can(ResAudit, true) || p.IsAdmin() {
|
||||
t.Error("operator")
|
||||
}
|
||||
admin := &model.User{Username: "admin", Admin: true}
|
||||
p = &Principal{User: admin, Token: &model.Token{Grants: []model.Grant{{Resource: ResLeases, Access: Read}}}}
|
||||
if !p.Can(ResLeases, false) || p.Can(ResLeases, true) || p.Can(ResConfig, false) || p.IsAdmin() {
|
||||
t.Error("restricted admin token")
|
||||
}
|
||||
if !Narrower(Roles["leases"], Roles["operator"]) || Narrower(Roles["operator"], Roles["viewer"]) {
|
||||
t.Error("narrower")
|
||||
}
|
||||
if _, err := ParseGrant("leases"); err == nil {
|
||||
t.Error("grant without access accepted")
|
||||
}
|
||||
if g, err := ParseGrant("Config:Write"); err != nil || g.Resource != "config" {
|
||||
t.Errorf("grant %v %v", g, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Package client is a small Go client for Steward's REST API, used by the
|
||||
// steward CLI.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/version"
|
||||
)
|
||||
|
||||
// Client talks to a Steward server.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
Token string
|
||||
HTTP *http.Client
|
||||
// DryRun previews changes instead of making them.
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// Options configure New.
|
||||
type Options struct {
|
||||
Server string // https://... or unix:///run/steward/steward.sock
|
||||
Token string
|
||||
Insecure bool // skip TLS certificate checks
|
||||
CACert string // PEM file with an extra CA to trust
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// New makes a client.
|
||||
func New(o Options) (*Client, error) {
|
||||
tr := http.DefaultTransport.(*http.Transport).Clone()
|
||||
base := strings.TrimSuffix(o.Server, "/")
|
||||
if path, ok := SocketPath(o.Server); ok {
|
||||
tr.Proxy = nil
|
||||
tr.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", path)
|
||||
}
|
||||
base = "http://stewardd"
|
||||
} else if u, err := url.Parse(o.Server); err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return nil, fmt.Errorf("invalid server URL %q (use e.g. https://dhcp.example.com:8067, or unix:///run/steward/steward.sock)", o.Server)
|
||||
}
|
||||
tc := &tls.Config{MinVersion: tls.VersionTLS12, InsecureSkipVerify: o.Insecure} //nolint:gosec // the user's choice
|
||||
if o.CACert != "" {
|
||||
pem, err := os.ReadFile(o.CACert)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil || pool == nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("no certificates in %s", o.CACert)
|
||||
}
|
||||
tc.RootCAs = pool
|
||||
}
|
||||
tr.TLSClientConfig = tc
|
||||
timeout := o.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 2 * time.Minute
|
||||
}
|
||||
return &Client{BaseURL: base, Token: o.Token, HTTP: &http.Client{Transport: tr, Timeout: timeout}}, nil
|
||||
}
|
||||
|
||||
// SocketPath returns the path of a unix:// URL.
|
||||
func SocketPath(server string) (string, bool) {
|
||||
path, ok := strings.CutPrefix(server, "unix://")
|
||||
if !ok || !strings.HasPrefix(path, "/") {
|
||||
return "", false
|
||||
}
|
||||
return path, true
|
||||
}
|
||||
|
||||
// Error is an API error.
|
||||
type Error struct {
|
||||
Status int
|
||||
Info api.ErrorInfo
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Info.Message != "" {
|
||||
return e.Info.Message
|
||||
}
|
||||
return fmt.Sprintf("HTTP %d", e.Status)
|
||||
}
|
||||
|
||||
// Do sends a request: body as JSON, the JSON answer into out (if not nil).
|
||||
// Changes get dry_run=1 when the client's DryRun is set.
|
||||
func (c *Client) Do(ctx context.Context, method, path string, query url.Values, body, out any) error {
|
||||
if c.DryRun && method != http.MethodGet {
|
||||
if query == nil {
|
||||
query = url.Values{}
|
||||
}
|
||||
query.Set("dry_run", "1")
|
||||
}
|
||||
u := c.BaseURL + api.Prefix + path
|
||||
if len(query) > 0 {
|
||||
u += "?" + query.Encode()
|
||||
}
|
||||
var rd io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, u, rd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "steward-cli/"+version.Short())
|
||||
if c.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
}
|
||||
resp, err := c.HTTP.Do(req)
|
||||
if err != nil {
|
||||
if c.BaseURL == "http://stewardd" {
|
||||
return fmt.Errorf("can't reach stewardd on its local socket: %w (is stewardd running? 'systemctl status steward')", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
e := &Error{Status: resp.StatusCode}
|
||||
var eb api.ErrorBody
|
||||
if json.Unmarshal(b, &eb) == nil && eb.Error.Message != "" {
|
||||
e.Info = eb.Error
|
||||
} else {
|
||||
e.Info.Message = fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
|
||||
}
|
||||
return e
|
||||
}
|
||||
if out != nil && len(b) > 0 {
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return fmt.Errorf("reading the answer: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get is Do for GET requests.
|
||||
func (c *Client) Get(ctx context.Context, path string, query url.Values, out any) error {
|
||||
return c.Do(ctx, http.MethodGet, path, query, nil, out)
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
// Package config loads stewardd's configuration file. It only says how
|
||||
// the daemon runs (where it listens, its files, logging); the DHCP
|
||||
// configuration itself is in the database.
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// DefaultPath is where stewardd looks for its configuration.
|
||||
const DefaultPath = "/etc/steward/steward.yaml"
|
||||
|
||||
// Config is the daemon configuration.
|
||||
type Config struct {
|
||||
// Listen are the web UI's and API's addresses.
|
||||
Listen []string `yaml:"listen"`
|
||||
TLS TLS `yaml:"tls"`
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
// Socket is a local unix socket for the API, where root and
|
||||
// stewardd's own user need no token ("" for none).
|
||||
Socket string `yaml:"socket"`
|
||||
Database string `yaml:"database"`
|
||||
Log Log `yaml:"log"`
|
||||
Session Session `yaml:"session"`
|
||||
Backup Backup `yaml:"backup"`
|
||||
}
|
||||
|
||||
// TLS is a certificate for HTTPS.
|
||||
type TLS struct {
|
||||
CertFile string `yaml:"cert_file"`
|
||||
KeyFile string `yaml:"key_file"`
|
||||
}
|
||||
|
||||
// Log configures logging to stderr (the journal under systemd).
|
||||
type Log struct {
|
||||
Level string `yaml:"level"` // debug, info, warn, error
|
||||
Format string `yaml:"format"` // text or json
|
||||
}
|
||||
|
||||
// Session configures web UI logins.
|
||||
type Session struct {
|
||||
Lifetime Duration `yaml:"lifetime"`
|
||||
IdleTimeout Duration `yaml:"idle_timeout"`
|
||||
// CookieSecure is auto (secure over HTTPS), always or never.
|
||||
CookieSecure string `yaml:"cookie_secure"`
|
||||
}
|
||||
|
||||
// Backup configures 'stewardd backup'.
|
||||
type Backup struct {
|
||||
Dir string `yaml:"dir"`
|
||||
Retention Duration `yaml:"retention"`
|
||||
// KeepAtLeast backups are kept however old they are.
|
||||
KeepAtLeast int `yaml:"keep_at_least"`
|
||||
}
|
||||
|
||||
// Duration is a time.Duration written as "15s" or "12h"; days ("30d")
|
||||
// work too.
|
||||
type Duration time.Duration
|
||||
|
||||
// UnmarshalYAML implements yaml.Unmarshaler.
|
||||
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
|
||||
var s string
|
||||
if err := n.Decode(&s); err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := ParseDuration(s)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*d = Duration(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParseDuration parses a Go duration, also accepting days ("30d").
|
||||
func ParseDuration(s string) (time.Duration, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if n, ok := strings.CutSuffix(s, "d"); ok {
|
||||
var days int
|
||||
if _, err := fmt.Sscanf(n, "%d", &days); err != nil || fmt.Sprint(days) != n {
|
||||
return 0, fmt.Errorf("invalid duration %q", s)
|
||||
}
|
||||
return time.Duration(days) * 24 * time.Hour, nil
|
||||
}
|
||||
return time.ParseDuration(s)
|
||||
}
|
||||
|
||||
// Default returns the defaults for Debian/Ubuntu.
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Listen: []string{"127.0.0.1:8067"},
|
||||
TrustedProxies: []string{},
|
||||
Socket: "/run/steward/steward.sock",
|
||||
Database: "/var/lib/steward/steward.db",
|
||||
Log: Log{Level: "info", Format: "text"},
|
||||
Session: Session{Lifetime: Duration(12 * time.Hour), IdleTimeout: Duration(2 * time.Hour), CookieSecure: "auto"},
|
||||
Backup: Backup{Dir: "/var/backups/steward", Retention: Duration(30 * 24 * time.Hour), KeepAtLeast: 7},
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the file at path over the defaults. A missing file is fine
|
||||
// when allowMissing is set.
|
||||
func Load(path string, allowMissing bool) (*Config, error) {
|
||||
c := Default()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) && allowMissing {
|
||||
return c, c.check()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
dec := yaml.NewDecoder(bytes.NewReader(b))
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(c); err != nil && err.Error() != "EOF" {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
if err := c.check(); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Config) check() error {
|
||||
if len(c.Listen) == 0 {
|
||||
return fmt.Errorf("listen: at least one address is needed")
|
||||
}
|
||||
if c.Socket != "" && !filepath.IsAbs(c.Socket) {
|
||||
return fmt.Errorf("socket must be an absolute path (or \"\" for none)")
|
||||
}
|
||||
if len(c.Socket) > 107 {
|
||||
return fmt.Errorf("socket: the path is %d bytes long; unix sockets allow at most 107", len(c.Socket))
|
||||
}
|
||||
if (c.TLS.CertFile == "") != (c.TLS.KeyFile == "") {
|
||||
return fmt.Errorf("tls: set both cert_file and key_file")
|
||||
}
|
||||
for _, p := range c.TrustedProxies {
|
||||
if _, err := ParsePrefix(p); err != nil {
|
||||
return fmt.Errorf("trusted_proxies: %v", err)
|
||||
}
|
||||
}
|
||||
if c.Database == "" || !filepath.IsAbs(c.Database) {
|
||||
return fmt.Errorf("database must be an absolute path")
|
||||
}
|
||||
if c.Backup.Dir != "" && !filepath.IsAbs(c.Backup.Dir) {
|
||||
return fmt.Errorf("backup.dir must be an absolute path")
|
||||
}
|
||||
switch c.Session.CookieSecure {
|
||||
case "auto", "always", "never":
|
||||
default:
|
||||
return fmt.Errorf("session.cookie_secure must be auto, always or never")
|
||||
}
|
||||
switch c.Log.Format {
|
||||
case "text", "json":
|
||||
default:
|
||||
return fmt.Errorf("log.format must be text or json")
|
||||
}
|
||||
switch c.Log.Level {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
return fmt.Errorf("log.level must be debug, info, warn or error")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ParsePrefix parses an address or a prefix.
|
||||
func ParsePrefix(s string) (netip.Prefix, error) {
|
||||
if strings.Contains(s, "/") {
|
||||
return netip.ParsePrefix(s)
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, err
|
||||
}
|
||||
return netip.PrefixFrom(a, a.BitLen()), nil
|
||||
}
|
||||
|
||||
// Proxies returns the trusted proxies.
|
||||
func (c *Config) Proxies() []netip.Prefix {
|
||||
var out []netip.Prefix
|
||||
for _, p := range c.TrustedProxies {
|
||||
if pf, err := ParsePrefix(p); err == nil {
|
||||
out = append(out, pf.Masked())
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Example is a commented example configuration with the defaults.
|
||||
const Example = `# Steward daemon configuration (/etc/steward/steward.yaml).
|
||||
# Every setting is optional; the values shown are the defaults. The DHCP
|
||||
# configuration (subnets, pools, reservations, options) is not here: it is
|
||||
# in the database, changed with the steward CLI or the web UI.
|
||||
|
||||
# Addresses the web UI and API listen on. Keep this off the internet:
|
||||
# bind to localhost or a management network, and use TLS (below) or a
|
||||
# TLS-terminating reverse proxy.
|
||||
listen:
|
||||
- "127.0.0.1:8067"
|
||||
|
||||
# HTTPS from a certificate you renew yourself (reread when it changes).
|
||||
# tls:
|
||||
# cert_file: /etc/steward/tls/fullchain.pem
|
||||
# key_file: /etc/steward/tls/privkey.pem
|
||||
|
||||
# Reverse proxies whose X-Forwarded-For and X-Forwarded-Proto are
|
||||
# believed.
|
||||
trusted_proxies: []
|
||||
|
||||
# A local socket for the API: root and stewardd's own user use it without
|
||||
# a token ('sudo steward status' just works). "" turns it off.
|
||||
socket: /run/steward/steward.sock
|
||||
|
||||
database: /var/lib/steward/steward.db
|
||||
|
||||
log:
|
||||
level: info # debug, info, warn or error
|
||||
format: text # text or json
|
||||
|
||||
session:
|
||||
lifetime: 12h
|
||||
idle_timeout: 2h
|
||||
cookie_secure: auto # auto (secure over HTTPS), always or never
|
||||
|
||||
# 'stewardd backup' (the daily steward-backup.timer) writes copies of the
|
||||
# database here and deletes those older than retention, keeping at least
|
||||
# keep_at_least.
|
||||
backup:
|
||||
dir: /var/backups/steward
|
||||
retention: 30d
|
||||
keep_at_least: 7
|
||||
`
|
||||
@@ -0,0 +1,42 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExampleIsTheDefaults(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "steward.yaml")
|
||||
os.WriteFile(path, []byte(Example), 0o644)
|
||||
c, err := Load(path, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(c, Default()) {
|
||||
t.Errorf("example differs from the defaults:\n%+v\n%+v", c, Default())
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrors(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"listen: []": "listen",
|
||||
"database: relative.db": "database",
|
||||
"session: {cookie_secure: x}": "cookie_secure",
|
||||
"tls: {cert_file: /a}": "tls",
|
||||
"no_such_setting: 1": "no_such_setting",
|
||||
"backup: {retention: 3x}": "duration",
|
||||
}
|
||||
for yml, want := range cases {
|
||||
path := filepath.Join(t.TempDir(), "s.yaml")
|
||||
os.WriteFile(path, []byte(yml), 0o644)
|
||||
if _, err := Load(path, false); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%q: %v", yml, err)
|
||||
}
|
||||
}
|
||||
if _, err := Load("/nonexistent/steward.yaml", true); err != nil {
|
||||
t.Errorf("missing file allowed: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,444 @@
|
||||
package dhcp4
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Value types of options, as written in Steward's configuration.
|
||||
const (
|
||||
TypeIP = "ip" // one IPv4 address
|
||||
TypeIPs = "ips" // a list of IPv4 addresses: "10.0.0.1, 10.0.0.2"
|
||||
TypeString = "string" // text
|
||||
TypeU8 = "uint8" // 0-255
|
||||
TypeU16 = "uint16" // 0-65535
|
||||
TypeU32 = "uint32" // 0-4294967295
|
||||
TypeI32 = "int32" // a signed number
|
||||
TypeBool = "bool" // true or false
|
||||
TypeDomains = "domains" // a list of domain names, in DNS wire format (option 119)
|
||||
TypeRoutes = "routes" // classless static routes (option 121): "10.20.0.0/16 via 10.0.10.1"
|
||||
TypeHex = "hex" // raw bytes: "01:02:0a" or "01020a"
|
||||
)
|
||||
|
||||
// Types lists the value types, for custom options.
|
||||
var Types = []string{TypeIP, TypeIPs, TypeString, TypeU8, TypeU16, TypeU32, TypeI32, TypeBool, TypeDomains, TypeRoutes, TypeHex}
|
||||
|
||||
// Def describes an option Steward knows.
|
||||
type Def struct {
|
||||
Code byte `json:"code"`
|
||||
Name string `json:"name"` // Kea's name for it, so imports and habits carry over
|
||||
Type string `json:"type"`
|
||||
// Help is a one-line description for the web UI and the CLI.
|
||||
Help string `json:"help"`
|
||||
// Common options are offered first in the web UI.
|
||||
Common bool `json:"common,omitempty"`
|
||||
}
|
||||
|
||||
// Catalog holds the options Steward knows by name. Any other option can
|
||||
// be set by its number, with a type (hex when not given).
|
||||
var Catalog = []Def{
|
||||
{Code: 2, Name: "time-offset", Type: TypeI32, Help: "the client's offset from UTC in seconds (obsolete; prefer tcode)"},
|
||||
{Code: 3, Name: "routers", Type: TypeIPs, Help: "default gateways, in order of preference", Common: true},
|
||||
{Code: 4, Name: "time-servers", Type: TypeIPs, Help: "RFC 868 time servers (rarely used; see ntp-servers)"},
|
||||
{Code: 6, Name: "domain-name-servers", Type: TypeIPs, Help: "DNS servers, in order of preference", Common: true},
|
||||
{Code: 7, Name: "log-servers", Type: TypeIPs, Help: "MIT-LCS UDP log servers"},
|
||||
{Code: 15, Name: "domain-name", Type: TypeString, Help: "the domain the client's host name belongs to", Common: true},
|
||||
{Code: 19, Name: "ip-forwarding", Type: TypeBool, Help: "whether the client should forward packets"},
|
||||
{Code: 23, Name: "default-ip-ttl", Type: TypeU8, Help: "the TTL for the client's outgoing packets"},
|
||||
{Code: 26, Name: "interface-mtu", Type: TypeU16, Help: "the MTU of the client's interface", Common: true},
|
||||
{Code: 28, Name: "broadcast-address", Type: TypeIP, Help: "the subnet's broadcast address"},
|
||||
{Code: 31, Name: "perform-router-discovery", Type: TypeBool, Help: "whether the client should solicit routers (RFC 1256)"},
|
||||
{Code: 42, Name: "ntp-servers", Type: TypeIPs, Help: "NTP servers, in order of preference", Common: true},
|
||||
{Code: 43, Name: "vendor-encapsulated-options", Type: TypeHex, Help: "vendor-specific information"},
|
||||
{Code: 44, Name: "netbios-name-servers", Type: TypeIPs, Help: "NetBIOS name servers (WINS)"},
|
||||
{Code: 46, Name: "netbios-node-type", Type: TypeU8, Help: "NetBIOS node type: 1 B, 2 P, 4 M, 8 H"},
|
||||
{Code: 47, Name: "netbios-scope", Type: TypeString, Help: "NetBIOS scope"},
|
||||
{Code: 60, Name: "vendor-class-identifier", Type: TypeString, Help: "the server's vendor class (PXE: PXEClient)"},
|
||||
{Code: 66, Name: "tftp-server-name", Type: TypeString, Help: "the TFTP server for network boot"},
|
||||
{Code: 67, Name: "boot-file-name", Type: TypeString, Help: "the file to boot from the TFTP server"},
|
||||
{Code: 69, Name: "smtp-server", Type: TypeIPs, Help: "mail servers"},
|
||||
{Code: 100, Name: "pcode", Type: TypeString, Help: "the time zone as a POSIX TZ string (EST5EDT,M3.2.0,M11.1.0)"},
|
||||
{Code: 101, Name: "tcode", Type: TypeString, Help: "the time zone by name (Europe/London)"},
|
||||
{Code: 114, Name: "v4-captive-portal", Type: TypeString, Help: "the captive portal's API URL (RFC 8910)"},
|
||||
{Code: 119, Name: "domain-search", Type: TypeDomains, Help: "domains to try when looking up short names", Common: true},
|
||||
{Code: 121, Name: "classless-static-route", Type: TypeRoutes, Help: "extra routes: \"10.20.0.0/16 via 10.0.10.1\" (clients that use it ignore routers, so add 0.0.0.0/0 too)"},
|
||||
{Code: 150, Name: "tftp-server-address", Type: TypeIPs, Help: "TFTP servers by address (Cisco phones and others)"},
|
||||
{Code: 252, Name: "wpad-url", Type: TypeString, Help: "web proxy auto-discovery URL"},
|
||||
}
|
||||
|
||||
// Reserved options are set by the server itself and can't be configured.
|
||||
var Reserved = map[byte]string{
|
||||
0: "pad",
|
||||
1: "the subnet mask comes from the subnet's prefix",
|
||||
12: "set the reservation's host name instead",
|
||||
50: "only clients send the requested address",
|
||||
51: "set the lease time instead",
|
||||
52: "the server handles option overload itself",
|
||||
53: "the message type is set by the server",
|
||||
54: "the server identifier is the interface's address",
|
||||
55: "only clients send the parameter request list",
|
||||
56: "the server writes its own messages",
|
||||
57: "only clients send the maximum message size",
|
||||
58: "the renewal time is half the lease time",
|
||||
59: "the rebinding time is 7/8 of the lease time",
|
||||
61: "only clients send a client identifier",
|
||||
82: "relay agent information is only added by relays",
|
||||
255: "end",
|
||||
}
|
||||
|
||||
var byCode, byName = func() (map[byte]Def, map[string]Def) {
|
||||
c, n := map[byte]Def{}, map[string]Def{}
|
||||
for _, d := range Catalog {
|
||||
c[d.Code], n[d.Name] = d, d
|
||||
}
|
||||
return c, n
|
||||
}()
|
||||
|
||||
// Lookup finds an option by name ("routers") or number ("3").
|
||||
func Lookup(nameOrCode string) (Def, bool) {
|
||||
s := strings.ToLower(strings.TrimSpace(nameOrCode))
|
||||
if d, ok := byName[s]; ok {
|
||||
return d, true
|
||||
}
|
||||
if n, err := strconv.Atoi(strings.TrimPrefix(s, "option-")); err == nil && n > 0 && n < 255 {
|
||||
if d, ok := byCode[byte(n)]; ok {
|
||||
return d, true
|
||||
}
|
||||
return Def{Code: byte(n), Name: "option-" + strconv.Itoa(n), Type: TypeHex}, true
|
||||
}
|
||||
return Def{}, false
|
||||
}
|
||||
|
||||
// ByCode returns the definition of an option code: the catalog's, or a
|
||||
// hex one for codes it doesn't know.
|
||||
func ByCode(code byte) Def {
|
||||
if d, ok := byCode[code]; ok {
|
||||
return d
|
||||
}
|
||||
return Def{Code: code, Name: "option-" + strconv.Itoa(int(code)), Type: TypeHex}
|
||||
}
|
||||
|
||||
// Known reports whether the catalog has the code.
|
||||
func Known(code byte) bool {
|
||||
_, ok := byCode[code]
|
||||
return ok
|
||||
}
|
||||
|
||||
// Names lists the catalog's names, sorted.
|
||||
func Names() []string {
|
||||
var out []string
|
||||
for n := range byName {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// Encode turns an option value written as text into its bytes, for the
|
||||
// given type.
|
||||
func Encode(typ, value string) ([]byte, error) {
|
||||
v := strings.TrimSpace(value)
|
||||
switch typ {
|
||||
case TypeIP:
|
||||
a, err := parseIP4(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return IP(a), nil
|
||||
case TypeIPs:
|
||||
items := splitList(v)
|
||||
if len(items) == 0 {
|
||||
return nil, errors.New("give at least one address")
|
||||
}
|
||||
var out []byte
|
||||
for _, s := range items {
|
||||
a, err := parseIP4(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, IP(a)...)
|
||||
}
|
||||
return out, nil
|
||||
case TypeString:
|
||||
if v == "" {
|
||||
return nil, errors.New("the value is empty")
|
||||
}
|
||||
if len(value) > 255 {
|
||||
return nil, errors.New("the text is longer than 255 bytes")
|
||||
}
|
||||
return []byte(value), nil
|
||||
case TypeU8, TypeU16, TypeU32:
|
||||
bits := map[string]int{TypeU8: 8, TypeU16: 16, TypeU32: 32}[typ]
|
||||
n, err := strconv.ParseUint(v, 10, bits)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a whole number from 0 to %d", v, uint64(1)<<bits-1)
|
||||
}
|
||||
switch bits {
|
||||
case 8:
|
||||
return []byte{byte(n)}, nil
|
||||
case 16:
|
||||
return binary.BigEndian.AppendUint16(nil, uint16(n)), nil
|
||||
}
|
||||
return U32(uint32(n)), nil
|
||||
case TypeI32:
|
||||
n, err := strconv.ParseInt(v, 10, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not a whole number from %d to %d", v, math.MinInt32, math.MaxInt32)
|
||||
}
|
||||
return U32(uint32(int32(n))), nil
|
||||
case TypeBool:
|
||||
switch strings.ToLower(v) {
|
||||
case "true", "yes", "on", "1":
|
||||
return []byte{1}, nil
|
||||
case "false", "no", "off", "0":
|
||||
return []byte{0}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%q is not true or false", v)
|
||||
case TypeDomains:
|
||||
items := splitList(v)
|
||||
if len(items) == 0 {
|
||||
return nil, errors.New("give at least one domain")
|
||||
}
|
||||
return encodeNames(items)
|
||||
case TypeRoutes:
|
||||
return encodeRoutes(v)
|
||||
case TypeHex:
|
||||
s := strings.NewReplacer(":", "", " ", "", "-", "").Replace(strings.TrimPrefix(strings.ToLower(v), "0x"))
|
||||
b, err := hex.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not hex bytes (write them as 01:02:0a or 01020a)", v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown option type %q (use one of %s)", typ, strings.Join(Types, ", "))
|
||||
}
|
||||
|
||||
// Decode turns option bytes into text, the way Encode reads it. Data that
|
||||
// doesn't fit the type is shown as hex.
|
||||
func Decode(typ string, b []byte) string {
|
||||
asHex := func() string {
|
||||
parts := make([]string, len(b))
|
||||
for i, c := range b {
|
||||
parts[i] = fmt.Sprintf("%02x", c)
|
||||
}
|
||||
return strings.Join(parts, ":")
|
||||
}
|
||||
switch typ {
|
||||
case TypeIP, TypeIPs:
|
||||
if len(b) == 0 || len(b)%4 != 0 || typ == TypeIP && len(b) != 4 {
|
||||
return asHex()
|
||||
}
|
||||
var parts []string
|
||||
for i := 0; i < len(b); i += 4 {
|
||||
parts = append(parts, addr4(b[i:]).String())
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
case TypeString:
|
||||
return string(b)
|
||||
case TypeU8:
|
||||
if len(b) == 1 {
|
||||
return strconv.Itoa(int(b[0]))
|
||||
}
|
||||
case TypeU16:
|
||||
if len(b) == 2 {
|
||||
return strconv.Itoa(int(binary.BigEndian.Uint16(b)))
|
||||
}
|
||||
case TypeU32:
|
||||
if len(b) == 4 {
|
||||
return strconv.FormatUint(uint64(binary.BigEndian.Uint32(b)), 10)
|
||||
}
|
||||
case TypeI32:
|
||||
if len(b) == 4 {
|
||||
return strconv.Itoa(int(int32(binary.BigEndian.Uint32(b))))
|
||||
}
|
||||
case TypeBool:
|
||||
if len(b) == 1 {
|
||||
return strconv.FormatBool(b[0] != 0)
|
||||
}
|
||||
case TypeDomains:
|
||||
if names, err := decodeNames(b); err == nil {
|
||||
return strings.Join(names, ", ")
|
||||
}
|
||||
case TypeRoutes:
|
||||
if s, err := decodeRoutes(b); err == nil {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return asHex()
|
||||
}
|
||||
|
||||
// Normalize rewrites a value in its canonical form ("10.0.0.1,10.0.0.2"
|
||||
// becomes "10.0.0.1, 10.0.0.2"), or reports why it is invalid.
|
||||
func Normalize(typ, value string) (string, error) {
|
||||
b, err := Encode(typ, value)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if typ == TypeString {
|
||||
return value, nil
|
||||
}
|
||||
return Decode(typ, b), nil
|
||||
}
|
||||
|
||||
func splitList(v string) []string {
|
||||
var out []string
|
||||
for _, s := range strings.FieldsFunc(v, func(r rune) bool { return r == ',' || r == ' ' || r == '\t' || r == '\n' }) {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseIP4(s string) (netip.Addr, error) {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(s))
|
||||
if err != nil || !a.Is4() {
|
||||
return netip.Addr{}, fmt.Errorf("%q is not an IPv4 address", s)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// encodeNames writes domain names in DNS wire format, without
|
||||
// compression (RFC 3397 allows but doesn't require it).
|
||||
func encodeNames(names []string) ([]byte, error) {
|
||||
var out []byte
|
||||
for _, n := range names {
|
||||
n = strings.TrimSuffix(strings.ToLower(n), ".")
|
||||
if n == "" {
|
||||
return nil, errors.New("empty domain name")
|
||||
}
|
||||
if len(n) > 253 {
|
||||
return nil, fmt.Errorf("domain %q is too long", n)
|
||||
}
|
||||
for _, l := range strings.Split(n, ".") {
|
||||
if l == "" || len(l) > 63 {
|
||||
return nil, fmt.Errorf("domain %q has an empty or over-long label", n)
|
||||
}
|
||||
for _, c := range l {
|
||||
if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
|
||||
return nil, fmt.Errorf("domain %q: %q isn't allowed in a domain name", n, c)
|
||||
}
|
||||
}
|
||||
out = append(out, byte(len(l)))
|
||||
out = append(out, l...)
|
||||
}
|
||||
out = append(out, 0)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// decodeNames reads DNS wire-format names, following compression
|
||||
// pointers within the data.
|
||||
func decodeNames(b []byte) ([]string, error) {
|
||||
var out []string
|
||||
for i := 0; i < len(b); {
|
||||
name, next, err := readName(b, i)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, name)
|
||||
i = next
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func readName(b []byte, i int) (string, int, error) {
|
||||
var labels []string
|
||||
next := -1
|
||||
for hops := 0; ; hops++ {
|
||||
if i >= len(b) || hops > 128 {
|
||||
return "", 0, errors.New("malformed domain name")
|
||||
}
|
||||
n := int(b[i])
|
||||
switch {
|
||||
case n == 0:
|
||||
if next < 0 {
|
||||
next = i + 1
|
||||
}
|
||||
return strings.Join(labels, "."), next, nil
|
||||
case n&0xc0 == 0xc0:
|
||||
if i+1 >= len(b) {
|
||||
return "", 0, errors.New("malformed compression pointer")
|
||||
}
|
||||
if next < 0 {
|
||||
next = i + 2
|
||||
}
|
||||
i = int(binary.BigEndian.Uint16(b[i:]) & 0x3fff)
|
||||
default:
|
||||
if i+1+n > len(b) {
|
||||
return "", 0, errors.New("domain label runs past the end")
|
||||
}
|
||||
labels = append(labels, string(b[i+1:i+1+n]))
|
||||
i += 1 + n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// encodeRoutes writes classless static routes (RFC 3442): for each, the
|
||||
// prefix length, the significant octets of the destination, the router.
|
||||
func encodeRoutes(v string) ([]byte, error) {
|
||||
var out []byte
|
||||
items := strings.Split(v, ",")
|
||||
n := 0
|
||||
for _, it := range items {
|
||||
it = strings.TrimSpace(it)
|
||||
if it == "" {
|
||||
continue
|
||||
}
|
||||
var dst, gw string
|
||||
if a, b, ok := strings.Cut(it, " via "); ok {
|
||||
dst, gw = a, b
|
||||
} else if a, b, ok := strings.Cut(it, " - "); ok { // Kea's form
|
||||
dst, gw = a, b
|
||||
} else {
|
||||
return nil, fmt.Errorf("route %q: write it as DESTINATION/LENGTH via ROUTER", it)
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(strings.TrimSpace(dst))
|
||||
if err != nil || !pfx.Addr().Is4() {
|
||||
return nil, fmt.Errorf("route %q: %q is not an IPv4 prefix", it, dst)
|
||||
}
|
||||
if pfx.Masked() != pfx {
|
||||
return nil, fmt.Errorf("route %q: %s has host bits set (the network is %s)", it, pfx, pfx.Masked())
|
||||
}
|
||||
router, err := parseIP4(gw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("route %q: %v", it, err)
|
||||
}
|
||||
a := pfx.Addr().As4()
|
||||
out = append(out, byte(pfx.Bits()))
|
||||
out = append(out, a[:(pfx.Bits()+7)/8]...)
|
||||
out = append(out, IP(router)...)
|
||||
n++
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, errors.New("give at least one route")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeRoutes(b []byte) (string, error) {
|
||||
var parts []string
|
||||
for i := 0; i < len(b); {
|
||||
bits := int(b[i])
|
||||
if bits > 32 {
|
||||
return "", errors.New("bad prefix length")
|
||||
}
|
||||
n := (bits + 7) / 8
|
||||
if i+1+n+4 > len(b) {
|
||||
return "", errors.New("route runs past the end")
|
||||
}
|
||||
var a [4]byte
|
||||
copy(a[:], b[i+1:i+1+n])
|
||||
pfx := netip.PrefixFrom(netip.AddrFrom4(a), bits)
|
||||
parts = append(parts, pfx.String()+" via "+addr4(b[i+1+n:]).String())
|
||||
i += 1 + n + 4
|
||||
}
|
||||
return strings.Join(parts, ", "), nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package dhcp4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
mac, _ := net.ParseMAC("aa:bb:cc:00:11:22")
|
||||
p := NewRequest(Discover, mac, 0x12345678)
|
||||
p.Flags = FlagBroadcast
|
||||
p.Set(OptHostName, []byte("laptop"))
|
||||
p.Set(OptParamRequest, []byte{1, 3, 6, 15})
|
||||
p.Set(OptClientID, append([]byte{1}, mac...))
|
||||
b := p.Marshal()
|
||||
if len(b) < MinLen {
|
||||
t.Fatalf("message is %d bytes, under %d", len(b), MinLen)
|
||||
}
|
||||
if p.Len() != len(b) {
|
||||
t.Errorf("Len() = %d, Marshal gave %d", p.Len(), len(b))
|
||||
}
|
||||
q, err := Parse(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.Type() != Discover || q.XID != 0x12345678 || !q.Broadcast() || q.MAC().String() != mac.String() {
|
||||
t.Errorf("parsed %+v", q)
|
||||
}
|
||||
if q.HostName() != "laptop" || q.ClientID() != "01aabbcc001122" {
|
||||
t.Errorf("host %q client id %q", q.HostName(), q.ClientID())
|
||||
}
|
||||
if !bytes.Equal(q.ParamRequest(), []byte{1, 3, 6, 15}) {
|
||||
t.Errorf("PRL %v", q.ParamRequest())
|
||||
}
|
||||
r := q.Reply(Offer)
|
||||
r.YIAddr = netip.MustParseAddr("10.0.10.57")
|
||||
if r.Op != BootReply || r.XID != q.XID || r.Type() != Offer || r.CHAddr != q.CHAddr {
|
||||
t.Errorf("reply %+v", r)
|
||||
}
|
||||
r2, err := Parse(r.Marshal())
|
||||
if err != nil || r2.YIAddr != r.YIAddr {
|
||||
t.Fatalf("reply round trip: %v %v", r2, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLongOptionsAreSplitAndJoined(t *testing.T) {
|
||||
p := NewRequest(Inform, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
|
||||
long := bytes.Repeat([]byte{7}, 600)
|
||||
p.Set(43, long)
|
||||
b := p.Marshal()
|
||||
q, err := Parse(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(q.Get(43), long) {
|
||||
t.Errorf("got %d bytes back, want 600", len(q.Get(43)))
|
||||
}
|
||||
if p.Len() != len(b) {
|
||||
t.Errorf("Len() = %d, Marshal gave %d", p.Len(), len(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverload(t *testing.T) {
|
||||
p := NewRequest(Request, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
|
||||
p.Set(OptOverload, []byte{1})
|
||||
b := p.Marshal()
|
||||
// Put option 12 in the file field (bytes 108..236).
|
||||
copy(b[108:], []byte{12, 3, 'p', 'c', '1', 255})
|
||||
q, err := Parse(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if q.HostName() != "pc1" || q.File != "" {
|
||||
t.Errorf("host %q file %q", q.HostName(), q.File)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseErrors(t *testing.T) {
|
||||
if _, err := Parse(make([]byte, 100)); err == nil {
|
||||
t.Error("short message accepted")
|
||||
}
|
||||
b := NewRequest(Discover, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1).Marshal()
|
||||
b[236] = 0
|
||||
if _, err := Parse(b); err == nil || !strings.Contains(err.Error(), "cookie") {
|
||||
t.Errorf("missing cookie: %v", err)
|
||||
}
|
||||
b = NewRequest(Discover, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1).Marshal()
|
||||
b[240+1] = 200 // message type option claims 200 bytes
|
||||
if _, err := Parse(b); err == nil {
|
||||
t.Error("overlong option accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostNameFromFQDN(t *testing.T) {
|
||||
p := NewRequest(Request, net.HardwareAddr{1, 2, 3, 4, 5, 6}, 1)
|
||||
wire, _ := encodeNames([]string{"desk.example.com"})
|
||||
p.Set(OptClientFQDN, append([]byte{0x04, 0, 0}, wire...))
|
||||
if got := p.HostName(); got != "desk" {
|
||||
t.Errorf("wire FQDN: %q", got)
|
||||
}
|
||||
p.Set(OptClientFQDN, append([]byte{0, 0, 0}, "tv.lan"...))
|
||||
if got := p.HostName(); got != "tv" {
|
||||
t.Errorf("ASCII FQDN: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecode(t *testing.T) {
|
||||
cases := []struct {
|
||||
typ, in, out string
|
||||
hex string
|
||||
}{
|
||||
{TypeIP, " 10.0.0.1 ", "10.0.0.1", "0a000001"},
|
||||
{TypeIPs, "10.0.0.1,10.0.0.2", "10.0.0.1, 10.0.0.2", "0a0000010a000002"},
|
||||
{TypeU8, "8", "8", "08"},
|
||||
{TypeU16, "1500", "1500", "05dc"},
|
||||
{TypeU32, "86400", "86400", "00015180"},
|
||||
{TypeI32, "-3600", "-3600", "fffff1f0"},
|
||||
{TypeBool, "yes", "true", "01"},
|
||||
{TypeHex, "01:02:0A", "01:02:0a", "01020a"},
|
||||
{TypeHex, "0x0102", "01:02", "0102"},
|
||||
{TypeDomains, "lan, Example.com.", "lan, example.com", "036c616e00076578616d706c6503636f6d00"},
|
||||
{TypeRoutes, "10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 - 10.0.10.1", "10.20.0.0/16 via 10.0.10.1, 0.0.0.0/0 via 10.0.10.1", "100a140a000a01000a000a01"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
b, err := Encode(c.typ, c.in)
|
||||
if err != nil {
|
||||
t.Errorf("%s %q: %v", c.typ, c.in, err)
|
||||
continue
|
||||
}
|
||||
if got := Decode(c.typ, b); got != c.out {
|
||||
t.Errorf("%s %q decodes as %q, want %q", c.typ, c.in, got, c.out)
|
||||
}
|
||||
if got := Decode(TypeHex, b); strings.ReplaceAll(got, ":", "") != c.hex {
|
||||
t.Errorf("%s %q encodes as %s, want %s", c.typ, c.in, got, c.hex)
|
||||
}
|
||||
}
|
||||
bad := []struct{ typ, in string }{
|
||||
{TypeIP, "10.0.0.256"}, {TypeIP, "::1"}, {TypeIPs, ""}, {TypeU8, "256"}, {TypeU16, "-1"},
|
||||
{TypeBool, "maybe"}, {TypeHex, "zz"}, {TypeDomains, "a..b"}, {TypeDomains, "bad!name"},
|
||||
{TypeRoutes, "10.20.0.0/16"}, {TypeRoutes, "10.20.0.1/16 via 10.0.0.1"}, {TypeString, ""}, {"nope", "x"},
|
||||
}
|
||||
for _, c := range bad {
|
||||
if _, err := Encode(c.typ, c.in); err == nil {
|
||||
t.Errorf("%s %q accepted", c.typ, c.in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCompressedNames(t *testing.T) {
|
||||
// "lan" then "example.lan" written with a pointer to the first "lan".
|
||||
b := []byte{3, 'l', 'a', 'n', 0, 7, 'e', 'x', 'a', 'm', 'p', 'l', 'e', 0xc0, 0}
|
||||
if got := Decode(TypeDomains, b); got != "lan, example.lan" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookup(t *testing.T) {
|
||||
if d, ok := Lookup("routers"); !ok || d.Code != 3 {
|
||||
t.Errorf("routers: %v %v", d, ok)
|
||||
}
|
||||
if d, ok := Lookup("42"); !ok || d.Name != "ntp-servers" {
|
||||
t.Errorf("42: %v %v", d, ok)
|
||||
}
|
||||
if d, ok := Lookup("224"); !ok || d.Type != TypeHex || d.Name != "option-224" {
|
||||
t.Errorf("224: %v %v", d, ok)
|
||||
}
|
||||
if _, ok := Lookup("no-such-option"); ok {
|
||||
t.Error("unknown name found")
|
||||
}
|
||||
for _, d := range Catalog {
|
||||
if _, ok := Reserved[d.Code]; ok {
|
||||
t.Errorf("%s (%d) is in the catalog and reserved", d.Name, d.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
// Package dhcp4 reads and writes DHCPv4 messages (RFC 2131) and their
|
||||
// options (RFC 2132), and holds the catalog of options Steward knows by
|
||||
// name. It does no I/O.
|
||||
package dhcp4
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Ports.
|
||||
const (
|
||||
ServerPort = 67
|
||||
ClientPort = 68
|
||||
)
|
||||
|
||||
// Op codes.
|
||||
const (
|
||||
BootRequest = 1
|
||||
BootReply = 2
|
||||
)
|
||||
|
||||
// MessageType is option 53.
|
||||
type MessageType byte
|
||||
|
||||
// Message types (RFC 2132 section 9.6).
|
||||
const (
|
||||
Discover MessageType = 1
|
||||
Offer MessageType = 2
|
||||
Request MessageType = 3
|
||||
Decline MessageType = 4
|
||||
Ack MessageType = 5
|
||||
Nak MessageType = 6
|
||||
Release MessageType = 7
|
||||
Inform MessageType = 8
|
||||
)
|
||||
|
||||
func (t MessageType) String() string {
|
||||
switch t {
|
||||
case Discover:
|
||||
return "DHCPDISCOVER"
|
||||
case Offer:
|
||||
return "DHCPOFFER"
|
||||
case Request:
|
||||
return "DHCPREQUEST"
|
||||
case Decline:
|
||||
return "DHCPDECLINE"
|
||||
case Ack:
|
||||
return "DHCPACK"
|
||||
case Nak:
|
||||
return "DHCPNAK"
|
||||
case Release:
|
||||
return "DHCPRELEASE"
|
||||
case Inform:
|
||||
return "DHCPINFORM"
|
||||
}
|
||||
return fmt.Sprintf("DHCP type %d", byte(t))
|
||||
}
|
||||
|
||||
// Option codes the server itself uses.
|
||||
const (
|
||||
OptPad = 0
|
||||
OptSubnetMask = 1
|
||||
OptRouters = 3
|
||||
OptDNSServers = 6
|
||||
OptHostName = 12
|
||||
OptDomainName = 15
|
||||
OptBroadcast = 28
|
||||
OptRequestedIP = 50
|
||||
OptLeaseTime = 51
|
||||
OptOverload = 52
|
||||
OptMessageType = 53
|
||||
OptServerID = 54
|
||||
OptParamRequest = 55
|
||||
OptMessage = 56
|
||||
OptMaxMessage = 57
|
||||
OptRenewalTime = 58
|
||||
OptRebindingTime = 59
|
||||
OptVendorClass = 60
|
||||
OptClientID = 61
|
||||
OptClientFQDN = 81
|
||||
OptEnd = 255
|
||||
)
|
||||
|
||||
// FlagBroadcast is the broadcast bit of the flags field: the client can't
|
||||
// receive unicast before it has an address.
|
||||
const FlagBroadcast = 0x8000
|
||||
|
||||
var magicCookie = []byte{99, 130, 83, 99}
|
||||
|
||||
// headerLen is the fixed part of a message, up to the magic cookie.
|
||||
const headerLen = 236
|
||||
|
||||
// MinLen is the shortest message to send: BOOTP's 300 bytes, which some
|
||||
// old clients and relays insist on.
|
||||
const MinLen = 300
|
||||
|
||||
// Option is one option: its code and its data.
|
||||
type Option struct {
|
||||
Code byte
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Packet is a DHCPv4 message.
|
||||
type Packet struct {
|
||||
Op byte
|
||||
HType byte
|
||||
HLen byte
|
||||
Hops byte
|
||||
XID uint32
|
||||
Secs uint16
|
||||
Flags uint16
|
||||
CIAddr netip.Addr
|
||||
YIAddr netip.Addr
|
||||
SIAddr netip.Addr
|
||||
GIAddr netip.Addr
|
||||
CHAddr [16]byte
|
||||
SName string
|
||||
File string
|
||||
// Options in the order they were read or are to be written. Options
|
||||
// that appear more than once are joined (RFC 3396).
|
||||
Options []Option
|
||||
}
|
||||
|
||||
var zero4 = netip.AddrFrom4([4]byte{})
|
||||
|
||||
// Parse reads a message.
|
||||
func Parse(b []byte) (*Packet, error) {
|
||||
if len(b) < headerLen+4 {
|
||||
return nil, fmt.Errorf("message too short (%d bytes)", len(b))
|
||||
}
|
||||
p := &Packet{
|
||||
Op: b[0], HType: b[1], HLen: b[2], Hops: b[3],
|
||||
XID: binary.BigEndian.Uint32(b[4:8]),
|
||||
Secs: binary.BigEndian.Uint16(b[8:10]),
|
||||
Flags: binary.BigEndian.Uint16(b[10:12]),
|
||||
CIAddr: addr4(b[12:16]), YIAddr: addr4(b[16:20]), SIAddr: addr4(b[20:24]), GIAddr: addr4(b[24:28]),
|
||||
}
|
||||
copy(p.CHAddr[:], b[28:44])
|
||||
if p.HLen > 16 {
|
||||
return nil, fmt.Errorf("hardware address length %d is over 16", p.HLen)
|
||||
}
|
||||
if string(b[236:240]) != string(magicCookie) {
|
||||
return nil, errors.New("no DHCP magic cookie (a BOOTP message?)")
|
||||
}
|
||||
opts, err := parseOptions(b[240:])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Option overload (52): more options in the file and sname fields.
|
||||
sname, file := b[44:108], b[108:236]
|
||||
if ov := find(opts, OptOverload); len(ov) == 1 {
|
||||
if ov[0]&1 != 0 {
|
||||
more, err := parseOptions(file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("options in the file field: %w", err)
|
||||
}
|
||||
opts = append(opts, more...)
|
||||
file = nil
|
||||
}
|
||||
if ov[0]&2 != 0 {
|
||||
more, err := parseOptions(sname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("options in the sname field: %w", err)
|
||||
}
|
||||
opts = append(opts, more...)
|
||||
sname = nil
|
||||
}
|
||||
}
|
||||
p.SName, p.File = cString(sname), cString(file)
|
||||
p.Options = join(opts)
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func addr4(b []byte) netip.Addr { return netip.AddrFrom4([4]byte(b[:4])) }
|
||||
|
||||
func cString(b []byte) string {
|
||||
if i := strings.IndexByte(string(b), 0); i >= 0 {
|
||||
b = b[:i]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func parseOptions(b []byte) ([]Option, error) {
|
||||
var out []Option
|
||||
for i := 0; i < len(b); {
|
||||
code := b[i]
|
||||
i++
|
||||
switch code {
|
||||
case OptPad:
|
||||
continue
|
||||
case OptEnd:
|
||||
return out, nil
|
||||
}
|
||||
if i >= len(b) {
|
||||
return nil, fmt.Errorf("option %d has no length", code)
|
||||
}
|
||||
n := int(b[i])
|
||||
i++
|
||||
if i+n > len(b) {
|
||||
return nil, fmt.Errorf("option %d is cut short (%d of %d bytes)", code, len(b)-i, n)
|
||||
}
|
||||
out = append(out, Option{Code: code, Data: append([]byte(nil), b[i:i+n]...)})
|
||||
i += n
|
||||
}
|
||||
// Some clients leave out the end option.
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// join concatenates repeated options into one (RFC 3396), keeping the
|
||||
// position of the first.
|
||||
func join(opts []Option) []Option {
|
||||
var out []Option
|
||||
seen := map[byte]int{}
|
||||
for _, o := range opts {
|
||||
if i, ok := seen[o.Code]; ok {
|
||||
out[i].Data = append(out[i].Data, o.Data...)
|
||||
continue
|
||||
}
|
||||
seen[o.Code] = len(out)
|
||||
out = append(out, o)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func find(opts []Option, code byte) []byte {
|
||||
for _, o := range opts {
|
||||
if o.Code == code {
|
||||
return o.Data
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Marshal writes the message. Options longer than 255 bytes are split
|
||||
// (RFC 3396); the message is padded to MinLen.
|
||||
func (p *Packet) Marshal() []byte {
|
||||
b := make([]byte, headerLen, MinLen+64)
|
||||
b[0], b[1], b[2], b[3] = p.Op, p.HType, p.HLen, p.Hops
|
||||
binary.BigEndian.PutUint32(b[4:8], p.XID)
|
||||
binary.BigEndian.PutUint16(b[8:10], p.Secs)
|
||||
binary.BigEndian.PutUint16(b[10:12], p.Flags)
|
||||
put4(b[12:16], p.CIAddr)
|
||||
put4(b[16:20], p.YIAddr)
|
||||
put4(b[20:24], p.SIAddr)
|
||||
put4(b[24:28], p.GIAddr)
|
||||
copy(b[28:44], p.CHAddr[:])
|
||||
copy(b[44:107], p.SName)
|
||||
copy(b[108:235], p.File)
|
||||
b = append(b, magicCookie...)
|
||||
for _, o := range p.Options {
|
||||
data := o.Data
|
||||
for first := true; first || len(data) > 0; first = false {
|
||||
n := min(len(data), 255)
|
||||
b = append(b, o.Code, byte(n))
|
||||
b = append(b, data[:n]...)
|
||||
data = data[n:]
|
||||
}
|
||||
}
|
||||
b = append(b, OptEnd)
|
||||
for len(b) < MinLen {
|
||||
b = append(b, OptPad)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Len is the length Marshal would produce.
|
||||
func (p *Packet) Len() int {
|
||||
n := headerLen + 4 + 1
|
||||
for _, o := range p.Options {
|
||||
n += len(o.Data) + 2*max(1, (len(o.Data)+254)/255)
|
||||
}
|
||||
return max(n, MinLen)
|
||||
}
|
||||
|
||||
func put4(b []byte, a netip.Addr) {
|
||||
if a.Is4() {
|
||||
v := a.As4()
|
||||
copy(b, v[:])
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns an option's data (nil when absent).
|
||||
func (p *Packet) Get(code byte) []byte { return find(p.Options, code) }
|
||||
|
||||
// Has reports whether the option is present.
|
||||
func (p *Packet) Has(code byte) bool {
|
||||
for _, o := range p.Options {
|
||||
if o.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Set adds or replaces an option.
|
||||
func (p *Packet) Set(code byte, data []byte) {
|
||||
for i, o := range p.Options {
|
||||
if o.Code == code {
|
||||
p.Options[i].Data = data
|
||||
return
|
||||
}
|
||||
}
|
||||
p.Options = append(p.Options, Option{Code: code, Data: data})
|
||||
}
|
||||
|
||||
// Type is the message type (0 when the option is missing or malformed).
|
||||
func (p *Packet) Type() MessageType {
|
||||
if d := p.Get(OptMessageType); len(d) == 1 {
|
||||
return MessageType(d[0])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// IPOption returns an option holding one IPv4 address.
|
||||
func (p *Packet) IPOption(code byte) (netip.Addr, bool) {
|
||||
d := p.Get(code)
|
||||
if len(d) != 4 {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
return addr4(d), true
|
||||
}
|
||||
|
||||
// MAC is the client's hardware address, for Ethernet-like types.
|
||||
func (p *Packet) MAC() net.HardwareAddr {
|
||||
n := int(p.HLen)
|
||||
if n == 0 || n > 16 {
|
||||
return nil
|
||||
}
|
||||
return net.HardwareAddr(append([]byte(nil), p.CHAddr[:n]...))
|
||||
}
|
||||
|
||||
// ClientID is option 61 in lower-case hex ("" when absent).
|
||||
func (p *Packet) ClientID() string {
|
||||
if d := p.Get(OptClientID); len(d) > 0 {
|
||||
return fmt.Sprintf("%x", d)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// HostName is the name the client gives: option 12, or else the name in
|
||||
// the client FQDN option (81), without its domain.
|
||||
func (p *Packet) HostName() string {
|
||||
if d := p.Get(OptHostName); len(d) > 0 {
|
||||
return strings.TrimRight(string(d), "\x00")
|
||||
}
|
||||
if d := p.Get(OptClientFQDN); len(d) > 3 {
|
||||
name := d[3:]
|
||||
if d[0]&0x04 != 0 { // E bit: canonical wire format
|
||||
if labels, err := decodeNames(name); err == nil && len(labels) > 0 {
|
||||
return strings.SplitN(labels[0], ".", 2)[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
return strings.SplitN(strings.TrimRight(string(name), "\x00"), ".", 2)[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ParamRequest is the client's parameter request list (option 55).
|
||||
func (p *Packet) ParamRequest() []byte { return p.Get(OptParamRequest) }
|
||||
|
||||
// MaxMessageSize is the largest reply the client accepts (option 57),
|
||||
// or 576 (RFC 2131's minimum) when it doesn't say.
|
||||
func (p *Packet) MaxMessageSize() int {
|
||||
if d := p.Get(OptMaxMessage); len(d) == 2 {
|
||||
if n := int(binary.BigEndian.Uint16(d)); n >= 576 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 576
|
||||
}
|
||||
|
||||
// Broadcast reports whether the client asked for broadcast replies.
|
||||
func (p *Packet) Broadcast() bool { return p.Flags&FlagBroadcast != 0 }
|
||||
|
||||
// Reply starts a reply to a client's message: the same transaction,
|
||||
// hardware address, flags and relay address, and the message type.
|
||||
func (p *Packet) Reply(t MessageType) *Packet {
|
||||
r := &Packet{
|
||||
Op: BootReply, HType: p.HType, HLen: p.HLen, XID: p.XID, Flags: p.Flags,
|
||||
CIAddr: zero4, YIAddr: zero4, SIAddr: zero4, GIAddr: p.GIAddr, CHAddr: p.CHAddr,
|
||||
}
|
||||
if !r.GIAddr.IsValid() {
|
||||
r.GIAddr = zero4
|
||||
}
|
||||
r.Set(OptMessageType, []byte{byte(t)})
|
||||
return r
|
||||
}
|
||||
|
||||
// NewRequest starts a client message (for tests and the CLI's probe).
|
||||
func NewRequest(t MessageType, mac net.HardwareAddr, xid uint32) *Packet {
|
||||
p := &Packet{Op: BootRequest, HType: 1, HLen: byte(len(mac)), XID: xid, CIAddr: zero4, YIAddr: zero4, SIAddr: zero4, GIAddr: zero4}
|
||||
copy(p.CHAddr[:], mac)
|
||||
p.Set(OptMessageType, []byte{byte(t)})
|
||||
return p
|
||||
}
|
||||
|
||||
// IsZero reports whether a is unset or 0.0.0.0.
|
||||
func IsZero(a netip.Addr) bool { return !a.IsValid() || a == zero4 }
|
||||
|
||||
// U32 encodes a 32-bit option value.
|
||||
func U32(v uint32) []byte { return binary.BigEndian.AppendUint32(nil, v) }
|
||||
|
||||
// IP encodes an address option value.
|
||||
func IP(a netip.Addr) []byte {
|
||||
v := a.As4()
|
||||
return v[:]
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// config is a configuration prepared for answering requests: subnets by
|
||||
// interface, reservations indexed, options encoded.
|
||||
type config struct {
|
||||
state *model.State
|
||||
byIface map[string][]*subnet
|
||||
// resByIP holds every reservation by address.
|
||||
resByIP map[netip.Addr]*model.Reservation
|
||||
// resByClient holds reservations per subnet by "mac aa:..." or
|
||||
// "id 01aa...".
|
||||
resByClient map[int64]map[string]*model.Reservation
|
||||
settings model.Settings
|
||||
}
|
||||
|
||||
type subnet struct {
|
||||
*model.Subnet
|
||||
network, broadcast netip.Addr
|
||||
leaseTime time.Duration
|
||||
// options are the encoded options of the settings and the subnet,
|
||||
// the subnet's winning.
|
||||
options map[byte][]byte
|
||||
}
|
||||
|
||||
func compile(st *model.State) *config {
|
||||
c := &config{
|
||||
state: st,
|
||||
byIface: map[string][]*subnet{},
|
||||
resByIP: map[netip.Addr]*model.Reservation{},
|
||||
resByClient: map[int64]map[string]*model.Reservation{},
|
||||
settings: st.Settings,
|
||||
}
|
||||
global := encodeOptions(st.Settings.Options, nil)
|
||||
for _, s := range st.Subnets {
|
||||
if s.Disabled || s.Interface == "" || !s.Prefix.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
lt := s.LeaseTime
|
||||
if lt <= 0 {
|
||||
lt = st.Settings.EffectiveLeaseTime()
|
||||
}
|
||||
sc := &subnet{Subnet: s, network: s.Prefix.Addr(), broadcast: model.Last(s.Prefix), leaseTime: time.Duration(lt) * time.Second,
|
||||
options: encodeOptions(s.Options, global)}
|
||||
c.byIface[s.Interface] = append(c.byIface[s.Interface], sc)
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
c.resByIP[r.IP] = r
|
||||
s := st.SubnetFor(r.IP)
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if c.resByClient[s.ID] == nil {
|
||||
c.resByClient[s.ID] = map[string]*model.Reservation{}
|
||||
}
|
||||
if r.ClientID != "" {
|
||||
c.resByClient[s.ID]["id "+r.ClientID] = r
|
||||
}
|
||||
if r.MAC != "" {
|
||||
c.resByClient[s.ID]["mac "+r.MAC] = r
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// encodeOptions encodes options over a base set (copied).
|
||||
func encodeOptions(opts []model.OptionValue, base map[byte][]byte) map[byte][]byte {
|
||||
out := map[byte][]byte{}
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for _, o := range opts {
|
||||
if o.Code <= 0 || o.Code >= 255 {
|
||||
continue
|
||||
}
|
||||
def := dhcp4.ByCode(byte(o.Code))
|
||||
typ := def.Type
|
||||
if !dhcp4.Known(byte(o.Code)) && o.Type != "" {
|
||||
typ = o.Type
|
||||
}
|
||||
if b, err := dhcp4.Encode(typ, o.Value); err == nil {
|
||||
out[byte(o.Code)] = b
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// reservationFor finds the client's reservation in a subnet.
|
||||
func (c *config) reservationFor(subnetID int64, cl client) *model.Reservation {
|
||||
m := c.resByClient[subnetID]
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
if cl.id != "" {
|
||||
if r := m["id "+cl.id]; r != nil {
|
||||
return r
|
||||
}
|
||||
}
|
||||
return m["mac "+cl.mac]
|
||||
}
|
||||
|
||||
// subnetFor finds the interface's subnet holding a.
|
||||
func (c *config) subnetFor(iface string, a netip.Addr) *subnet {
|
||||
for _, s := range c.byIface[iface] {
|
||||
if s.Prefix.Contains(a) {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// client identifies who sent a request.
|
||||
type client struct {
|
||||
mac string // aa:bb:cc:dd:ee:ff
|
||||
id string // client identifier (option 61) in hex, or ""
|
||||
}
|
||||
|
||||
// matches reports whether a reservation is for the client: by client
|
||||
// identifier when the reservation has one, else by MAC address.
|
||||
func (cl client) matches(r *model.Reservation) bool {
|
||||
if r.ClientID != "" {
|
||||
return r.ClientID == cl.id
|
||||
}
|
||||
return r.MAC == cl.mac
|
||||
}
|
||||
|
||||
func (cl client) String() string {
|
||||
if cl.id != "" {
|
||||
return cl.mac + " (client id " + cl.id + ")"
|
||||
}
|
||||
return cl.mac
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// Outbound is a reply and where to send it.
|
||||
type Outbound struct {
|
||||
Packet *dhcp4.Packet
|
||||
// Dst is the client's address for unicast replies; invalid for a
|
||||
// broadcast on the interface.
|
||||
Dst netip.Addr
|
||||
}
|
||||
|
||||
var broadcast = netip.AddrFrom4([4]byte{255, 255, 255, 255})
|
||||
|
||||
// Handle answers one request that arrived on an interface. It returns nil
|
||||
// when there is nothing to send.
|
||||
func (s *Server) Handle(ctx context.Context, iface string, p *dhcp4.Packet) *Outbound {
|
||||
if p.Op != dhcp4.BootRequest {
|
||||
return nil
|
||||
}
|
||||
if p.HType != 1 || p.HLen != 6 {
|
||||
s.warnOnce("htype "+iface, time.Hour, "ignoring a DHCP request for a hardware type other than Ethernet", "interface", iface, "htype", p.HType, "hlen", p.HLen)
|
||||
return nil
|
||||
}
|
||||
cfg := s.cfg.Load()
|
||||
subs := cfg.byIface[iface]
|
||||
if len(subs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !dhcp4.IsZero(p.GIAddr) {
|
||||
s.warnOnce("relay "+iface, time.Hour, "ignoring a relayed DHCP request: Steward 0.1 doesn't serve relays yet", "interface", iface, "relay", p.GIAddr)
|
||||
return nil
|
||||
}
|
||||
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()}
|
||||
if a, ok := p.IPOption(dhcp4.OptRequestedIP); ok {
|
||||
ev.IP = a.String()
|
||||
} else if !dhcp4.IsZero(p.CIAddr) {
|
||||
ev.IP = p.CIAddr.String()
|
||||
}
|
||||
s.events.add(ev)
|
||||
var out *Outbound
|
||||
switch t {
|
||||
case dhcp4.Discover:
|
||||
out = s.discover(ctx, cfg, iface, subs, p, cl)
|
||||
case dhcp4.Request:
|
||||
out = s.request(ctx, cfg, iface, p, cl)
|
||||
case dhcp4.Decline:
|
||||
s.decline(ctx, cfg, iface, p, cl)
|
||||
case dhcp4.Release:
|
||||
s.release(ctx, iface, p, cl)
|
||||
case dhcp4.Inform:
|
||||
out = s.inform(cfg, iface, p, cl)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
if out != nil {
|
||||
ev := Event{Time: s.now(), Interface: iface, Type: out.Packet.Type().String(), MAC: cl.mac, Hostname: p.HostName()}
|
||||
if !dhcp4.IsZero(out.Packet.YIAddr) {
|
||||
ev.IP = out.Packet.YIAddr.String()
|
||||
}
|
||||
if m := out.Packet.Get(dhcp4.OptMessage); m != nil {
|
||||
ev.Detail = string(m)
|
||||
}
|
||||
s.events.add(ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- DISCOVER
|
||||
|
||||
func (s *Server) discover(ctx context.Context, cfg *config, iface string, subs []*subnet, p *dhcp4.Packet, cl client) *Outbound {
|
||||
requested, _ := p.IPOption(dhcp4.OptRequestedIP)
|
||||
for try := 0; try < 8; try++ {
|
||||
ip, sub, fresh := s.choose(cfg, iface, subs, cl, requested)
|
||||
if !ip.IsValid() {
|
||||
s.warnOnce("full "+iface, 10*time.Minute, "no free address for a client: every pool on the interface is in use", "interface", iface, "client", cl.String())
|
||||
s.events.add(Event{Time: s.now(), Interface: iface, Type: "NO ADDRESS", MAC: cl.mac, Detail: "every pool on " + iface + " is in use"})
|
||||
return nil
|
||||
}
|
||||
// A fresh address from a pool is pinged first: something that
|
||||
// answers is using it without a lease.
|
||||
if fresh && cfg.settings.PingCheck && s.pinger != nil {
|
||||
if s.pinger.Ping(ctx, ip, cfg.settings.EffectivePingTimeout()) {
|
||||
s.conflict(ctx, cfg, iface, sub, ip)
|
||||
continue
|
||||
}
|
||||
}
|
||||
res := cfg.reservationFor(sub.ID, cl)
|
||||
if res != nil && res.IP != ip {
|
||||
res = nil
|
||||
}
|
||||
sid, err := s.serverAddr(iface, sub)
|
||||
if err != nil {
|
||||
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
|
||||
return nil
|
||||
}
|
||||
r := p.Reply(dhcp4.Offer)
|
||||
r.YIAddr = ip
|
||||
s.addOptions(r, p, sub, res, sid, true)
|
||||
return &Outbound{Packet: r}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// choose picks the address to offer: the client's reservation, the
|
||||
// address it had, the one it asks for, or a free one from the pools. fresh
|
||||
// says the address comes from a pool and the client never had it. The
|
||||
// address is held for the client for a minute.
|
||||
func (s *Server) choose(cfg *config, iface string, subs []*subnet, cl client, requested netip.Addr) (netip.Addr, *subnet, bool) {
|
||||
now := s.now()
|
||||
own := s.ownAddrs(iface)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
hold := func(ip netip.Addr, sub *subnet, fresh bool) (netip.Addr, *subnet, bool) {
|
||||
s.offers[ip] = offer{cl: cl, expires: now.Add(offerHold)}
|
||||
return ip, sub, fresh
|
||||
}
|
||||
// 1. A reservation.
|
||||
for _, sub := range subs {
|
||||
if r := cfg.reservationFor(sub.ID, cl); r != nil {
|
||||
if s.usable(cfg, own, sub, r.IP, cl, now) {
|
||||
return hold(r.IP, sub, false)
|
||||
}
|
||||
s.warnOnce("resbusy "+r.IP.String(), 10*time.Minute, "a client's reserved address is held by another client; handing out another one until it is free", "ip", r.IP, "client", cl.String())
|
||||
}
|
||||
}
|
||||
// 2. The address the client had (an active lease first, then the most
|
||||
// recent one).
|
||||
var prev *model.Lease
|
||||
for _, l := range s.leases {
|
||||
if !l.Owns(cl.mac, cl.id) || l.State == model.LeaseDeclined {
|
||||
continue
|
||||
}
|
||||
if prev == nil || l.ActiveAt(now) && !prev.ActiveAt(now) || l.ActiveAt(now) == prev.ActiveAt(now) && l.End.After(prev.End) {
|
||||
prev = l
|
||||
}
|
||||
}
|
||||
if prev != nil {
|
||||
if sub := cfg.subnetFor(iface, prev.IP); sub != nil && s.usable(cfg, own, sub, prev.IP, cl, now) {
|
||||
return hold(prev.IP, sub, false)
|
||||
}
|
||||
}
|
||||
// 3. The address the client asks for.
|
||||
if requested.IsValid() {
|
||||
if sub := cfg.subnetFor(iface, requested); sub != nil && s.usable(cfg, own, sub, requested, cl, now) {
|
||||
return hold(requested, sub, true)
|
||||
}
|
||||
}
|
||||
// 4. A free address: one never handed out, else the one free the
|
||||
// longest.
|
||||
var oldest netip.Addr
|
||||
var oldestSub *subnet
|
||||
var oldestEnd time.Time
|
||||
for _, sub := range subs {
|
||||
for _, pool := range sub.Pools {
|
||||
for v := model.U32(pool.Start); v <= model.U32(pool.End) && v != 0; v++ {
|
||||
ip := model.FromU32(v)
|
||||
if !s.usable(cfg, own, sub, ip, cl, now) {
|
||||
continue
|
||||
}
|
||||
l := s.leases[ip]
|
||||
if l == nil {
|
||||
return hold(ip, sub, true)
|
||||
}
|
||||
if !oldest.IsValid() || l.End.Before(oldestEnd) {
|
||||
oldest, oldestSub, oldestEnd = ip, sub, l.End
|
||||
}
|
||||
if v == model.U32(pool.End) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if oldest.IsValid() {
|
||||
return hold(oldest, oldestSub, true)
|
||||
}
|
||||
return netip.Addr{}, nil, false
|
||||
}
|
||||
|
||||
// usable reports whether ip can go to the client (s.mu held): inside the
|
||||
// subnet but not its network, broadcast or server address; reserved for
|
||||
// this client or in a pool and not reserved for anyone else; not leased
|
||||
// to, declined by or offered to someone else.
|
||||
func (s *Server) usable(cfg *config, own map[netip.Addr]bool, sub *subnet, ip netip.Addr, cl client, now time.Time) bool {
|
||||
if !sub.Prefix.Contains(ip) || ip == sub.network || ip == sub.broadcast || own[ip] {
|
||||
return false
|
||||
}
|
||||
if r := cfg.resByIP[ip]; r != nil {
|
||||
if !cl.matches(r) {
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
inPool := false
|
||||
for _, p := range sub.Pools {
|
||||
if p.Contains(ip) {
|
||||
inPool = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !inPool {
|
||||
return false
|
||||
}
|
||||
// A client with a reservation in this subnet gets that address,
|
||||
// not a pool address.
|
||||
if r := cfg.reservationFor(sub.ID, cl); r != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if l := s.leases[ip]; l != nil && l.HeldAt(now) && (l.State == model.LeaseDeclined || !l.Owns(cl.mac, cl.id)) {
|
||||
return false
|
||||
}
|
||||
if o, ok := s.offers[ip]; ok && now.Before(o.expires) && o.cl != cl {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// conflict holds back an address that answered a ping.
|
||||
func (s *Server) conflict(ctx context.Context, cfg *config, iface string, sub *subnet, ip netip.Addr) {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
delete(s.offers, ip)
|
||||
l := &model.Lease{IP: ip, SubnetID: sub.ID, State: model.LeaseDeclined, Start: now, End: now.Add(cfg.settings.EffectiveDeclineHold()), Interface: iface, Hostname: "(answered a ping)"}
|
||||
if err := s.putLease(ctx, l); err != nil {
|
||||
s.log.Error("saving a conflict failed", "ip", ip, "err", err)
|
||||
}
|
||||
s.log.Warn("an address answered a ping before it was offered: something uses it without a lease; holding it back", "ip", ip, "interface", iface, "until", l.End.Format(time.RFC3339))
|
||||
s.events.add(Event{Time: now, Interface: iface, Type: "CONFLICT", IP: ip.String(), Detail: "answered a ping; held back"})
|
||||
}
|
||||
|
||||
// ---- REQUEST
|
||||
|
||||
func (s *Server) request(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
|
||||
sidOpt, hasSID := p.IPOption(dhcp4.OptServerID)
|
||||
reqIP, hasReq := p.IPOption(dhcp4.OptRequestedIP)
|
||||
switch {
|
||||
case hasSID:
|
||||
// SELECTING: the client answers an offer, ours or another server's.
|
||||
if !s.isOwnAddr(iface, sidOpt) {
|
||||
s.mu.Lock()
|
||||
for ip, o := range s.offers {
|
||||
if o.cl == cl {
|
||||
delete(s.offers, ip)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
if !hasReq {
|
||||
return s.nak(cfg, iface, p, nil, "no requested address")
|
||||
}
|
||||
return s.ack(ctx, cfg, iface, p, cl, reqIP)
|
||||
case hasReq:
|
||||
// INIT-REBOOT: the client wants to keep the address it had.
|
||||
return s.ack(ctx, cfg, iface, p, cl, reqIP)
|
||||
case !dhcp4.IsZero(p.CIAddr):
|
||||
// RENEWING (unicast to us) or REBINDING (broadcast).
|
||||
return s.ack(ctx, cfg, iface, p, cl, p.CIAddr)
|
||||
}
|
||||
return s.nak(cfg, iface, p, nil, "no address in the request")
|
||||
}
|
||||
|
||||
// ack gives the client ip if it may have it, saving the lease first;
|
||||
// otherwise a NAK, so the client starts over.
|
||||
func (s *Server) ack(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client, ip netip.Addr) *Outbound {
|
||||
sub := cfg.subnetFor(iface, ip)
|
||||
if sub == nil {
|
||||
return s.nak(cfg, iface, p, nil, fmt.Sprintf("%s does not belong on %s", ip, iface))
|
||||
}
|
||||
now := s.now()
|
||||
own := s.ownAddrs(iface)
|
||||
s.mu.Lock()
|
||||
if !s.usable(cfg, own, sub, ip, cl, now) {
|
||||
s.mu.Unlock()
|
||||
why := "address not available"
|
||||
if r := cfg.reservationFor(sub.ID, cl); r != nil && r.IP != ip {
|
||||
why = "this client has a reservation for " + r.IP.String()
|
||||
}
|
||||
return s.nak(cfg, iface, p, sub, why)
|
||||
}
|
||||
start, host := now, p.HostName()
|
||||
if l := s.leases[ip]; l != nil && l.Owns(cl.mac, cl.id) {
|
||||
if l.ActiveAt(now) {
|
||||
start = l.Start
|
||||
}
|
||||
// Renewals often leave the host name out.
|
||||
if host == "" {
|
||||
host = l.Hostname
|
||||
}
|
||||
}
|
||||
l := &model.Lease{IP: ip, SubnetID: sub.ID, MAC: cl.mac, ClientID: cl.id, Hostname: host, State: model.LeaseActive,
|
||||
Start: start, End: now.Add(sub.leaseTime), Interface: iface}
|
||||
err := s.putLease(ctx, l)
|
||||
delete(s.offers, ip)
|
||||
s.mu.Unlock()
|
||||
if err != nil {
|
||||
// Without the lease saved, the address could go to someone else
|
||||
// after a restart: don't hand it out.
|
||||
s.log.Error("saving a lease failed; not answering", "ip", ip, "err", err)
|
||||
return nil
|
||||
}
|
||||
sid, err := s.serverAddr(iface, sub)
|
||||
if err != nil {
|
||||
s.warnOnce("sid "+iface, 10*time.Minute, "can't answer DHCP: "+err.Error(), "interface", iface)
|
||||
return nil
|
||||
}
|
||||
res := cfg.reservationFor(sub.ID, cl)
|
||||
if res != nil && res.IP != ip {
|
||||
res = nil
|
||||
}
|
||||
r := p.Reply(dhcp4.Ack)
|
||||
r.YIAddr = ip
|
||||
r.CIAddr = p.CIAddr
|
||||
if !r.CIAddr.IsValid() {
|
||||
r.CIAddr = netip.AddrFrom4([4]byte{})
|
||||
}
|
||||
s.addOptions(r, p, sub, res, sid, true)
|
||||
return &Outbound{Packet: r, Dst: unicastTo(p)}
|
||||
}
|
||||
|
||||
// nak refuses a request. Without authority (settings.not_authoritative),
|
||||
// requests for addresses Steward doesn't know get no answer instead.
|
||||
func (s *Server) nak(cfg *config, iface string, p *dhcp4.Packet, sub *subnet, why string) *Outbound {
|
||||
if cfg.settings.NotAuthoritative && sub == nil {
|
||||
return nil
|
||||
}
|
||||
sid, err := s.serverAddr(iface, sub)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
r := p.Reply(dhcp4.Nak)
|
||||
r.Flags |= dhcp4.FlagBroadcast
|
||||
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
|
||||
r.Set(dhcp4.OptMessage, []byte(why))
|
||||
return &Outbound{Packet: r}
|
||||
}
|
||||
|
||||
// unicastTo is where a reply goes: the client's own address when it has
|
||||
// one, else a broadcast.
|
||||
func unicastTo(p *dhcp4.Packet) netip.Addr {
|
||||
if !dhcp4.IsZero(p.CIAddr) {
|
||||
return p.CIAddr
|
||||
}
|
||||
return netip.Addr{}
|
||||
}
|
||||
|
||||
// ---- DECLINE, RELEASE, INFORM
|
||||
|
||||
func (s *Server) decline(ctx context.Context, cfg *config, iface string, p *dhcp4.Packet, cl client) {
|
||||
ip, ok := p.IPOption(dhcp4.OptRequestedIP)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
l := s.leases[ip]
|
||||
if l == nil || !l.Owns(cl.mac, cl.id) {
|
||||
return
|
||||
}
|
||||
l2 := *l
|
||||
l2.State, l2.End = model.LeaseDeclined, now.Add(cfg.settings.EffectiveDeclineHold())
|
||||
if err := s.putLease(ctx, &l2); err != nil {
|
||||
s.log.Error("saving a declined lease failed", "ip", ip, "err", err)
|
||||
return
|
||||
}
|
||||
s.log.Warn("a client declined its address: another device uses it; holding it back", "ip", ip, "client", cl.String(), "interface", iface, "until", l2.End.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
func (s *Server) release(ctx context.Context, iface string, p *dhcp4.Packet, cl client) {
|
||||
ip := p.CIAddr
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
l := s.leases[ip]
|
||||
if l == nil || !l.Owns(cl.mac, cl.id) || !l.ActiveAt(now) {
|
||||
return
|
||||
}
|
||||
l2 := *l
|
||||
l2.State, l2.End = model.LeaseReleased, now
|
||||
if err := s.putLease(ctx, &l2); err != nil {
|
||||
s.log.Error("saving a released lease failed", "ip", ip, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) inform(cfg *config, iface string, p *dhcp4.Packet, cl client) *Outbound {
|
||||
if dhcp4.IsZero(p.CIAddr) {
|
||||
return nil
|
||||
}
|
||||
sub := cfg.subnetFor(iface, p.CIAddr)
|
||||
if sub == nil {
|
||||
return nil
|
||||
}
|
||||
sid, err := s.serverAddr(iface, sub)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var res *model.Reservation
|
||||
if r := cfg.resByIP[p.CIAddr]; r != nil && cl.matches(r) {
|
||||
res = r
|
||||
}
|
||||
r := p.Reply(dhcp4.Ack)
|
||||
r.CIAddr = p.CIAddr
|
||||
s.addOptions(r, p, sub, res, sid, false)
|
||||
return &Outbound{Packet: r, Dst: p.CIAddr}
|
||||
}
|
||||
|
||||
// ---- options
|
||||
|
||||
// Options always sent when configured, even when the client doesn't ask.
|
||||
var essential = []byte{dhcp4.OptSubnetMask, dhcp4.OptRouters, dhcp4.OptDNSServers, dhcp4.OptDomainName, dhcp4.OptHostName}
|
||||
|
||||
// addOptions adds the server identifier, the lease times (lease true) and
|
||||
// the configured options: the reservation's over the subnet's over the
|
||||
// settings'. With a parameter request list only what the client asked
|
||||
// for (and the essentials) is sent, in its order.
|
||||
func (s *Server) addOptions(r, req *dhcp4.Packet, sub *subnet, res *model.Reservation, sid netip.Addr, lease bool) {
|
||||
r.Set(dhcp4.OptServerID, dhcp4.IP(sid))
|
||||
if lease {
|
||||
lt := uint32(sub.leaseTime / time.Second)
|
||||
r.Set(dhcp4.OptLeaseTime, dhcp4.U32(lt))
|
||||
r.Set(dhcp4.OptRenewalTime, dhcp4.U32(lt/2))
|
||||
r.Set(dhcp4.OptRebindingTime, dhcp4.U32(lt*7/8))
|
||||
}
|
||||
opts := sub.options
|
||||
if res != nil && (len(res.Options) > 0 || res.Hostname != "") {
|
||||
opts = encodeOptions(res.Options, sub.options)
|
||||
if res.Hostname != "" {
|
||||
opts[dhcp4.OptHostName] = []byte(res.Hostname)
|
||||
}
|
||||
}
|
||||
all := map[byte][]byte{dhcp4.OptSubnetMask: dhcp4.IP(model.Mask(sub.Prefix))}
|
||||
for k, v := range opts {
|
||||
all[k] = v
|
||||
}
|
||||
var order []byte
|
||||
added := map[byte]bool{}
|
||||
addCode := func(c byte) {
|
||||
if _, ok := all[c]; ok && !added[c] {
|
||||
added[c] = true
|
||||
order = append(order, c)
|
||||
}
|
||||
}
|
||||
if prl := req.ParamRequest(); len(prl) > 0 {
|
||||
for _, c := range prl {
|
||||
addCode(c)
|
||||
}
|
||||
for _, c := range essential {
|
||||
addCode(c)
|
||||
}
|
||||
} else {
|
||||
for c := byte(1); c < 255; c++ {
|
||||
addCode(c)
|
||||
}
|
||||
}
|
||||
limit := req.MaxMessageSize() - 28 // IP and UDP headers
|
||||
for _, c := range order {
|
||||
r.Set(c, all[c])
|
||||
if r.Len() > limit {
|
||||
r.Options = r.Options[:len(r.Options)-1]
|
||||
s.warnOnce(fmt.Sprintf("size %d", c), time.Hour, "a reply would be too large for the client; leaving out options", "first_left_out", dhcp4.ByCode(c).Name, "max_message_size", req.MaxMessageSize())
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,446 @@
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
)
|
||||
|
||||
type fakeIfaces map[string][]netip.Prefix
|
||||
|
||||
func (f fakeIfaces) Addrs(name string) ([]netip.Prefix, error) {
|
||||
a, ok := f[name]
|
||||
if !ok {
|
||||
return nil, errors.New("no interface " + name)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
type fakePinger struct {
|
||||
mu sync.Mutex
|
||||
answers map[netip.Addr]bool
|
||||
pinged []netip.Addr
|
||||
}
|
||||
|
||||
func (f *fakePinger) Ping(_ context.Context, a netip.Addr, _ time.Duration) bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
f.pinged = append(f.pinged, a)
|
||||
return f.answers[a]
|
||||
}
|
||||
|
||||
type harness struct {
|
||||
t *testing.T
|
||||
s *Server
|
||||
st *store.Store
|
||||
ping *fakePinger
|
||||
clock time.Time
|
||||
}
|
||||
|
||||
func pool(s string) model.Pool {
|
||||
p, err := model.ParsePool(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T, edit func(st *model.State)) *harness {
|
||||
t.Helper()
|
||||
db, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ifs := fakeIfaces{
|
||||
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
|
||||
"eth0.20": {netip.MustParsePrefix("10.0.20.1/24")},
|
||||
"eth1": {netip.MustParsePrefix("192.168.1.1/24")},
|
||||
}
|
||||
h := &harness{t: t, st: db, ping: &fakePinger{answers: map[netip.Addr]bool{}}, clock: time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC)}
|
||||
h.s = New(nil, db, ifs, h.ping)
|
||||
h.s.now = func() time.Time { return h.clock }
|
||||
st := &model.State{Settings: model.DefaultSettings(),
|
||||
Subnets: []*model.Subnet{
|
||||
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.103")},
|
||||
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}, {Code: 6, Value: "10.0.10.2"}, {Code: 15, Value: "lan"}, {Code: 42, Value: "10.0.10.2"}}},
|
||||
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24"), Pools: []model.Pool{pool("10.0.20.100-10.0.20.200")}, LeaseTime: 3600},
|
||||
},
|
||||
Reservations: []*model.Reservation{
|
||||
{ID: 3, MAC: "aa:00:00:00:00:99", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas", Options: []model.OptionValue{{Code: 6, Value: "1.1.1.1"}}},
|
||||
{ID: 4, MAC: "aa:00:00:00:00:98", IP: netip.MustParseAddr("10.0.10.101"), Hostname: "printer"},
|
||||
}}
|
||||
st.Settings.Options = []model.OptionValue{{Code: 6, Value: "9.9.9.9"}}
|
||||
if edit != nil {
|
||||
edit(st)
|
||||
}
|
||||
h.s.SetConfig(st)
|
||||
return h
|
||||
}
|
||||
|
||||
func mac(n byte) net.HardwareAddr { return net.HardwareAddr{0xaa, 0, 0, 0, 0, n} }
|
||||
|
||||
func (h *harness) send(iface string, p *dhcp4.Packet) *Outbound {
|
||||
h.t.Helper()
|
||||
// Through the wire format, as for real.
|
||||
q, err := dhcp4.Parse(p.Marshal())
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
out := h.s.Handle(context.Background(), iface, q)
|
||||
if out != nil {
|
||||
r, err := dhcp4.Parse(out.Packet.Marshal())
|
||||
if err != nil {
|
||||
h.t.Fatal(err)
|
||||
}
|
||||
out.Packet = r
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *harness) discover(iface string, m net.HardwareAddr, opts ...dhcp4.Option) *Outbound {
|
||||
p := dhcp4.NewRequest(dhcp4.Discover, m, 1)
|
||||
for _, o := range opts {
|
||||
p.Set(o.Code, o.Data)
|
||||
}
|
||||
return h.send(iface, p)
|
||||
}
|
||||
|
||||
// lease runs DISCOVER and REQUEST and returns the ACK.
|
||||
func (h *harness) lease(iface string, m net.HardwareAddr) *dhcp4.Packet {
|
||||
h.t.Helper()
|
||||
off := h.discover(iface, m)
|
||||
if off == nil || off.Packet.Type() != dhcp4.Offer {
|
||||
h.t.Fatalf("no offer for %s on %s", m, iface)
|
||||
}
|
||||
sid, _ := off.Packet.IPOption(dhcp4.OptServerID)
|
||||
req := dhcp4.NewRequest(dhcp4.Request, m, 2)
|
||||
req.Set(dhcp4.OptServerID, dhcp4.IP(sid))
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(off.Packet.YIAddr))
|
||||
req.Set(dhcp4.OptHostName, []byte("client"+m.String()[15:]))
|
||||
ack := h.send(iface, req)
|
||||
if ack == nil || ack.Packet.Type() != dhcp4.Ack {
|
||||
h.t.Fatalf("no ACK for %s: %+v", m, ack)
|
||||
}
|
||||
if ack.Packet.YIAddr != off.Packet.YIAddr {
|
||||
h.t.Fatalf("ACK %s differs from offer %s", ack.Packet.YIAddr, off.Packet.YIAddr)
|
||||
}
|
||||
return ack.Packet
|
||||
}
|
||||
|
||||
func ipOpt(p *dhcp4.Packet, code byte) string {
|
||||
return dhcp4.Decode(dhcp4.TypeIPs, p.Get(code))
|
||||
}
|
||||
|
||||
func TestOfferAndAck(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
off := h.discover("eth0", mac(1))
|
||||
if off == nil {
|
||||
t.Fatal("no offer")
|
||||
}
|
||||
p := off.Packet
|
||||
if p.YIAddr.String() != "10.0.10.100" || off.Dst.IsValid() {
|
||||
t.Errorf("offered %s to %v", p.YIAddr, off.Dst)
|
||||
}
|
||||
if ipOpt(p, dhcp4.OptServerID) != "10.0.10.2" || ipOpt(p, dhcp4.OptSubnetMask) != "255.255.255.0" || ipOpt(p, dhcp4.OptRouters) != "10.0.10.1" {
|
||||
t.Errorf("options: sid %s mask %s routers %s", ipOpt(p, 54), ipOpt(p, 1), ipOpt(p, 3))
|
||||
}
|
||||
// The subnet's DNS server wins over the settings'.
|
||||
if ipOpt(p, dhcp4.OptDNSServers) != "10.0.10.2" || string(p.Get(dhcp4.OptDomainName)) != "lan" {
|
||||
t.Errorf("dns %s domain %q", ipOpt(p, 6), p.Get(15))
|
||||
}
|
||||
if dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptLeaseTime)) != "43200" || dhcp4.Decode(dhcp4.TypeU32, p.Get(dhcp4.OptRenewalTime)) != "21600" {
|
||||
t.Errorf("lease time %v", p.Get(51))
|
||||
}
|
||||
ack := h.lease("eth0", mac(1))
|
||||
l := h.s.Lease(ack.YIAddr)
|
||||
if l == nil || l.State != model.LeaseActive || l.MAC != mac(1).String() || l.Interface != "eth0" || l.SubnetID != 1 || l.Hostname != "client01" {
|
||||
t.Fatalf("lease %+v", l)
|
||||
}
|
||||
// Saved before the ACK.
|
||||
saved, _ := h.st.LoadLeases(context.Background())
|
||||
if len(saved) != 1 || saved[0].IP != ack.YIAddr {
|
||||
t.Errorf("saved %+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubnetsPerInterface(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
if ip := h.lease("eth0.20", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.20.") {
|
||||
t.Errorf("a client on eth0.20 got %s", ip)
|
||||
}
|
||||
// The same MAC on the office VLAN gets an office address.
|
||||
if ip := h.lease("eth0", mac(1)).YIAddr; !strings.HasPrefix(ip.String(), "10.0.10.") {
|
||||
t.Errorf("a client on eth0 got %s", ip)
|
||||
}
|
||||
// No subnet on eth1: no answer at all.
|
||||
if out := h.discover("eth1", mac(2)); out != nil {
|
||||
t.Errorf("answered on eth1: %+v", out)
|
||||
}
|
||||
// Asking eth0.20 for an office address: NAK.
|
||||
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.100")))
|
||||
out := h.send("eth0.20", req)
|
||||
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "does not belong on eth0.20") {
|
||||
t.Errorf("init-reboot on the wrong VLAN: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReservations(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
ack := h.lease("eth0", mac(0x99))
|
||||
if ack.YIAddr.String() != "10.0.10.20" || string(ack.Get(dhcp4.OptHostName)) != "nas" || ipOpt(ack, 6) != "1.1.1.1" {
|
||||
t.Errorf("reserved client got %s host %q dns %s", ack.YIAddr, ack.Get(12), ipOpt(ack, 6))
|
||||
}
|
||||
// 10.0.10.101 is reserved for the printer: nobody else gets it, even
|
||||
// though it is in the pool.
|
||||
got := map[string]bool{}
|
||||
for i := byte(1); i <= 3; i++ {
|
||||
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
|
||||
}
|
||||
if got["10.0.10.101"] || !got["10.0.10.100"] || !got["10.0.10.102"] || !got["10.0.10.103"] {
|
||||
t.Errorf("pool addresses handed out: %v", got)
|
||||
}
|
||||
// The pool is now full (the reserved address doesn't count).
|
||||
if out := h.discover("eth0", mac(4)); out != nil {
|
||||
t.Errorf("offered %s from a full pool", out.Packet.YIAddr)
|
||||
}
|
||||
if h.lease("eth0", mac(0x98)).YIAddr.String() != "10.0.10.101" {
|
||||
t.Error("the printer didn't get its reservation")
|
||||
}
|
||||
// A client with a reservation asking for another address gets a NAK
|
||||
// that says why.
|
||||
req := dhcp4.NewRequest(dhcp4.Request, mac(0x99), 7)
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.102")))
|
||||
out := h.send("eth0", req)
|
||||
if out == nil || out.Packet.Type() != dhcp4.Nak || !strings.Contains(string(out.Packet.Get(dhcp4.OptMessage)), "reservation for 10.0.10.20") {
|
||||
t.Errorf("got %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReturningClientKeepsAddress(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
first := h.lease("eth0", mac(1)).YIAddr
|
||||
h.lease("eth0", mac(2))
|
||||
rel := dhcp4.NewRequest(dhcp4.Release, mac(1), 9)
|
||||
rel.CIAddr = first
|
||||
if out := h.send("eth0", rel); out != nil {
|
||||
t.Error("a release was answered")
|
||||
}
|
||||
if l := h.s.Lease(first); l.State != model.LeaseReleased {
|
||||
t.Errorf("after release: %+v", l)
|
||||
}
|
||||
h.clock = h.clock.Add(time.Hour)
|
||||
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
|
||||
t.Errorf("came back to %s, had %s", again, first)
|
||||
}
|
||||
// Expired leases too.
|
||||
h.clock = h.clock.Add(48 * time.Hour)
|
||||
if again := h.lease("eth0", mac(1)).YIAddr; again != first {
|
||||
t.Errorf("after expiry got %s, had %s", again, first)
|
||||
}
|
||||
// A new client gets a never-used address before a released one.
|
||||
if ip := h.lease("eth0", mac(3)).YIAddr; ip == first || ip.String() == "10.0.10.101" {
|
||||
t.Errorf("new client got %s", ip)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenewAndRebind(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
ack := h.lease("eth0.20", mac(1))
|
||||
start := h.s.Lease(ack.YIAddr).Start
|
||||
h.clock = h.clock.Add(30 * time.Minute)
|
||||
ren := dhcp4.NewRequest(dhcp4.Request, mac(1), 11)
|
||||
ren.CIAddr = ack.YIAddr
|
||||
out := h.send("eth0.20", ren)
|
||||
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != ack.YIAddr || out.Packet.CIAddr != ack.YIAddr {
|
||||
t.Fatalf("renewal: %+v", out)
|
||||
}
|
||||
l := h.s.Lease(ack.YIAddr)
|
||||
if l.Hostname != "client01" {
|
||||
t.Errorf("a renewal without a host name lost it: %q", l.Hostname)
|
||||
}
|
||||
if !l.Start.Equal(start) || !l.End.Equal(h.clock.Add(time.Hour)) {
|
||||
t.Errorf("renewed lease %v - %v", l.Start, l.End)
|
||||
}
|
||||
// Another client renewing that address: NAK.
|
||||
ren2 := dhcp4.NewRequest(dhcp4.Request, mac(2), 12)
|
||||
ren2.CIAddr = ack.YIAddr
|
||||
if out := h.send("eth0.20", ren2); out == nil || out.Packet.Type() != dhcp4.Nak {
|
||||
t.Errorf("someone else's renewal: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitReboot(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
ip := h.lease("eth0", mac(1)).YIAddr
|
||||
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 20)
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
|
||||
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst.IsValid() {
|
||||
t.Errorf("own address after reboot: %+v", out)
|
||||
}
|
||||
// Someone else's address: NAK.
|
||||
req = dhcp4.NewRequest(dhcp4.Request, mac(2), 21)
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
|
||||
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
|
||||
t.Errorf("another's address: %+v", out)
|
||||
}
|
||||
// A free pool address from a client Steward hasn't seen (say, after
|
||||
// switching from another server): ACK.
|
||||
req = dhcp4.NewRequest(dhcp4.Request, mac(3), 22)
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.103")))
|
||||
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Ack {
|
||||
t.Errorf("free address: %+v", out)
|
||||
}
|
||||
// An address outside the pools: NAK.
|
||||
req = dhcp4.NewRequest(dhcp4.Request, mac(4), 23)
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.50")))
|
||||
if out := h.send("eth0", req); out == nil || out.Packet.Type() != dhcp4.Nak {
|
||||
t.Errorf("address outside the pools: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOtherServerChosen(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
off := h.discover("eth0", mac(1))
|
||||
req := dhcp4.NewRequest(dhcp4.Request, mac(1), 2)
|
||||
req.Set(dhcp4.OptServerID, dhcp4.IP(netip.MustParseAddr("10.0.10.254")))
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("10.0.10.77")))
|
||||
if out := h.send("eth0", req); out != nil {
|
||||
t.Errorf("answered a request to another server: %+v", out)
|
||||
}
|
||||
// The offer was given up: another client can have the address.
|
||||
if out := h.discover("eth0", mac(2)); out == nil || out.Packet.YIAddr != off.Packet.YIAddr {
|
||||
t.Errorf("offer not released: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclineAndPingConflict(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
ip := h.lease("eth0", mac(1)).YIAddr
|
||||
dec := dhcp4.NewRequest(dhcp4.Decline, mac(1), 30)
|
||||
dec.Set(dhcp4.OptRequestedIP, dhcp4.IP(ip))
|
||||
h.send("eth0", dec)
|
||||
if l := h.s.Lease(ip); l.State != model.LeaseDeclined || !l.End.Equal(h.clock.Add(24*time.Hour)) {
|
||||
t.Errorf("declined lease %+v", l)
|
||||
}
|
||||
// Not offered again, not even to the client that declined it.
|
||||
if out := h.discover("eth0", mac(1)); out == nil || out.Packet.YIAddr == ip {
|
||||
t.Errorf("declined address offered again: %+v", out)
|
||||
}
|
||||
// An address that answers a ping is skipped and held back.
|
||||
h2 := newHarness(t, nil)
|
||||
h2.ping.answers[netip.MustParseAddr("10.0.10.100")] = true
|
||||
off := h2.discover("eth0", mac(1))
|
||||
if off == nil || off.Packet.YIAddr.String() != "10.0.10.102" {
|
||||
t.Fatalf("offer after a conflict: %+v", off)
|
||||
}
|
||||
if l := h2.s.Lease(netip.MustParseAddr("10.0.10.100")); l == nil || l.State != model.LeaseDeclined {
|
||||
t.Errorf("conflict not recorded: %+v", l)
|
||||
}
|
||||
// Reservations aren't pinged (the device may still have its address).
|
||||
h2.ping.pinged = nil
|
||||
h2.lease("eth0", mac(0x99))
|
||||
for _, a := range h2.ping.pinged {
|
||||
if a.String() == "10.0.10.20" {
|
||||
t.Error("pinged a reserved address")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedNetwork(t *testing.T) {
|
||||
h := newHarness(t, func(st *model.State) {
|
||||
st.Subnets = append(st.Subnets, &model.Subnet{ID: 5, Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.11.0/24"), Pools: []model.Pool{pool("10.0.11.10-10.0.11.11")}})
|
||||
})
|
||||
got := map[string]bool{}
|
||||
for i := byte(1); i <= 5; i++ {
|
||||
got[h.lease("eth0", mac(i)).YIAddr.String()] = true
|
||||
}
|
||||
if !got["10.0.11.10"] || !got["10.0.11.11"] {
|
||||
t.Errorf("second subnet not used: %v", got)
|
||||
}
|
||||
// Its clients are answered from the interface's address (10.0.10.2),
|
||||
// with the second subnet's mask.
|
||||
l := h.s.Lease(netip.MustParseAddr("10.0.11.10"))
|
||||
ren := dhcp4.NewRequest(dhcp4.Request, net.HardwareAddr(mustMAC(l.MAC)), 3)
|
||||
ren.CIAddr = l.IP
|
||||
out := h.send("eth0", ren)
|
||||
if out == nil || ipOpt(out.Packet, 54) != "10.0.10.2" || ipOpt(out.Packet, 1) != "255.255.255.0" {
|
||||
t.Errorf("renewal in the second subnet: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func mustMAC(s string) net.HardwareAddr {
|
||||
m, err := net.ParseMAC(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func TestParameterRequestList(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
off := h.discover("eth0", mac(1), dhcp4.Option{Code: dhcp4.OptParamRequest, Data: []byte{42, 1}})
|
||||
var codes []byte
|
||||
for _, o := range off.Packet.Options {
|
||||
codes = append(codes, o.Code)
|
||||
}
|
||||
// Type, server ID and lease times; then NTP and mask as asked; then
|
||||
// the essentials the client didn't ask for.
|
||||
want := []byte{53, 54, 51, 58, 59, 42, 1, 3, 6, 15}
|
||||
if string(codes) != string(want) {
|
||||
t.Errorf("options %v, want %v", codes, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInformAndRelay(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
inf := dhcp4.NewRequest(dhcp4.Inform, mac(1), 40)
|
||||
inf.CIAddr = netip.MustParseAddr("10.0.10.50")
|
||||
out := h.send("eth0", inf)
|
||||
if out == nil || out.Packet.Type() != dhcp4.Ack || out.Dst != inf.CIAddr || out.Packet.Has(dhcp4.OptLeaseTime) || ipOpt(out.Packet, 3) != "10.0.10.1" {
|
||||
t.Errorf("inform: %+v", out)
|
||||
}
|
||||
rel := dhcp4.NewRequest(dhcp4.Discover, mac(2), 41)
|
||||
rel.GIAddr = netip.MustParseAddr("10.0.30.1")
|
||||
if out := h.send("eth0", rel); out != nil {
|
||||
t.Error("answered a relayed request")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotAuthoritative(t *testing.T) {
|
||||
h := newHarness(t, func(st *model.State) { st.Settings.NotAuthoritative = true })
|
||||
req := dhcp4.NewRequest(dhcp4.Request, mac(3), 5)
|
||||
req.Set(dhcp4.OptRequestedIP, dhcp4.IP(netip.MustParseAddr("192.168.7.7")))
|
||||
if out := h.send("eth0", req); out != nil {
|
||||
t.Errorf("NAK without authority: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledSubnet(t *testing.T) {
|
||||
h := newHarness(t, func(st *model.State) { st.Subnets[1].Disabled = true })
|
||||
if out := h.discover("eth0.20", mac(1)); out != nil {
|
||||
t.Error("a disabled subnet answered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrune(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
ip := h.lease("eth0", mac(1)).YIAddr
|
||||
h.clock = h.clock.Add(40 * 24 * time.Hour)
|
||||
h.s.Prune(context.Background())
|
||||
if h.s.Lease(ip) != nil {
|
||||
t.Error("old lease kept")
|
||||
}
|
||||
if ls, _ := h.st.LoadLeases(context.Background()); len(ls) != 0 {
|
||||
t.Errorf("old lease still saved: %+v", ls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
)
|
||||
|
||||
// packetConn is what a listener reads from and writes to.
|
||||
type packetConn interface {
|
||||
ReadFromUDPAddrPort(b []byte) (int, netip.AddrPort, error)
|
||||
WriteToUDPAddrPort(b []byte, addr netip.AddrPort) (int, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
type listener struct {
|
||||
conn packetConn
|
||||
err string
|
||||
since time.Time
|
||||
}
|
||||
|
||||
type listeners struct {
|
||||
mu sync.Mutex
|
||||
open map[string]*listener
|
||||
// failed are interfaces whose socket couldn't be opened, with why.
|
||||
failed map[string]*listener
|
||||
}
|
||||
|
||||
// listenFunc opens the socket for an interface (listen_linux.go); tests
|
||||
// replace it.
|
||||
var listenFunc = listenInterface
|
||||
|
||||
// Run serves DHCP until ctx ends: it opens a socket on every interface
|
||||
// that has a subnet, and follows configuration changes. Interfaces that
|
||||
// don't exist yet (a VLAN that comes up later) are retried every 30
|
||||
// seconds.
|
||||
func (s *Server) Run(ctx context.Context) {
|
||||
t := time.NewTicker(30 * time.Second)
|
||||
defer t.Stop()
|
||||
prune := time.NewTicker(time.Hour)
|
||||
defer prune.Stop()
|
||||
for {
|
||||
s.reconcile(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.closeAll()
|
||||
return
|
||||
case <-s.changed:
|
||||
case <-t.C:
|
||||
case <-prune.C:
|
||||
s.Prune(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) reconcile(ctx context.Context) {
|
||||
want := map[string]bool{}
|
||||
for iface := range s.cfg.Load().byIface {
|
||||
want[iface] = true
|
||||
}
|
||||
s.listen.mu.Lock()
|
||||
defer s.listen.mu.Unlock()
|
||||
if s.listen.open == nil {
|
||||
s.listen.open, s.listen.failed = map[string]*listener{}, map[string]*listener{}
|
||||
}
|
||||
for iface, l := range s.listen.open {
|
||||
if !want[iface] {
|
||||
l.conn.Close()
|
||||
delete(s.listen.open, iface)
|
||||
s.log.Info("stopped serving DHCP on an interface (no subnets left)", "interface", iface)
|
||||
}
|
||||
}
|
||||
for iface := range s.listen.failed {
|
||||
if !want[iface] {
|
||||
delete(s.listen.failed, iface)
|
||||
}
|
||||
}
|
||||
for iface := range want {
|
||||
if s.listen.open[iface] != nil {
|
||||
continue
|
||||
}
|
||||
conn, err := listenFunc(iface, s.ServerPort)
|
||||
if err != nil {
|
||||
if f := s.listen.failed[iface]; f == nil || f.err != err.Error() {
|
||||
s.log.Warn("can't serve DHCP on an interface; retrying every 30 seconds", "interface", iface, "err", err)
|
||||
s.listen.failed[iface] = &listener{err: err.Error(), since: s.now()}
|
||||
}
|
||||
continue
|
||||
}
|
||||
delete(s.listen.failed, iface)
|
||||
l := &listener{conn: conn, since: s.now()}
|
||||
s.listen.open[iface] = l
|
||||
s.log.Info("serving DHCP", "interface", iface, "port", s.ServerPort)
|
||||
go s.serve(ctx, iface, conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) closeAll() {
|
||||
s.listen.mu.Lock()
|
||||
defer s.listen.mu.Unlock()
|
||||
for iface, l := range s.listen.open {
|
||||
l.conn.Close()
|
||||
delete(s.listen.open, iface)
|
||||
}
|
||||
}
|
||||
|
||||
// serve reads requests from one interface's socket. Each is answered in
|
||||
// its own goroutine (a ping check takes up to its timeout), at most 32 at
|
||||
// a time.
|
||||
func (s *Server) serve(ctx context.Context, iface string, conn packetConn) {
|
||||
sem := make(chan struct{}, 32)
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, src, err := conn.ReadFromUDPAddrPort(buf)
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) || ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.warnOnce("read "+iface, time.Minute, "reading a DHCP request failed", "interface", iface, "err", err)
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
p, err := dhcp4.Parse(append([]byte(nil), buf[:n]...))
|
||||
if err != nil {
|
||||
s.warnOnce("parse "+iface+" "+src.Addr().String(), 10*time.Minute, "ignoring a malformed DHCP message", "interface", iface, "from", src, "err", err)
|
||||
continue
|
||||
}
|
||||
sem <- struct{}{}
|
||||
go func() {
|
||||
defer func() {
|
||||
<-sem
|
||||
if v := recover(); v != nil {
|
||||
s.log.Error("panic answering a DHCP request", "interface", iface, "panic", v)
|
||||
}
|
||||
}()
|
||||
if out := s.Handle(ctx, iface, p); out != nil {
|
||||
s.send(conn, iface, out)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) send(conn packetConn, iface string, out *Outbound) {
|
||||
dst := netip.AddrPortFrom(broadcast, uint16(s.ClientPort))
|
||||
if out.Dst.IsValid() {
|
||||
dst = netip.AddrPortFrom(out.Dst, uint16(s.ClientPort))
|
||||
}
|
||||
if _, err := conn.WriteToUDPAddrPort(out.Packet.Marshal(), dst); err != nil {
|
||||
s.warnOnce("send "+iface, time.Minute, "sending a DHCP reply failed", "interface", iface, "to", dst, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// InterfaceStatus says whether DHCP is served on an interface.
|
||||
type InterfaceStatus struct {
|
||||
Name string `json:"name"`
|
||||
Listening bool `json:"listening"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Since time.Time `json:"since"`
|
||||
Addresses []string `json:"addresses"`
|
||||
Subnets []int64 `json:"subnets"`
|
||||
}
|
||||
|
||||
// Status lists the interfaces that have subnets.
|
||||
func (s *Server) Status() []InterfaceStatus {
|
||||
cfg := s.cfg.Load()
|
||||
s.listen.mu.Lock()
|
||||
defer s.listen.mu.Unlock()
|
||||
var out []InterfaceStatus
|
||||
for iface, subs := range cfg.byIface {
|
||||
st := InterfaceStatus{Name: iface, Addresses: []string{}, Subnets: []int64{}}
|
||||
for _, sub := range subs {
|
||||
st.Subnets = append(st.Subnets, sub.ID)
|
||||
}
|
||||
if l := s.listen.open[iface]; l != nil {
|
||||
st.Listening, st.Since = true, l.since
|
||||
} else if l := s.listen.failed[iface]; l != nil {
|
||||
st.Error, st.Since = l.err, l.since
|
||||
} else {
|
||||
st.Error = "starting"
|
||||
}
|
||||
if addrs, err := s.ifaces.Addrs(iface); err == nil {
|
||||
for _, a := range addrs {
|
||||
st.Addresses = append(st.Addresses, a.String())
|
||||
}
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out
|
||||
}
|
||||
|
||||
// explainListenError adds what to do to a socket error.
|
||||
func explainListenError(iface string, port int, err error) error {
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "no such device"), strings.Contains(msg, "no such network interface"):
|
||||
return errors.New("there is no interface " + iface + " on this server (yet)")
|
||||
case strings.Contains(msg, "permission denied") && port < 1024:
|
||||
return errors.New(msg + " (port 67 needs the CAP_NET_BIND_SERVICE capability; steward.service grants it, so check the installed unit: systemctl cat steward)")
|
||||
case strings.Contains(msg, "address already in use"):
|
||||
return errors.New(msg + " (another DHCP server, such as Kea or dnsmasq, is listening on this interface; stop it first)")
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strconv"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// listenInterface opens a UDP socket on the DHCP server port that only
|
||||
// sees the interface's traffic (SO_BINDTODEVICE), so a request's
|
||||
// interface is known, and that may send broadcasts out of it. Sockets for
|
||||
// several interfaces share the port (SO_REUSEADDR with different
|
||||
// devices).
|
||||
func listenInterface(iface string, port int) (packetConn, error) {
|
||||
if _, err := net.InterfaceByName(iface); err != nil {
|
||||
return nil, explainListenError(iface, port, err)
|
||||
}
|
||||
var serr error
|
||||
lc := net.ListenConfig{Control: func(network, address string, c syscall.RawConn) error {
|
||||
err := c.Control(func(fd uintptr) {
|
||||
if serr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1); serr != nil {
|
||||
return
|
||||
}
|
||||
if serr = syscall.SetsockoptInt(int(fd), syscall.SOL_SOCKET, syscall.SO_BROADCAST, 1); serr != nil {
|
||||
return
|
||||
}
|
||||
serr = syscall.SetsockoptString(int(fd), syscall.SOL_SOCKET, syscall.SO_BINDTODEVICE, iface)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return serr
|
||||
}}
|
||||
pc, err := lc.ListenPacket(context.Background(), "udp4", "0.0.0.0:"+strconv.Itoa(port))
|
||||
if err != nil {
|
||||
return nil, explainListenError(iface, port, err)
|
||||
}
|
||||
return pc.(*net.UDPConn), nil
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package dhcpd
|
||||
|
||||
import "errors"
|
||||
|
||||
func listenInterface(iface string, port int) (packetConn, error) {
|
||||
return nil, errors.New("Steward's DHCP server runs on Linux only")
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
)
|
||||
|
||||
// ICMPPinger pings with an unprivileged ICMP socket (Linux "ping sockets",
|
||||
// allowed for the groups in net.ipv4.ping_group_range, which systemd opens
|
||||
// to everyone). When the socket can't be opened, the check is skipped and
|
||||
// Unavailable says why.
|
||||
type ICMPPinger struct {
|
||||
Log *slog.Logger
|
||||
seq atomic.Uint32
|
||||
once sync.Once
|
||||
// Unavailable is why pings can't be sent ("" when they can).
|
||||
Unavailable atomic.Value
|
||||
}
|
||||
|
||||
// Ping reports whether a answers an echo request within timeout.
|
||||
func (p *ICMPPinger) Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool {
|
||||
conn, err := icmp.ListenPacket("udp4", "0.0.0.0")
|
||||
if err != nil {
|
||||
p.once.Do(func() {
|
||||
msg := "can't send pings, so addresses aren't checked before they are offered: " + err.Error() + " (check net.ipv4.ping_group_range)"
|
||||
p.Unavailable.Store(msg)
|
||||
if p.Log != nil {
|
||||
p.Log.Warn(msg)
|
||||
}
|
||||
})
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
seq := int(p.seq.Add(1) & 0xffff)
|
||||
msg := icmp.Message{Type: ipv4.ICMPTypeEcho, Body: &icmp.Echo{ID: os.Getpid() & 0xffff, Seq: seq, Data: []byte("steward conflict check")}}
|
||||
b, err := msg.Marshal(nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
dst := &net.UDPAddr{IP: a.AsSlice()}
|
||||
if _, err := conn.WriteTo(b, dst); err != nil {
|
||||
return false
|
||||
}
|
||||
deadline := time.Now().Add(timeout)
|
||||
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
|
||||
deadline = d
|
||||
}
|
||||
conn.SetReadDeadline(deadline)
|
||||
buf := make([]byte, 1500)
|
||||
for {
|
||||
n, from, err := conn.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
ua, ok := from.(*net.UDPAddr)
|
||||
if !ok || !ua.IP.Equal(dst.IP) {
|
||||
continue
|
||||
}
|
||||
m, err := icmp.ParseMessage(1, buf[:n])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if e, ok := m.Body.(*icmp.Echo); ok && m.Type == ipv4.ICMPTypeEchoReply && e.Seq == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Problem says why pings can't be sent ("" when they can).
|
||||
func (p *ICMPPinger) Problem() string {
|
||||
if v, ok := p.Unavailable.Load().(string); ok {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
// Package dhcpd is Steward's DHCPv4 server: it listens on the interfaces
|
||||
// that have subnets, answers requests from those subnets only, and keeps
|
||||
// the leases (docs/design.md, "Answering DHCP").
|
||||
package dhcpd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// LeaseStore persists leases.
|
||||
type LeaseStore interface {
|
||||
LoadLeases(ctx context.Context) ([]*model.Lease, error)
|
||||
PutLease(ctx context.Context, l *model.Lease) error
|
||||
DeleteLease(ctx context.Context, ip netip.Addr) error
|
||||
PruneLeases(ctx context.Context, before time.Time) (int64, error)
|
||||
}
|
||||
|
||||
// Interfaces reports a network interface's IPv4 addresses with their
|
||||
// prefix lengths (10.0.10.1/24); an error when it doesn't exist.
|
||||
type Interfaces interface {
|
||||
Addrs(name string) ([]netip.Prefix, error)
|
||||
}
|
||||
|
||||
// Pinger checks whether an address answers (conflict detection).
|
||||
type Pinger interface {
|
||||
Ping(ctx context.Context, a netip.Addr, timeout time.Duration) bool
|
||||
}
|
||||
|
||||
// How long an offer holds its address for the client.
|
||||
const offerHold = time.Minute
|
||||
|
||||
// Leases that ended this long ago are deleted (until then, a client that
|
||||
// comes back gets its old address if it is free).
|
||||
const keepEnded = 30 * 24 * time.Hour
|
||||
|
||||
// Server answers DHCP requests.
|
||||
type Server struct {
|
||||
log *slog.Logger
|
||||
store LeaseStore
|
||||
ifaces Interfaces
|
||||
pinger Pinger
|
||||
now func() time.Time
|
||||
|
||||
// ServerPort and ClientPort are 67 and 68 (tests use others).
|
||||
ServerPort, ClientPort int
|
||||
|
||||
cfg atomic.Pointer[config]
|
||||
|
||||
mu sync.Mutex
|
||||
leases map[netip.Addr]*model.Lease
|
||||
offers map[netip.Addr]offer
|
||||
|
||||
events events
|
||||
warned sync.Map // throttled log lines: key -> time.Time
|
||||
listen listeners
|
||||
changed chan struct{}
|
||||
}
|
||||
|
||||
type offer struct {
|
||||
cl client
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
// New makes a server. ifaces and pinger may be nil (the host's interfaces;
|
||||
// no ping check).
|
||||
func New(log *slog.Logger, st LeaseStore, ifaces Interfaces, pinger Pinger) *Server {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
if ifaces == nil {
|
||||
ifaces = &HostInterfaces{}
|
||||
}
|
||||
s := &Server{log: log, store: st, ifaces: ifaces, pinger: pinger, now: time.Now, ServerPort: 67, ClientPort: 68,
|
||||
leases: map[netip.Addr]*model.Lease{}, offers: map[netip.Addr]offer{}, changed: make(chan struct{}, 1)}
|
||||
s.cfg.Store(compile(&model.State{Settings: model.DefaultSettings()}))
|
||||
s.events.max = 1000
|
||||
return s
|
||||
}
|
||||
|
||||
// LoadLeases reads the leases from the store (at start).
|
||||
func (s *Server) LoadLeases(ctx context.Context) error {
|
||||
ls, err := s.store.LoadLeases(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, l := range ls {
|
||||
s.leases[l.IP] = l
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetConfig gives the server a new configuration. Listeners follow it.
|
||||
func (s *Server) SetConfig(st *model.State) {
|
||||
s.cfg.Store(compile(st))
|
||||
select {
|
||||
case s.changed <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Leases returns a copy of every lease, by address.
|
||||
func (s *Server) Leases() []*model.Lease {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
out := make([]*model.Lease, 0, len(s.leases))
|
||||
for _, l := range s.leases {
|
||||
c := *l
|
||||
out = append(out, &c)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
|
||||
return out
|
||||
}
|
||||
|
||||
// Lease returns one lease.
|
||||
func (s *Server) Lease(ip netip.Addr) *model.Lease {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if l := s.leases[ip]; l != nil {
|
||||
c := *l
|
||||
return &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrNoLease is returned for an address without a lease.
|
||||
var ErrNoLease = errors.New("no lease for that address")
|
||||
|
||||
// DeleteLease forgets a lease: the address is free at once (the client
|
||||
// may still be using it until it next renews, and then gets a NAK).
|
||||
func (s *Server) DeleteLease(ctx context.Context, ip netip.Addr) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.leases[ip] == nil {
|
||||
return ErrNoLease
|
||||
}
|
||||
if err := s.store.DeleteLease(ctx, ip); err != nil {
|
||||
return err
|
||||
}
|
||||
delete(s.leases, ip)
|
||||
delete(s.offers, ip)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportLeases adds leases (from another server), replacing what is known
|
||||
// for their addresses.
|
||||
func (s *Server) ImportLeases(ctx context.Context, ls []*model.Lease) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, l := range ls {
|
||||
if err := s.store.PutLease(ctx, l); err != nil {
|
||||
return err
|
||||
}
|
||||
c := *l
|
||||
s.leases[l.IP] = &c
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Prune deletes leases that ended long ago, and forgets old offers.
|
||||
func (s *Server) Prune(ctx context.Context) {
|
||||
now := s.now()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cut := now.Add(-keepEnded)
|
||||
n := 0
|
||||
for ip, l := range s.leases {
|
||||
if l.End.Before(cut) {
|
||||
delete(s.leases, ip)
|
||||
n++
|
||||
}
|
||||
}
|
||||
for ip, o := range s.offers {
|
||||
if now.After(o.expires) {
|
||||
delete(s.offers, ip)
|
||||
}
|
||||
}
|
||||
if n > 0 {
|
||||
if _, err := s.store.PruneLeases(ctx, cut); err != nil {
|
||||
s.log.Warn("pruning old leases failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// putLease saves a lease, then keeps it (s.mu held).
|
||||
func (s *Server) putLease(ctx context.Context, l *model.Lease) error {
|
||||
l.Updated = s.now()
|
||||
if err := s.store.PutLease(ctx, l); err != nil {
|
||||
return err
|
||||
}
|
||||
c := *l
|
||||
s.leases[l.IP] = &c
|
||||
return nil
|
||||
}
|
||||
|
||||
// warnOnce logs a warning at most once per interval for a key.
|
||||
func (s *Server) warnOnce(key string, every time.Duration, msg string, args ...any) {
|
||||
now := s.now()
|
||||
if v, ok := s.warned.Load(key); ok && now.Sub(v.(time.Time)) < every {
|
||||
return
|
||||
}
|
||||
s.warned.Store(key, now)
|
||||
s.log.Warn(msg, args...)
|
||||
}
|
||||
|
||||
// ---- interfaces
|
||||
|
||||
// HostInterfaces reads this machine's interfaces (cached briefly, since
|
||||
// every request asks).
|
||||
type HostInterfaces struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
cache map[string][]netip.Prefix
|
||||
err error
|
||||
}
|
||||
|
||||
// Addrs implements Interfaces.
|
||||
func (h *HostInterfaces) Addrs(name string) ([]netip.Prefix, error) {
|
||||
all, err := h.All()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a, ok := all[name]
|
||||
if !ok {
|
||||
return nil, errors.New("no interface " + name)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// All returns every interface with its IPv4 prefixes.
|
||||
func (h *HostInterfaces) All() (map[string][]netip.Prefix, error) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if time.Since(h.at) < 5*time.Second && (h.cache != nil || h.err != nil) {
|
||||
return h.cache, h.err
|
||||
}
|
||||
h.at = time.Now()
|
||||
ifs, err := net.Interfaces()
|
||||
if err != nil {
|
||||
h.cache, h.err = nil, err
|
||||
return nil, err
|
||||
}
|
||||
m := map[string][]netip.Prefix{}
|
||||
for _, i := range ifs {
|
||||
var ps []netip.Prefix
|
||||
addrs, _ := i.Addrs()
|
||||
for _, a := range addrs {
|
||||
if n, ok := a.(*net.IPNet); ok {
|
||||
if ip4 := n.IP.To4(); ip4 != nil {
|
||||
bits, _ := n.Mask.Size()
|
||||
ps = append(ps, netip.PrefixFrom(netip.AddrFrom4([4]byte(ip4)), bits))
|
||||
}
|
||||
}
|
||||
}
|
||||
m[i.Name] = ps
|
||||
}
|
||||
h.cache, h.err = m, nil
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// serverAddr is the interface's address to answer a subnet's clients
|
||||
// from: its address in the subnet, else its first IPv4 address.
|
||||
func (s *Server) serverAddr(iface string, sub *subnet) (netip.Addr, error) {
|
||||
addrs, err := s.ifaces.Addrs(iface)
|
||||
if err != nil {
|
||||
return netip.Addr{}, err
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if sub != nil && sub.Prefix.Contains(a.Addr()) {
|
||||
return a.Addr(), nil
|
||||
}
|
||||
}
|
||||
if len(addrs) > 0 {
|
||||
return addrs[0].Addr(), nil
|
||||
}
|
||||
return netip.Addr{}, errors.New("interface " + iface + " has no IPv4 address")
|
||||
}
|
||||
|
||||
// ownAddrs is the set of the interface's addresses.
|
||||
func (s *Server) ownAddrs(iface string) map[netip.Addr]bool {
|
||||
addrs, _ := s.ifaces.Addrs(iface)
|
||||
m := make(map[netip.Addr]bool, len(addrs))
|
||||
for _, p := range addrs {
|
||||
m[p.Addr()] = true
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// isOwnAddr reports whether a is one of the interface's addresses.
|
||||
func (s *Server) isOwnAddr(iface string, a netip.Addr) bool {
|
||||
addrs, _ := s.ifaces.Addrs(iface)
|
||||
for _, p := range addrs {
|
||||
if p.Addr() == a {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- events
|
||||
|
||||
// Event is one DHCP message in or out, for the activity log.
|
||||
type Event struct {
|
||||
Time time.Time `json:"time"`
|
||||
Interface string `json:"interface"`
|
||||
Type string `json:"type"` // DHCPDISCOVER, DHCPOFFER, ...
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Hostname string `json:"hostname,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
type events struct {
|
||||
mu sync.Mutex
|
||||
max int
|
||||
list []Event
|
||||
counts map[string]uint64
|
||||
}
|
||||
|
||||
func (e *events) add(ev Event) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
e.list = append(e.list, ev)
|
||||
if len(e.list) > e.max {
|
||||
e.list = append([]Event(nil), e.list[len(e.list)-e.max:]...)
|
||||
}
|
||||
if e.counts == nil {
|
||||
e.counts = map[string]uint64{}
|
||||
}
|
||||
e.counts[ev.Type]++
|
||||
}
|
||||
|
||||
// Events returns the latest events, newest first, optionally only those of
|
||||
// one MAC address or interface.
|
||||
func (s *Server) Events(limit int, mac, iface string) []Event {
|
||||
s.events.mu.Lock()
|
||||
defer s.events.mu.Unlock()
|
||||
out := []Event{}
|
||||
for i := len(s.events.list) - 1; i >= 0 && (limit <= 0 || len(out) < limit); i-- {
|
||||
ev := s.events.list[i]
|
||||
if mac != "" && !strings.EqualFold(ev.MAC, mac) || iface != "" && ev.Interface != iface {
|
||||
continue
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Counts returns how many messages of each type were seen and sent since
|
||||
// the start.
|
||||
func (s *Server) Counts() map[string]uint64 {
|
||||
s.events.mu.Lock()
|
||||
defer s.events.mu.Unlock()
|
||||
out := map[string]uint64{}
|
||||
for k, v := range s.events.counts {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// Package engine changes Steward's configuration. Every change, from the
|
||||
// API, the CLI or an import, goes through Apply: the change is made on a
|
||||
// copy of the state, the result is validated as a whole, and only then are
|
||||
// the changed objects and a history entry saved together and the new state
|
||||
// handed to the DHCP server. Revert undoes a history entry the same way.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"steward/internal/auth"
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
"steward/internal/validate"
|
||||
)
|
||||
|
||||
// Kinds of errors, for the API's status codes.
|
||||
const (
|
||||
KindBadRequest = "bad_request"
|
||||
KindNotFound = "not_found"
|
||||
KindConflict = "conflict"
|
||||
KindValidation = "validation"
|
||||
)
|
||||
|
||||
// Error is a refused change.
|
||||
type Error struct {
|
||||
Kind string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Problems []validate.Problem `json:"problems,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string { return e.Message }
|
||||
|
||||
// Errorf makes an Error.
|
||||
func Errorf(kind, format string, args ...any) *Error {
|
||||
return &Error{Kind: kind, Message: fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
// AsError unwraps an Error.
|
||||
func AsError(err error) (*Error, bool) {
|
||||
var e *Error
|
||||
ok := errors.As(err, &e)
|
||||
return e, ok
|
||||
}
|
||||
|
||||
// Result describes an applied (or previewed) change.
|
||||
type Result struct {
|
||||
Applied bool `json:"applied"`
|
||||
DryRun bool `json:"dry_run,omitempty"`
|
||||
HistoryID int64 `json:"history_id,omitempty"`
|
||||
Changes []model.Change `json:"changes"`
|
||||
Warnings []validate.Problem `json:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
// Engine holds the current configuration.
|
||||
type Engine struct {
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
// Env describes the host's interfaces for validation (nil: skip).
|
||||
Env func() *validate.Env
|
||||
// OnChange is called with each new state (the DHCP server's config).
|
||||
OnChange func(*model.State)
|
||||
|
||||
mu sync.Mutex
|
||||
state *model.State
|
||||
}
|
||||
|
||||
// New loads the configuration.
|
||||
func New(ctx context.Context, st *store.Store, log *slog.Logger) (*Engine, error) {
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s, err := st.LoadState(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
Normalize(s)
|
||||
return &Engine{store: st, log: log, state: s}, nil
|
||||
}
|
||||
|
||||
// Store is the engine's database.
|
||||
func (e *Engine) Store() *store.Store { return e.store }
|
||||
|
||||
// State returns a copy of the current configuration.
|
||||
func (e *Engine) State() *model.State {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.state.Clone()
|
||||
}
|
||||
|
||||
func (e *Engine) env() *validate.Env {
|
||||
if e.Env == nil {
|
||||
return nil
|
||||
}
|
||||
return e.Env()
|
||||
}
|
||||
|
||||
// Problems validates the current configuration (for the status page).
|
||||
func (e *Engine) Problems() []validate.Problem {
|
||||
return validate.Check(e.State(), e.env())
|
||||
}
|
||||
|
||||
// Edit changes a configuration in place. NewID gives IDs for new objects.
|
||||
type Edit func(st *model.State, newID func() int64) error
|
||||
|
||||
// Options say how to apply a change.
|
||||
type Options struct {
|
||||
DryRun bool
|
||||
Via string // web, cli, api, import
|
||||
}
|
||||
|
||||
// Apply makes a change: edit runs on a copy of the configuration, the
|
||||
// result is validated, and the changed objects are saved with a history
|
||||
// entry. A change that touches nothing is not recorded.
|
||||
func (e *Engine) Apply(ctx context.Context, p *auth.Principal, description string, opts Options, edit Edit) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
next := e.state.Clone()
|
||||
var idErr error
|
||||
newID := func() int64 {
|
||||
ids, err := e.store.AllocateIDs(ctx, 1)
|
||||
if err != nil {
|
||||
idErr = err
|
||||
return 0
|
||||
}
|
||||
return ids[0]
|
||||
}
|
||||
if err := edit(next, newID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if idErr != nil {
|
||||
return nil, idErr
|
||||
}
|
||||
return e.commit(ctx, p, description, opts, next, 0)
|
||||
}
|
||||
|
||||
// commit validates next and saves it as the new state.
|
||||
func (e *Engine) commit(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State, revertOf int64) (*Result, error) {
|
||||
Normalize(next)
|
||||
next.Sort()
|
||||
changes := Diff(e.state, next)
|
||||
res := &Result{DryRun: opts.DryRun, Changes: changes}
|
||||
if len(changes) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
problems := validate.Check(next, e.env())
|
||||
if validate.HasErrors(problems) {
|
||||
errs := validate.Errors(problems)
|
||||
msg := errs[0].Message
|
||||
if errs[0].Object != "" {
|
||||
msg = errs[0].Object + ": " + msg
|
||||
}
|
||||
if len(errs) > 1 {
|
||||
msg += fmt.Sprintf(" (and %d more problems)", len(errs)-1)
|
||||
}
|
||||
return res, &Error{Kind: KindValidation, Message: msg, Problems: errs}
|
||||
}
|
||||
// Only report warnings the change brings; old ones are on the status
|
||||
// page.
|
||||
res.Warnings = newProblems(validate.Check(e.state, e.env()), problems)
|
||||
if opts.DryRun {
|
||||
return res, nil
|
||||
}
|
||||
entry := &model.HistoryEntry{Time: time.Now(), Username: p.Name(), Via: opts.Via, Description: description, Changes: changes, RevertOf: revertOf}
|
||||
if p != nil && p.User != nil {
|
||||
entry.UserID = p.User.ID
|
||||
}
|
||||
if entry.Description == "" {
|
||||
entry.Description = Describe(changes)
|
||||
}
|
||||
if err := e.store.SaveChanges(ctx, changes, entry); err != nil {
|
||||
return res, err
|
||||
}
|
||||
e.state = next
|
||||
res.Applied, res.HistoryID = true, entry.ID
|
||||
e.Audit(ctx, p, "config.change", fmt.Sprintf("history %d", entry.ID), "success", entry.Description)
|
||||
e.log.Info("configuration changed", "history", entry.ID, "user", p.Name(), "via", opts.Via, "description", entry.Description)
|
||||
if e.OnChange != nil {
|
||||
e.OnChange(next.Clone())
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func newProblems(before, after []validate.Problem) []validate.Problem {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range before {
|
||||
seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] = true
|
||||
}
|
||||
var out []validate.Problem
|
||||
for _, p := range after {
|
||||
if !seen[p.Code+"\x00"+p.Object+"\x00"+p.Message] {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Revert undoes a history entry, if the objects it changed are still as
|
||||
// it left them.
|
||||
func (e *Engine) Revert(ctx context.Context, p *auth.Principal, id int64, opts Options) (*Result, error) {
|
||||
h, err := e.store.GetHistory(ctx, id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, Errorf(KindNotFound, "history entry %d not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.RevertedBy != 0 {
|
||||
return nil, Errorf(KindConflict, "history entry %d was already undone by entry %d", id, h.RevertedBy)
|
||||
}
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
cur := objects(e.state)
|
||||
next := e.state.Clone()
|
||||
for i := len(h.Changes) - 1; i >= 0; i-- {
|
||||
c := h.Changes[i]
|
||||
now := cur[key{c.Kind, c.ID}]
|
||||
if !sameJSON(now, c.After) {
|
||||
what := c.Label
|
||||
if what == "" {
|
||||
what = fmt.Sprintf("%s %d", c.Kind, c.ID)
|
||||
}
|
||||
return nil, Errorf(KindConflict, "%s %s has changed since entry %d; undo the later changes first, or change it by hand", c.Kind, what, id)
|
||||
}
|
||||
if err := put(next, c.Kind, c.ID, c.Before); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return e.commit(ctx, p, fmt.Sprintf("undo %d: %s", h.ID, h.Description), opts, next, h.ID)
|
||||
}
|
||||
|
||||
// Replace makes the configuration next as a whole (imports).
|
||||
func (e *Engine) Replace(ctx context.Context, p *auth.Principal, description string, opts Options, next *model.State) (*Result, error) {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return e.commit(ctx, p, description, opts, next.Clone(), 0)
|
||||
}
|
||||
|
||||
// Audit records an action in the audit log.
|
||||
func (e *Engine) Audit(ctx context.Context, p *auth.Principal, action, object, result, detail string) {
|
||||
ev := &model.AuditEvent{Action: action, Object: object, Result: result, Detail: detail}
|
||||
if p != nil {
|
||||
ev.Username, ev.Auth, ev.IP = p.Name(), p.Method, p.IP
|
||||
if p.User != nil {
|
||||
ev.UserID = p.User.ID
|
||||
}
|
||||
if p.Token != nil {
|
||||
ev.TokenID = p.Token.ID
|
||||
}
|
||||
}
|
||||
if err := e.store.AddAudit(ctx, ev); err != nil {
|
||||
e.log.Warn("writing the audit log failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- diffs
|
||||
|
||||
type key struct {
|
||||
kind string
|
||||
id int64
|
||||
}
|
||||
|
||||
func objects(st *model.State) map[key]json.RawMessage {
|
||||
m := map[key]json.RawMessage{{model.KindSettings, 0}: mustJSON(st.Settings)}
|
||||
for _, s := range st.Subnets {
|
||||
m[key{model.KindSubnet, s.ID}] = mustJSON(s)
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
m[key{model.KindReservation, r.ID}] = mustJSON(r)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func labels(st *model.State) map[key]string {
|
||||
m := map[key]string{{model.KindSettings, 0}: "settings"}
|
||||
for _, s := range st.Subnets {
|
||||
m[key{model.KindSubnet, s.ID}] = s.Label()
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
m[key{model.KindReservation, r.ID}] = r.Label()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// Diff lists what changes from a to b: settings, then subnets, then
|
||||
// reservations, each by ID.
|
||||
func Diff(a, b *model.State) []model.Change {
|
||||
oa, ob := objects(a), objects(b)
|
||||
la, lb := labels(a), labels(b)
|
||||
var keys []key
|
||||
for k := range oa {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for k := range ob {
|
||||
if _, ok := oa[k]; !ok {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
}
|
||||
order := map[string]int{model.KindSettings: 0, model.KindSubnet: 1, model.KindReservation: 2}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].kind != keys[j].kind {
|
||||
return order[keys[i].kind] < order[keys[j].kind]
|
||||
}
|
||||
return keys[i].id < keys[j].id
|
||||
})
|
||||
var out []model.Change
|
||||
for _, k := range keys {
|
||||
before, after := oa[k], ob[k]
|
||||
if sameJSON(before, after) {
|
||||
continue
|
||||
}
|
||||
label := lb[k]
|
||||
if label == "" {
|
||||
label = la[k]
|
||||
}
|
||||
out = append(out, model.Change{Kind: k.kind, ID: k.id, Label: label, Before: before, After: after})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Describe sums up changes: "create subnet office (10.0.10.0/24)", or
|
||||
// "3 changes" for many.
|
||||
func Describe(cs []model.Change) string {
|
||||
if len(cs) == 0 {
|
||||
return "no changes"
|
||||
}
|
||||
parts := []string{}
|
||||
for _, c := range cs {
|
||||
if c.Kind == model.KindSettings {
|
||||
parts = append(parts, "update settings")
|
||||
continue
|
||||
}
|
||||
parts = append(parts, c.Action()+" "+c.Kind+" "+c.Label)
|
||||
}
|
||||
if len(parts) > 3 {
|
||||
return fmt.Sprintf("%s and %d more changes", strings.Join(parts[:2], ", "), len(parts)-2)
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func mustJSON(v any) json.RawMessage {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func sameJSON(a, b json.RawMessage) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == nil && b == nil
|
||||
}
|
||||
return bytes.Equal(a, b)
|
||||
}
|
||||
|
||||
// put sets (or with nil data deletes) one object in st.
|
||||
func put(st *model.State, kind string, id int64, data json.RawMessage) error {
|
||||
switch kind {
|
||||
case model.KindSettings:
|
||||
if data == nil {
|
||||
return fmt.Errorf("settings can't be deleted")
|
||||
}
|
||||
var s model.Settings
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
st.Settings = s
|
||||
case model.KindSubnet:
|
||||
var keep []*model.Subnet
|
||||
for _, s := range st.Subnets {
|
||||
if s.ID != id {
|
||||
keep = append(keep, s)
|
||||
}
|
||||
}
|
||||
if data != nil {
|
||||
var s model.Subnet
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
keep = append(keep, &s)
|
||||
}
|
||||
st.Subnets = keep
|
||||
case model.KindReservation:
|
||||
var keep []*model.Reservation
|
||||
for _, r := range st.Reservations {
|
||||
if r.ID != id {
|
||||
keep = append(keep, r)
|
||||
}
|
||||
}
|
||||
if data != nil {
|
||||
var r model.Reservation
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
keep = append(keep, &r)
|
||||
}
|
||||
st.Reservations = keep
|
||||
default:
|
||||
return fmt.Errorf("unknown kind %q", kind)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize fills in option names and writes option values, pools and
|
||||
// lists in their canonical form, so equal configurations compare equal.
|
||||
func Normalize(st *model.State) {
|
||||
st.Settings.Options = normOptions(st.Settings.Options)
|
||||
if st.Subnets == nil {
|
||||
st.Subnets = []*model.Subnet{}
|
||||
}
|
||||
if st.Reservations == nil {
|
||||
st.Reservations = []*model.Reservation{}
|
||||
}
|
||||
for _, s := range st.Subnets {
|
||||
s.Options = normOptions(s.Options)
|
||||
if s.Pools == nil {
|
||||
s.Pools = []model.Pool{}
|
||||
}
|
||||
if s.Prefix.IsValid() && s.Prefix.Addr().Is4() {
|
||||
s.Prefix = s.Prefix.Masked()
|
||||
}
|
||||
sort.SliceStable(s.Pools, func(i, j int) bool { return s.Pools[i].Start.Less(s.Pools[j].Start) })
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
r.Options = normOptions(r.Options)
|
||||
}
|
||||
}
|
||||
|
||||
func normOptions(opts []model.OptionValue) []model.OptionValue {
|
||||
out := []model.OptionValue{}
|
||||
for _, o := range opts {
|
||||
if o.Code > 0 && o.Code < 255 {
|
||||
def := dhcp4.ByCode(byte(o.Code))
|
||||
o.Name = def.Name
|
||||
typ := def.Type
|
||||
if dhcp4.Known(byte(o.Code)) {
|
||||
o.Type = ""
|
||||
} else if o.Type != "" {
|
||||
typ = o.Type
|
||||
}
|
||||
if v, err := dhcp4.Normalize(typ, o.Value); err == nil {
|
||||
o.Value = v
|
||||
}
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool { return out[i].Code < out[j].Code })
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"steward/internal/auth"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
)
|
||||
|
||||
func newEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
st, err := store.Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e, err := New(context.Background(), st, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
var admin = &auth.Principal{User: &model.User{ID: 1, Username: "admin", Admin: true}, Method: "session"}
|
||||
|
||||
func addOffice(t *testing.T, e *Engine) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
|
||||
p, _ := model.ParsePool("10.0.10.100-10.0.10.199")
|
||||
id = newID()
|
||||
st.Subnets = append(st.Subnets, &model.Subnet{ID: id, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
|
||||
Pools: []model.Pool{p}, Options: []model.OptionValue{{Code: 6, Value: "10.0.10.2,10.0.10.3"}}})
|
||||
return nil
|
||||
})
|
||||
if err != nil || !res.Applied || res.HistoryID == 0 {
|
||||
t.Fatalf("apply: %+v %v", res, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func TestApplyNormalizesAndRecords(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
id := addOffice(t, e)
|
||||
s := e.State().SubnetByID(id)
|
||||
if s.Options[0].Value != "10.0.10.2, 10.0.10.3" || s.Options[0].Name != "domain-name-servers" {
|
||||
t.Errorf("options not normalized: %+v", s.Options)
|
||||
}
|
||||
h, _ := e.Store().ListHistory(context.Background(), 10, 0)
|
||||
if len(h) != 1 || h[0].Description != "create subnet office (10.0.10.0/24)" || h[0].Via != "cli" || h[0].Username != "admin" {
|
||||
t.Errorf("history %+v", h[0])
|
||||
}
|
||||
// Nothing changed: nothing recorded.
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, _ func() int64) error { return nil })
|
||||
if err != nil || res.Applied || len(res.Changes) != 0 {
|
||||
t.Errorf("empty change: %+v %v", res, err)
|
||||
}
|
||||
// Reloading from the database gives the same state.
|
||||
e2, err := New(context.Background(), e.Store(), nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := Diff(e.State(), e2.State()); len(d) != 0 {
|
||||
t.Errorf("reloaded state differs: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationRefuses(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
addOffice(t, e)
|
||||
_, err := e.Apply(context.Background(), admin, "", Options{}, func(st *model.State, newID func() int64) error {
|
||||
st.Subnets = append(st.Subnets, &model.Subnet{ID: newID(), Interface: "eth1", Prefix: netip.MustParsePrefix("10.0.0.0/16")})
|
||||
return nil
|
||||
})
|
||||
ee, ok := AsError(err)
|
||||
if !ok || ee.Kind != KindValidation || !strings.Contains(ee.Message, "overlaps subnet office") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
if len(e.State().Subnets) != 1 {
|
||||
t.Error("refused change was kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRun(t *testing.T) {
|
||||
e := newEngine(t)
|
||||
res, err := e.Apply(context.Background(), admin, "", Options{DryRun: true}, func(st *model.State, newID func() int64) error {
|
||||
st.Subnets = append(st.Subnets, &model.Subnet{ID: newID(), Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24")})
|
||||
return nil
|
||||
})
|
||||
if err != nil || res.Applied || len(res.Changes) != 1 || len(res.Warnings) != 1 || res.Warnings[0].Code != "subnet_no_pools" {
|
||||
t.Fatalf("dry run: %+v %v", res, err)
|
||||
}
|
||||
if len(e.State().Subnets) != 0 {
|
||||
t.Error("dry run changed the state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevert(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
e := newEngine(t)
|
||||
var got *model.State
|
||||
e.OnChange = func(st *model.State) { got = st }
|
||||
id := addOffice(t, e)
|
||||
res, err := e.Apply(ctx, admin, "longer leases", Options{}, func(st *model.State, _ func() int64) error {
|
||||
st.SubnetByID(id).LeaseTime = 86400
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == nil || got.SubnetByID(id).LeaseTime != 86400 {
|
||||
t.Error("OnChange didn't get the new state")
|
||||
}
|
||||
// Undoing the creation conflicts: the subnet changed since.
|
||||
if _, err := e.Revert(ctx, admin, 1, Options{}); err == nil || !strings.Contains(err.Error(), "has changed since entry 1") {
|
||||
t.Fatalf("revert of an older entry: %v", err)
|
||||
}
|
||||
if _, err := e.Revert(ctx, admin, res.HistoryID, Options{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e.State().SubnetByID(id).LeaseTime != 0 {
|
||||
t.Error("revert didn't restore the lease time")
|
||||
}
|
||||
if _, err := e.Revert(ctx, admin, res.HistoryID, Options{}); err == nil || !strings.Contains(err.Error(), "already undone") {
|
||||
t.Errorf("second revert: %v", err)
|
||||
}
|
||||
if _, err := e.Revert(ctx, admin, 1, Options{}); err != nil {
|
||||
t.Fatalf("revert of the creation: %v", err)
|
||||
}
|
||||
if len(e.State().Subnets) != 0 {
|
||||
t.Error("subnet still there")
|
||||
}
|
||||
h, _ := e.Store().GetHistory(ctx, 1)
|
||||
if h.RevertedBy == 0 {
|
||||
t.Error("entry 1 not marked undone")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
// Package kea reads a Kea DHCPv4 configuration (kea-dhcp4.conf) and its
|
||||
// lease file (the memfile CSV), so a Kea setup can move to Steward without
|
||||
// renumbering anything. What Steward doesn't do is listed, not dropped
|
||||
// silently.
|
||||
package kea
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// Result is what an import makes of a Kea configuration.
|
||||
type Result struct {
|
||||
// State has the settings, subnets and reservations (IDs not set).
|
||||
State *model.State
|
||||
// Notes say how things were mapped; Skipped lists what Steward
|
||||
// doesn't support, so it isn't lost silently.
|
||||
Notes []string
|
||||
Skipped []string
|
||||
}
|
||||
|
||||
// Options steer an import.
|
||||
type Options struct {
|
||||
// HostInterfaces are this machine's interfaces with their prefixes,
|
||||
// to find a subnet's interface when Kea doesn't name it.
|
||||
HostInterfaces map[string][]netip.Prefix
|
||||
// Interfaces gives the interface of subnets by prefix
|
||||
// ("10.0.20.0/24" -> "eth0.20"); it wins over the configuration.
|
||||
Interfaces map[string]string
|
||||
}
|
||||
|
||||
// StripComments removes Kea's comments (#, // and /* */) outside strings.
|
||||
func StripComments(b []byte) []byte {
|
||||
var out bytes.Buffer
|
||||
inStr, esc := false, false
|
||||
for i := 0; i < len(b); i++ {
|
||||
c := b[i]
|
||||
if inStr {
|
||||
out.WriteByte(c)
|
||||
switch {
|
||||
case esc:
|
||||
esc = false
|
||||
case c == '\\':
|
||||
esc = true
|
||||
case c == '"':
|
||||
inStr = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case c == '"':
|
||||
inStr = true
|
||||
out.WriteByte(c)
|
||||
case c == '#' || c == '/' && i+1 < len(b) && b[i+1] == '/':
|
||||
for i < len(b) && b[i] != '\n' {
|
||||
i++
|
||||
}
|
||||
out.WriteByte('\n')
|
||||
case c == '/' && i+1 < len(b) && b[i+1] == '*':
|
||||
i += 2
|
||||
for i+1 < len(b) && !(b[i] == '*' && b[i+1] == '/') {
|
||||
if b[i] == '\n' {
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
i++
|
||||
}
|
||||
i++
|
||||
default:
|
||||
out.WriteByte(c)
|
||||
}
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
type obj = map[string]json.RawMessage
|
||||
|
||||
type importer struct {
|
||||
opts Options
|
||||
res *Result
|
||||
globals []string // interfaces-config interfaces
|
||||
}
|
||||
|
||||
// ParseConfig reads a kea-dhcp4.conf.
|
||||
func ParseConfig(data []byte, opts Options) (*Result, error) {
|
||||
clean := StripComments(data)
|
||||
if bytes.Contains(clean, []byte("<?include")) {
|
||||
return nil, fmt.Errorf("the configuration uses <?include ...?>; put the included files' contents in place first")
|
||||
}
|
||||
var top obj
|
||||
if err := json.Unmarshal(clean, &top); err != nil {
|
||||
return nil, fmt.Errorf("reading the Kea configuration: %w", jsonError(clean, err))
|
||||
}
|
||||
raw, ok := top["Dhcp4"]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no Dhcp4 section: is this a kea-dhcp4.conf?")
|
||||
}
|
||||
var d4 obj
|
||||
if err := json.Unmarshal(raw, &d4); err != nil {
|
||||
return nil, fmt.Errorf("Dhcp4: %w", err)
|
||||
}
|
||||
im := &importer{opts: opts, res: &Result{State: &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}}}}
|
||||
for k := range top {
|
||||
if k != "Dhcp4" {
|
||||
im.skip("the %s section (Steward imports Dhcp4 only)", k)
|
||||
}
|
||||
}
|
||||
if err := im.dhcp4(d4); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return im.res, nil
|
||||
}
|
||||
|
||||
// jsonError adds the line number to a JSON syntax error.
|
||||
func jsonError(b []byte, err error) error {
|
||||
if se, ok := err.(*json.SyntaxError); ok {
|
||||
line := 1 + bytes.Count(b[:min(int(se.Offset), len(b))], []byte("\n"))
|
||||
return fmt.Errorf("line %d: %v", line, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (im *importer) note(format string, args ...any) {
|
||||
im.res.Notes = append(im.res.Notes, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func (im *importer) skip(format string, args ...any) {
|
||||
im.res.Skipped = append(im.res.Skipped, fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func get[T any](o obj, key string, into *T) (bool, error) {
|
||||
raw, ok := o[key]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
if err := json.Unmarshal(raw, into); err != nil {
|
||||
return true, fmt.Errorf("%s: %v", key, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Keys of Dhcp4 that are handled, ignored on purpose, or skipped with a
|
||||
// note.
|
||||
var (
|
||||
handledKeys = map[string]bool{"interfaces-config": true, "valid-lifetime": true, "option-data": true, "subnet4": true, "shared-networks": true,
|
||||
"reservations": true, "authoritative": true, "decline-probation-period": true}
|
||||
ignoredKeys = map[string]string{
|
||||
"lease-database": "Steward keeps leases in its own database", "control-socket": "Steward has its own API",
|
||||
"loggers": "Steward logs to the journal", "renew-timer": "Steward sends half the lease time",
|
||||
"rebind-timer": "Steward sends 7/8 of the lease time", "expired-leases-processing": "Steward expires leases itself",
|
||||
"sanity-checks": "", "multi-threading": "", "server-tag": "", "option-def": "", "calculate-tee-times": "", "t1-percent": "", "t2-percent": "",
|
||||
"min-valid-lifetime": "", "max-valid-lifetime": "", "host-reservation-identifiers": "", "echo-client-id": "", "match-client-id": "",
|
||||
"user-context": "", "comment": "", "cache-threshold": "", "dhcp4o6-port": "", "hostname-char-set": "", "hostname-char-replacement": "",
|
||||
}
|
||||
)
|
||||
|
||||
func (im *importer) dhcp4(d obj) error {
|
||||
var keys []string
|
||||
for k := range d {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if handledKeys[k] {
|
||||
continue
|
||||
}
|
||||
if why, ok := ignoredKeys[k]; ok {
|
||||
if why != "" {
|
||||
im.note("%s isn't needed: %s", k, why)
|
||||
}
|
||||
continue
|
||||
}
|
||||
im.skip("%s (not supported by Steward 0.1)", k)
|
||||
}
|
||||
var ic struct {
|
||||
Interfaces []string `json:"interfaces"`
|
||||
}
|
||||
if _, err := get(d, "interfaces-config", &ic); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, i := range ic.Interfaces {
|
||||
name, _, _ := strings.Cut(i, "/")
|
||||
if name != "*" && name != "" {
|
||||
im.globals = append(im.globals, name)
|
||||
}
|
||||
}
|
||||
set := &im.res.State.Settings
|
||||
var vl int
|
||||
if ok, err := get(d, "valid-lifetime", &vl); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
set.LeaseTime = vl
|
||||
}
|
||||
var dpp int
|
||||
if ok, err := get(d, "decline-probation-period", &dpp); err != nil {
|
||||
return err
|
||||
} else if ok {
|
||||
set.DeclineHold = dpp
|
||||
}
|
||||
var authoritative bool
|
||||
if ok, err := get(d, "authoritative", &authoritative); err != nil {
|
||||
return err
|
||||
} else if !ok || !authoritative {
|
||||
im.note("Kea wasn't authoritative; Steward is by default (it answers requests for addresses that don't belong on an interface with a NAK, so clients start over at once); turn it off in the settings if another DHCP server shares a network")
|
||||
}
|
||||
var err error
|
||||
if set.Options, err = im.options(d, "global"); err != nil {
|
||||
return err
|
||||
}
|
||||
var subnets []obj
|
||||
if _, err := get(d, "subnet4", &subnets); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, s := range subnets {
|
||||
if err := im.subnet(s, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var shared []obj
|
||||
if _, err := get(d, "shared-networks", &shared); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, sh := range shared {
|
||||
var name string
|
||||
get(sh, "name", &name)
|
||||
var subs []obj
|
||||
if _, err := get(sh, "subnet4", &subs); err != nil {
|
||||
return fmt.Errorf("shared network %s: %w", name, err)
|
||||
}
|
||||
if len(subs) > 1 {
|
||||
im.note("shared network %s: its %d subnets are served together on one interface, as in Kea", name, len(subs))
|
||||
}
|
||||
for _, s := range subs {
|
||||
if err := im.subnet(s, sh); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
var global []obj
|
||||
if _, err := get(d, "reservations", &global); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range global {
|
||||
if err := im.reservation(r, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// options reads option-data.
|
||||
func (im *importer) options(o obj, where string) ([]model.OptionValue, error) {
|
||||
var list []obj
|
||||
if _, err := get(o, "option-data", &list); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", where, err)
|
||||
}
|
||||
out := []model.OptionValue{}
|
||||
for _, od := range list {
|
||||
var name, data, space string
|
||||
var code int
|
||||
csvFormat := true
|
||||
get(od, "name", &name)
|
||||
get(od, "code", &code)
|
||||
get(od, "data", &data)
|
||||
get(od, "space", &space)
|
||||
get(od, "csv-format", &csvFormat)
|
||||
if space != "" && space != "dhcp4" {
|
||||
im.skip("%s: option %s%d in space %s", where, name, code, space)
|
||||
continue
|
||||
}
|
||||
var def dhcp4.Def
|
||||
ok := false
|
||||
if name != "" {
|
||||
def, ok = dhcp4.Lookup(name)
|
||||
}
|
||||
if !ok && code > 0 && code < 255 {
|
||||
def, ok = dhcp4.ByCode(byte(code)), true
|
||||
}
|
||||
if !ok {
|
||||
im.skip("%s: option %s (Steward doesn't know it by name; set it by number)", where, name)
|
||||
continue
|
||||
}
|
||||
if why, reserved := dhcp4.Reserved[def.Code]; reserved {
|
||||
im.note("%s: option %s left out: %s", where, def.Name, why)
|
||||
continue
|
||||
}
|
||||
v := model.OptionValue{Code: int(def.Code), Value: data}
|
||||
if !dhcp4.Known(def.Code) {
|
||||
v.Type = dhcp4.TypeHex
|
||||
}
|
||||
if !csvFormat {
|
||||
b, err := hex.DecodeString(strings.NewReplacer(":", "", " ", "").Replace(strings.TrimPrefix(strings.ToLower(data), "0x")))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: option %s: data %q isn't hex (csv-format is false)", where, def.Name, data)
|
||||
}
|
||||
typ := def.Type
|
||||
if v.Type != "" {
|
||||
typ = v.Type
|
||||
}
|
||||
v.Value = dhcp4.Decode(typ, b)
|
||||
}
|
||||
typ := def.Type
|
||||
if v.Type != "" {
|
||||
typ = v.Type
|
||||
}
|
||||
if _, err := dhcp4.Encode(typ, v.Value); err != nil {
|
||||
return nil, fmt.Errorf("%s: option %s: %v", where, def.Name, err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (im *importer) subnet(s obj, shared obj) error {
|
||||
var prefix, iface, comment string
|
||||
var lifetime int
|
||||
get(s, "subnet", &prefix)
|
||||
pfx, err := netip.ParsePrefix(prefix)
|
||||
if err != nil || !pfx.Addr().Is4() {
|
||||
return fmt.Errorf("subnet %q isn't an IPv4 prefix", prefix)
|
||||
}
|
||||
where := "subnet " + pfx.String()
|
||||
sn := &model.Subnet{Prefix: pfx.Masked(), Pools: []model.Pool{}}
|
||||
if shared != nil {
|
||||
get(shared, "interface", &iface)
|
||||
var name string
|
||||
get(shared, "name", &name)
|
||||
sn.Comment = "shared network " + name
|
||||
if ok, _ := get(shared, "valid-lifetime", &lifetime); ok {
|
||||
sn.LeaseTime = lifetime
|
||||
}
|
||||
so, err := im.options(shared, "shared network "+name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sn.Options = so
|
||||
}
|
||||
if ok, _ := get(s, "interface", &iface); ok && iface == "" {
|
||||
iface = ""
|
||||
}
|
||||
if ok, _ := get(s, "valid-lifetime", &lifetime); ok {
|
||||
sn.LeaseTime = lifetime
|
||||
}
|
||||
if lt := im.res.State.Settings.LeaseTime; sn.LeaseTime == lt {
|
||||
sn.LeaseTime = 0
|
||||
}
|
||||
var uc struct {
|
||||
Comment string `json:"comment"`
|
||||
}
|
||||
if get(s, "user-context", &uc); uc.Comment != "" {
|
||||
comment = uc.Comment
|
||||
}
|
||||
get(s, "comment", &comment)
|
||||
if comment != "" {
|
||||
sn.Comment = strings.TrimSpace(sn.Comment + " " + comment)
|
||||
}
|
||||
opts, err := im.options(s, where)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// The subnet's options over the shared network's.
|
||||
sn.Options = mergeOptions(sn.Options, opts)
|
||||
var pools []obj
|
||||
if _, err := get(s, "pools", &pools); err != nil {
|
||||
return fmt.Errorf("%s: %w", where, err)
|
||||
}
|
||||
for _, p := range pools {
|
||||
var ps string
|
||||
get(p, "pool", &ps)
|
||||
pool, err := model.ParsePool(strings.ReplaceAll(ps, " ", ""))
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %v", where, err)
|
||||
}
|
||||
sn.Pools = append(sn.Pools, pool)
|
||||
if _, ok := p["option-data"]; ok {
|
||||
im.skip("%s: options of pool %s (Steward sets options per subnet)", where, pool)
|
||||
}
|
||||
if _, ok := p["client-class"]; ok {
|
||||
im.skip("%s: the client class of pool %s", where, pool)
|
||||
}
|
||||
}
|
||||
for _, k := range []string{"relay", "client-class", "require-client-classes", "next-server", "boot-file-name", "4o6-interface", "4o6-subnet"} {
|
||||
if _, ok := s[k]; ok {
|
||||
im.skip("%s: %s", where, k)
|
||||
}
|
||||
}
|
||||
if forced, ok := im.opts.Interfaces[sn.Prefix.String()]; ok {
|
||||
iface = forced
|
||||
}
|
||||
if iface == "" {
|
||||
iface = im.findInterface(sn.Prefix)
|
||||
if iface != "" {
|
||||
im.note("%s: served on %s (Kea didn't name its interface)", where, iface)
|
||||
}
|
||||
}
|
||||
sn.Interface = iface
|
||||
im.res.State.Subnets = append(im.res.State.Subnets, sn)
|
||||
var res []obj
|
||||
if _, err := get(s, "reservations", &res); err != nil {
|
||||
return fmt.Errorf("%s: %w", where, err)
|
||||
}
|
||||
for _, r := range res {
|
||||
if err := im.reservation(r, sn); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// findInterface guesses a subnet's interface: the host's interface with an
|
||||
// address in it, else the only interface Kea listened on.
|
||||
func (im *importer) findInterface(p netip.Prefix) string {
|
||||
for name, addrs := range im.opts.HostInterfaces {
|
||||
for _, a := range addrs {
|
||||
if p.Contains(a.Addr()) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(im.globals) == 1 {
|
||||
return im.globals[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func mergeOptions(base, over []model.OptionValue) []model.OptionValue {
|
||||
out := []model.OptionValue{}
|
||||
seen := map[int]bool{}
|
||||
for _, o := range over {
|
||||
seen[o.Code] = true
|
||||
}
|
||||
for _, o := range base {
|
||||
if !seen[o.Code] {
|
||||
out = append(out, o)
|
||||
}
|
||||
}
|
||||
return append(out, over...)
|
||||
}
|
||||
|
||||
func (im *importer) reservation(r obj, sn *model.Subnet) error {
|
||||
var hw, cid, ip, host string
|
||||
get(r, "hw-address", &hw)
|
||||
get(r, "client-id", &cid)
|
||||
get(r, "ip-address", &ip)
|
||||
get(r, "hostname", &host)
|
||||
where := "reservation " + ip
|
||||
if host != "" {
|
||||
where = "reservation " + host
|
||||
}
|
||||
if ip == "" {
|
||||
im.skip("%s: no ip-address (Steward reservations always fix the address)", where)
|
||||
return nil
|
||||
}
|
||||
a, err := netip.ParseAddr(ip)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %q isn't an address", where, ip)
|
||||
}
|
||||
res := &model.Reservation{IP: a, Hostname: host, Options: []model.OptionValue{}}
|
||||
if hw != "" {
|
||||
if res.MAC, err = model.NormalizeMAC(hw); err != nil {
|
||||
return fmt.Errorf("%s: %v", where, err)
|
||||
}
|
||||
}
|
||||
if cid != "" {
|
||||
if res.ClientID, err = model.NormalizeClientID(cid); err != nil {
|
||||
return fmt.Errorf("%s: %v", where, err)
|
||||
}
|
||||
}
|
||||
if res.MAC == "" && res.ClientID == "" {
|
||||
im.skip("%s: identified by %s (Steward knows clients by MAC address or client id)", where, firstKey(r, "duid", "circuit-id", "flex-id"))
|
||||
return nil
|
||||
}
|
||||
if res.Options, err = im.options(r, where); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range []string{"client-classes", "next-server", "boot-file-name", "server-hostname"} {
|
||||
if _, ok := r[k]; ok {
|
||||
im.skip("%s: %s", where, k)
|
||||
}
|
||||
}
|
||||
im.res.State.Reservations = append(im.res.State.Reservations, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstKey(o obj, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if _, ok := o[k]; ok {
|
||||
return k
|
||||
}
|
||||
}
|
||||
return "something else"
|
||||
}
|
||||
|
||||
// ParseLeases reads a Kea memfile lease file (kea-leases4.csv). The file
|
||||
// is a log: later lines for an address replace earlier ones. Only leases
|
||||
// that haven't ended are returned (declined ones too).
|
||||
func ParseLeases(r io.Reader, now time.Time) ([]*model.Lease, error) {
|
||||
cr := csv.NewReader(r)
|
||||
cr.FieldsPerRecord = -1
|
||||
header, err := cr.Read()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading the lease file: %w", err)
|
||||
}
|
||||
col := map[string]int{}
|
||||
for i, h := range header {
|
||||
col[strings.TrimSpace(h)] = i
|
||||
}
|
||||
for _, need := range []string{"address", "hwaddr", "expire", "valid_lifetime"} {
|
||||
if _, ok := col[need]; !ok {
|
||||
return nil, fmt.Errorf("the lease file has no %s column: is it a Kea DHCPv4 memfile (kea-leases4.csv)?", need)
|
||||
}
|
||||
}
|
||||
field := func(rec []string, name string) string {
|
||||
if i, ok := col[name]; ok && i < len(rec) {
|
||||
return strings.TrimSpace(rec[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
byIP := map[netip.Addr]*model.Lease{}
|
||||
line := 1
|
||||
for {
|
||||
rec, err := cr.Read()
|
||||
line++
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lease file line %d: %w", line, err)
|
||||
}
|
||||
a, err := netip.ParseAddr(field(rec, "address"))
|
||||
if err != nil || !a.Is4() {
|
||||
continue
|
||||
}
|
||||
exp, _ := strconv.ParseInt(field(rec, "expire"), 10, 64)
|
||||
life, _ := strconv.ParseInt(field(rec, "valid_lifetime"), 10, 64)
|
||||
end := time.Unix(exp, 0).UTC()
|
||||
l := &model.Lease{IP: a, State: model.LeaseActive, Start: end.Add(-time.Duration(life) * time.Second), End: end, Updated: now,
|
||||
Hostname: unescape(field(rec, "hostname"))}
|
||||
if m, err := model.NormalizeMAC(field(rec, "hwaddr")); err == nil {
|
||||
l.MAC = m
|
||||
}
|
||||
if c := field(rec, "client_id"); c != "" {
|
||||
if id, err := model.NormalizeClientID(c); err == nil {
|
||||
l.ClientID = id
|
||||
}
|
||||
}
|
||||
switch field(rec, "state") {
|
||||
case "1":
|
||||
l.State = model.LeaseDeclined
|
||||
case "2", "3":
|
||||
l.State = model.LeaseReleased
|
||||
}
|
||||
byIP[a] = l
|
||||
}
|
||||
var out []*model.Lease
|
||||
for _, l := range byIP {
|
||||
if l.MAC == "" && l.State != model.LeaseDeclined {
|
||||
continue
|
||||
}
|
||||
if l.End.After(now) || l.State == model.LeaseReleased {
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].IP.Less(out[j].IP) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// unescape undoes Kea's escaping of commas in the lease file.
|
||||
func unescape(s string) string { return strings.ReplaceAll(s, ",", ",") }
|
||||
@@ -0,0 +1,170 @@
|
||||
package kea
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
const sample = `
|
||||
# Kea for the house
|
||||
{
|
||||
"Dhcp4": {
|
||||
"interfaces-config": { "interfaces": [ "eth0", "eth0.20/10.0.20.1" ] },
|
||||
"lease-database": { "type": "memfile", "persist": true, "name": "/var/lib/kea/kea-leases4.csv" },
|
||||
"valid-lifetime": 43200,
|
||||
"renew-timer": 21600,
|
||||
"authoritative": true,
|
||||
"option-data": [
|
||||
{ "name": "domain-name-servers", "data": "10.0.10.2, 10.0.10.3" },
|
||||
{ "name": "domain-name", "data": "lan" }, // our domain
|
||||
{ "name": "domain-search", "data": "lan, home.arpa" }
|
||||
],
|
||||
"hooks-libraries": [ { "library": "/usr/lib/kea/hooks/libdhcp_lease_cmds.so" } ],
|
||||
/* the office */
|
||||
"subnet4": [
|
||||
{
|
||||
"id": 1,
|
||||
"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" },
|
||||
{ "code": 42, "data": "0a000a02", "csv-format": false }
|
||||
],
|
||||
"reservations": [
|
||||
{ "hw-address": "AA-BB-CC-00-00-01", "ip-address": "10.0.10.20", "hostname": "nas" },
|
||||
{ "client-id": "01:aa:bb:cc:00:00:02", "ip-address": "10.0.10.21", "hostname": "printer",
|
||||
"option-data": [ { "name": "domain-name-servers", "data": "1.1.1.1" } ] },
|
||||
{ "duid": "00:01:02", "ip-address": "10.0.10.22" }
|
||||
]
|
||||
}
|
||||
],
|
||||
"shared-networks": [
|
||||
{
|
||||
"name": "iot",
|
||||
"interface": "eth0.20",
|
||||
"valid-lifetime": 3600,
|
||||
"option-data": [ { "name": "routers", "data": "10.0.20.1" } ],
|
||||
"subnet4": [
|
||||
{ "id": 2, "subnet": "10.0.20.0/24", "pools": [ { "pool": "10.0.20.0/25" } ], "relay": { "ip-addresses": [ "10.0.20.254" ] } },
|
||||
{ "id": 3, "subnet": "10.0.21.0/24", "pools": [ { "pool": "10.0.21.10-10.0.21.20" } ],
|
||||
"option-data": [ { "name": "routers", "data": "10.0.21.1" } ] }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"Logging": { "loggers": [] }
|
||||
}
|
||||
`
|
||||
|
||||
func TestParseConfig(t *testing.T) {
|
||||
res, err := ParseConfig([]byte(sample), Options{HostInterfaces: map[string][]netip.Prefix{"eth0": {netip.MustParsePrefix("10.0.10.2/24")}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := res.State
|
||||
if st.Settings.LeaseTime != 43200 || len(st.Settings.Options) != 3 {
|
||||
t.Errorf("settings %+v", st.Settings)
|
||||
}
|
||||
if len(st.Subnets) != 3 {
|
||||
t.Fatalf("subnets %+v", st.Subnets)
|
||||
}
|
||||
office := st.Subnets[0]
|
||||
if office.Interface != "eth0" || office.Pools[0].String() != "10.0.10.100-10.0.10.199" || office.LeaseTime != 0 {
|
||||
t.Errorf("office %+v", office)
|
||||
}
|
||||
if len(office.Options) != 2 || office.Options[1].Code != 42 || office.Options[1].Value != "10.0.10.2" {
|
||||
t.Errorf("office options %+v", office.Options)
|
||||
}
|
||||
iot, iot2 := st.Subnets[1], st.Subnets[2]
|
||||
if iot.Interface != "eth0.20" || iot.LeaseTime != 3600 || iot.Pools[0].String() != "10.0.20.0-10.0.20.127" || iot.Options[0].Value != "10.0.20.1" {
|
||||
t.Errorf("iot %+v", iot)
|
||||
}
|
||||
if iot2.Interface != "eth0.20" || len(iot2.Options) != 1 || iot2.Options[0].Value != "10.0.21.1" {
|
||||
t.Errorf("iot2 %+v %+v", iot2, iot2.Options)
|
||||
}
|
||||
if len(st.Reservations) != 2 || st.Reservations[0].MAC != "aa:bb:cc:00:00:01" || st.Reservations[1].ClientID != "01aabbcc000002" ||
|
||||
st.Reservations[1].Options[0].Value != "1.1.1.1" {
|
||||
t.Errorf("reservations %+v %+v", st.Reservations[0], st.Reservations[1])
|
||||
}
|
||||
skipped := strings.Join(res.Skipped, "\n")
|
||||
for _, want := range []string{"hooks-libraries", "Logging", "duid", "relay"} {
|
||||
if !strings.Contains(skipped, want) {
|
||||
t.Errorf("skipped list lacks %s:\n%s", want, skipped)
|
||||
}
|
||||
}
|
||||
notes := strings.Join(res.Notes, "\n")
|
||||
for _, want := range []string{"renew-timer", "shared network iot"} {
|
||||
if !strings.Contains(notes, want) {
|
||||
t.Errorf("notes lack %s:\n%s", want, notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInterfaceOverride(t *testing.T) {
|
||||
cfg := `{"Dhcp4": {"interfaces-config": {"interfaces": ["*"]}, "subnet4": [{"subnet": "192.168.5.0/24", "pools": [{"pool": "192.168.5.10-192.168.5.20"}]}]}}`
|
||||
res, err := ParseConfig([]byte(cfg), Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.State.Subnets[0].Interface != "" {
|
||||
t.Errorf("guessed %q", res.State.Subnets[0].Interface)
|
||||
}
|
||||
res, _ = ParseConfig([]byte(cfg), Options{Interfaces: map[string]string{"192.168.5.0/24": "br0"}})
|
||||
if res.State.Subnets[0].Interface != "br0" {
|
||||
t.Errorf("override: %q", res.State.Subnets[0].Interface)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigErrors(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
`{"Dhcp6": {}}`: "no Dhcp4",
|
||||
"{\n\"Dhcp4\": {\n\"subnet4\": [\n}}": "line 4",
|
||||
`{"Dhcp4": {}} <?include "/etc/kea/more.json"?>`: "include",
|
||||
`{"Dhcp4": {"subnet4": [{"subnet": "2001:db8::/64"}]}}`: "IPv4",
|
||||
`{"Dhcp4": {"option-data": [{"name": "routers", "data": "x"}]}}`: "routers",
|
||||
} {
|
||||
if _, err := ParseConfig([]byte(in), Options{}); err == nil || !strings.Contains(err.Error(), want) {
|
||||
t.Errorf("%q: %v", in, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStripComments(t *testing.T) {
|
||||
in := "{\"a\": \"x // not a comment # nor this\", # comment\n \"b\": /* gone */ 1 // tail\n}"
|
||||
got := string(StripComments([]byte(in)))
|
||||
if !strings.Contains(got, `"x // not a comment # nor this"`) || strings.Contains(got, "gone") || strings.Contains(got, "tail") {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLeases(t *testing.T) {
|
||||
now := time.Unix(1_800_000_000, 0)
|
||||
csv := `address,hwaddr,client_id,valid_lifetime,expire,subnet_id,fqdn_fwd,fqdn_rev,hostname,state,user_context,pool_id
|
||||
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800010000,1,0,0,laptop,0,,0
|
||||
10.0.10.101,aa:bb:cc:00:00:06,,43200,1799990000,1,0,0,old,0,,0
|
||||
10.0.10.100,aa:bb:cc:00:00:05,01:aa:bb:cc:00:00:05,43200,1800040000,1,0,0,laptop,work,0,,0
|
||||
10.0.10.102,,,86400,1800050000,1,0,0,,1,,0
|
||||
10.0.10.103,aa:bb:cc:00:00:07,,43200,1799000000,1,0,0,gone,3,,0
|
||||
`
|
||||
ls, err := ParseLeases(strings.NewReader(csv), now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ls) != 3 {
|
||||
t.Fatalf("leases %+v", ls)
|
||||
}
|
||||
l := ls[0]
|
||||
if l.IP.String() != "10.0.10.100" || l.State != model.LeaseActive || l.End.Unix() != 1800040000 || l.Hostname != "laptop,work" || l.ClientID != "01aabbcc000005" {
|
||||
t.Errorf("lease %+v", l)
|
||||
}
|
||||
if ls[1].State != model.LeaseDeclined || ls[2].State != model.LeaseReleased {
|
||||
t.Errorf("states %s %s", ls[1].State, ls[2].State)
|
||||
}
|
||||
if _, err := ParseLeases(strings.NewReader("a,b\n1,2\n"), now); err == nil {
|
||||
t.Error("a file without Kea's columns was accepted")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/auth"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
"steward/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionCookie = "steward_session"
|
||||
csrfHeader = "X-CSRF-Token"
|
||||
)
|
||||
|
||||
// rateLimiter limits failed logins and token attempts per key (address or
|
||||
// user name) over a sliding window.
|
||||
type rateLimiter struct {
|
||||
mu sync.Mutex
|
||||
hits map[string][]time.Time
|
||||
}
|
||||
|
||||
func newRateLimiter() *rateLimiter { return &rateLimiter{hits: map[string][]time.Time{}} }
|
||||
|
||||
const (
|
||||
failWindow = 15 * time.Minute
|
||||
failsPerIP = 20
|
||||
failsPerKey = 8
|
||||
)
|
||||
|
||||
func (l *rateLimiter) recent(key string, now time.Time) []time.Time {
|
||||
var keep []time.Time
|
||||
for _, t := range l.hits[key] {
|
||||
if now.Sub(t) < failWindow {
|
||||
keep = append(keep, t)
|
||||
}
|
||||
}
|
||||
l.hits[key] = keep
|
||||
return keep
|
||||
}
|
||||
|
||||
func (l *rateLimiter) blocked(key string, max int) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return len(l.recent(key, time.Now())) >= max
|
||||
}
|
||||
|
||||
func (l *rateLimiter) fail(keys ...string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for _, k := range keys {
|
||||
l.hits[k] = append(l.recent(k, now), now)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *rateLimiter) reset(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.hits, key)
|
||||
}
|
||||
|
||||
func (l *rateLimiter) cleanup() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
now := time.Now()
|
||||
for k := range l.hits {
|
||||
if len(l.recent(k, now)) == 0 {
|
||||
delete(l.hits, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var errUnauthenticated = &httpError{http.StatusUnauthorized, "unauthenticated", "authentication required: log in, or send an API token as 'Authorization: Bearer steward_...'"}
|
||||
|
||||
// authenticate identifies the caller: the local socket's peer, a bearer
|
||||
// token or a session cookie (with the CSRF token for changes).
|
||||
func (s *Server) authenticate(r *http.Request) (*auth.Principal, *model.Session, error) {
|
||||
ip := s.clientIP(r)
|
||||
if pr := peerOf(r.Context()); pr != nil && r.Header.Get("Authorization") == "" {
|
||||
if trustedUID(pr.UID) {
|
||||
return socketPrincipal(pr, r.UserAgent()), nil, nil
|
||||
}
|
||||
return nil, nil, socketDenied(pr)
|
||||
}
|
||||
if h := r.Header.Get("Authorization"); h != "" {
|
||||
scheme, tok, _ := strings.Cut(h, " ")
|
||||
if !strings.EqualFold(scheme, "Bearer") {
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "unsupported authorization scheme (use Bearer with an API token)"}
|
||||
}
|
||||
if s.limiter.blocked("ip:"+ip, failsPerIP) {
|
||||
return nil, nil, &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed attempts; try again later"}
|
||||
}
|
||||
p, err := s.tokenPrincipal(r.Context(), strings.TrimSpace(tok), ip, r.UserAgent())
|
||||
if err != nil {
|
||||
s.limiter.fail("ip:" + ip)
|
||||
s.eng.Audit(r.Context(), &auth.Principal{IP: ip, Method: "token"}, "auth.token_failed", "", "failure", err.Error())
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "unauthenticated", "invalid API token: " + err.Error()}
|
||||
}
|
||||
return p, nil, nil
|
||||
}
|
||||
c, err := r.Cookie(sessionCookie)
|
||||
if err != nil || c.Value == "" {
|
||||
return nil, nil, errUnauthenticated
|
||||
}
|
||||
hash := auth.HashSecret(c.Value)
|
||||
se, err := s.store.GetSession(r.Context(), hash)
|
||||
if err != nil {
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
||||
}
|
||||
now := time.Now()
|
||||
if now.After(se.ExpiresAt) || now.Sub(se.LastSeenAt) > s.cfg.SessionIdle {
|
||||
s.store.DeleteSession(r.Context(), hash)
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your session has expired; please log in again"}
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), se.UserID)
|
||||
if err != nil || u.Disabled {
|
||||
s.store.DeleteSession(r.Context(), hash)
|
||||
return nil, nil, &httpError{http.StatusUnauthorized, "session_expired", "your account is not available; please log in again"}
|
||||
}
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
got := r.Header.Get(csrfHeader)
|
||||
if got == "" || subtle.ConstantTimeCompare([]byte(got), []byte(se.CSRFToken)) != 1 {
|
||||
return nil, nil, &httpError{http.StatusForbidden, "csrf", "missing or invalid " + csrfHeader + " header"}
|
||||
}
|
||||
}
|
||||
if now.Sub(se.LastSeenAt) > time.Minute {
|
||||
s.store.TouchSession(r.Context(), hash, now)
|
||||
}
|
||||
return &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}, se, nil
|
||||
}
|
||||
|
||||
func (s *Server) tokenPrincipal(ctx context.Context, tok, ip, agent string) (*auth.Principal, error) {
|
||||
pid, secret, err := auth.ParseToken(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t, err := s.store.GetTokenByPublicID(ctx, pid)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, errors.New("unknown token")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !auth.CheckSecret(secret, t.SecretHash) {
|
||||
return nil, errors.New("wrong token secret")
|
||||
}
|
||||
if t.RevokedAt != nil {
|
||||
return nil, errors.New("the token was revoked")
|
||||
}
|
||||
now := time.Now()
|
||||
if t.ExpiresAt != nil && now.After(*t.ExpiresAt) {
|
||||
return nil, errors.New("the token has expired")
|
||||
}
|
||||
u, err := s.store.GetUser(ctx, t.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Disabled {
|
||||
return nil, errors.New("the token's user is disabled")
|
||||
}
|
||||
if t.LastUsedAt == nil || now.Sub(*t.LastUsedAt) > time.Minute || t.LastUsedIP != ip {
|
||||
s.store.TouchToken(ctx, t.ID, ip)
|
||||
}
|
||||
return &auth.Principal{User: u, Token: t, Method: "token", IP: ip, Agent: agent}, nil
|
||||
}
|
||||
|
||||
// authed wraps a handler with authentication and error handling.
|
||||
func (s *Server) authed(h handler) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, s.cfg.MaxBodyBytes)
|
||||
p, se, err := s.authenticate(r)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
return
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), keyPrincipal, p)
|
||||
if se != nil {
|
||||
ctx = context.WithValue(ctx, keySession, se)
|
||||
}
|
||||
r = r.WithContext(ctx)
|
||||
if err := h(w, r, p); err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// public wraps an unauthenticated handler.
|
||||
func (s *Server) public(h func(w http.ResponseWriter, r *http.Request) error) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
|
||||
if err := h(w, r); err != nil {
|
||||
s.writeErr(w, r, err, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) cookieSecure(r *http.Request) bool {
|
||||
switch s.cfg.CookieSecure {
|
||||
case "always":
|
||||
return true
|
||||
case "never":
|
||||
return false
|
||||
}
|
||||
return s.isHTTPS(r)
|
||||
}
|
||||
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) error {
|
||||
var in api.Login
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
ip := s.clientIP(r)
|
||||
name := strings.TrimSpace(in.Username)
|
||||
userKey := "user:" + strings.ToLower(name)
|
||||
who := &auth.Principal{User: &model.User{Username: name}, IP: ip, Method: "session"}
|
||||
if s.limiter.blocked("ip:"+ip, failsPerIP) || s.limiter.blocked(userKey, failsPerKey) {
|
||||
s.eng.Audit(r.Context(), who, "auth.login_blocked", "user "+name, "denied", "too many failed attempts")
|
||||
return &httpError{http.StatusTooManyRequests, "rate_limited", "too many failed login attempts; wait a few minutes and try again"}
|
||||
}
|
||||
u, err := s.store.GetUserByName(r.Context(), name)
|
||||
hash := ""
|
||||
if err == nil {
|
||||
hash = u.PasswordHash
|
||||
}
|
||||
ok := auth.CheckPassword(hash, in.Password)
|
||||
if err != nil || !ok || u.Disabled || u.PasswordHash == "" {
|
||||
s.limiter.fail("ip:"+ip, userKey)
|
||||
reason := "wrong username or password"
|
||||
if err == nil && u.Disabled && ok {
|
||||
reason = "account disabled"
|
||||
}
|
||||
s.eng.Audit(r.Context(), who, "auth.login_failed", "user "+name, "failure", reason)
|
||||
return &httpError{http.StatusUnauthorized, "login_failed", "wrong username or password"}
|
||||
}
|
||||
s.limiter.reset(userKey)
|
||||
id, csrf := auth.NewSessionID()
|
||||
now := time.Now()
|
||||
se := &model.Session{IDHash: auth.HashSecret(id), UserID: u.ID, CSRFToken: csrf, CreatedAt: now, ExpiresAt: now.Add(s.cfg.SessionLifetime), LastSeenAt: now, IP: ip, UserAgent: r.UserAgent()}
|
||||
if err := s.store.CreateSession(r.Context(), se); err != nil {
|
||||
return err
|
||||
}
|
||||
s.store.TouchLogin(r.Context(), u.ID)
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: id, Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, Expires: se.ExpiresAt})
|
||||
p := &auth.Principal{User: u, Method: "session", IP: ip, Agent: r.UserAgent()}
|
||||
s.eng.Audit(r.Context(), p, "auth.login", "user "+u.Username, "success", "")
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) logout(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
||||
s.store.DeleteSession(r.Context(), se.IDHash)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: sessionCookie, Value: "", Path: "/", HttpOnly: true, Secure: s.cookieSecure(r), SameSite: http.SameSiteStrictMode, MaxAge: -1})
|
||||
s.eng.Audit(r.Context(), p, "auth.logout", "user "+p.Name(), "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) whoamiOf(p *auth.Principal, se *model.Session) api.Whoami {
|
||||
out := api.Whoami{User: p.User, Admin: p.IsAdmin(), Method: p.Method, Permissions: p.Permissions(), Build: version.Info()}
|
||||
if se != nil {
|
||||
out.CSRFToken = se.CSRFToken
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) whoami(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
se, _ := r.Context().Value(keySession).(*model.Session)
|
||||
writeJSON(w, http.StatusOK, s.whoamiOf(p, se))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/auth"
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/engine"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
)
|
||||
|
||||
// change applies a configuration change and answers with what changed.
|
||||
// object, if set, returns the object to include from the new state.
|
||||
func (s *Server) change(w http.ResponseWriter, r *http.Request, p *auth.Principal, description string, edit engine.Edit, object func(st *model.State) any) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.Apply(r.Context(), p, description, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, edit)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
s.respondChange(w, r, res, object, r.Method == http.MethodPost)
|
||||
return nil
|
||||
}
|
||||
|
||||
// respondChange answers a change: 201 when it created something.
|
||||
func (s *Server) respondChange(w http.ResponseWriter, r *http.Request, res *engine.Result, object func(st *model.State) any, creates bool) {
|
||||
out := api.MutationResponse{Applied: res.Applied, DryRun: res.DryRun, HistoryID: res.HistoryID, Changes: res.Changes, Warnings: res.Warnings}
|
||||
if out.Changes == nil {
|
||||
out.Changes = []model.Change{}
|
||||
}
|
||||
if object != nil && res.Applied {
|
||||
if v := object(s.eng.State()); v != nil {
|
||||
out.Object, _ = json.Marshal(v)
|
||||
}
|
||||
}
|
||||
status := http.StatusOK
|
||||
if res.Applied && creates {
|
||||
status = http.StatusCreated
|
||||
}
|
||||
writeJSON(w, status, out)
|
||||
}
|
||||
|
||||
// optionsFrom reads options given by name or number.
|
||||
func optionsFrom(in []api.OptionInput) ([]model.OptionValue, error) {
|
||||
out := []model.OptionValue{}
|
||||
for _, o := range in {
|
||||
def, ok := dhcp4.Lookup(o.Option)
|
||||
if !ok {
|
||||
return nil, badRequest("unknown option %q: use a name from 'steward option list' or a number from 1 to 254", o.Option)
|
||||
}
|
||||
if why, reserved := dhcp4.Reserved[def.Code]; reserved {
|
||||
return nil, badRequest("option %s (%d) can't be set: %s", o.Option, def.Code, why)
|
||||
}
|
||||
v := model.OptionValue{Code: int(def.Code), Value: o.Value}
|
||||
if !dhcp4.Known(def.Code) {
|
||||
v.Type = o.Type
|
||||
if v.Type == "" {
|
||||
v.Type = dhcp4.TypeHex
|
||||
}
|
||||
} else if o.Type != "" && o.Type != def.Type {
|
||||
return nil, badRequest("option %s has type %s; a type can only be given for options Steward doesn't know", def.Name, def.Type)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---- settings
|
||||
|
||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.eng.State().Settings)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) updateSettings(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var in api.SettingsInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
var opts []model.OptionValue
|
||||
if in.Options != nil {
|
||||
var err error
|
||||
if opts, err = optionsFrom(*in.Options); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
set := &st.Settings
|
||||
if in.LeaseTime != nil {
|
||||
set.LeaseTime = *in.LeaseTime
|
||||
}
|
||||
if in.Options != nil {
|
||||
set.Options = opts
|
||||
}
|
||||
if in.PingCheck != nil {
|
||||
set.PingCheck = *in.PingCheck
|
||||
}
|
||||
if in.PingTimeout != nil {
|
||||
set.PingTimeout = *in.PingTimeout
|
||||
}
|
||||
if in.DeclineHold != nil {
|
||||
set.DeclineHold = *in.DeclineHold
|
||||
}
|
||||
if in.NotAuthoritative != nil {
|
||||
set.NotAuthoritative = *in.NotAuthoritative
|
||||
}
|
||||
return nil
|
||||
}, func(st *model.State) any { return st.Settings })
|
||||
}
|
||||
|
||||
// ---- subnets
|
||||
|
||||
func (s *Server) presentSubnet(sn *model.Subnet, st *model.State, leases []*model.Lease) api.Subnet {
|
||||
out := api.Subnet{Subnet: sn}
|
||||
now := s.nowFn()
|
||||
for _, p := range sn.Pools {
|
||||
out.Usage.PoolSize += p.Size()
|
||||
}
|
||||
reserved := map[netip.Addr]bool{}
|
||||
for _, res := range st.Reservations {
|
||||
if sn.Prefix.Contains(res.IP) {
|
||||
out.Usage.Reservations++
|
||||
reserved[res.IP] = true
|
||||
for _, p := range sn.Pools {
|
||||
if p.Contains(res.IP) {
|
||||
out.Usage.PoolSize-- // reserved addresses aren't handed out
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, l := range leases {
|
||||
if !sn.Prefix.Contains(l.IP) {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case reserved[l.IP] && l.ActiveAt(now):
|
||||
out.Usage.ReservationsLeased++
|
||||
case l.ActiveAt(now):
|
||||
out.Usage.Leased++
|
||||
case l.HeldAt(now):
|
||||
out.Usage.Declined++
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) listSubnets(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
leases := s.dhcp.Leases()
|
||||
out := []api.Subnet{}
|
||||
for _, sn := range st.Subnets {
|
||||
out = append(out, s.presentSubnet(sn, st, leases))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// subnetRef finds a subnet by ID, name or prefix (10.0.10.0/24 or
|
||||
// 10.0.10.0_24 in a path).
|
||||
func subnetRef(st *model.State, ref string) (*model.Subnet, error) {
|
||||
if sn := st.FindSubnet(strings.Replace(ref, "_", "/", 1)); sn != nil {
|
||||
return sn, nil
|
||||
}
|
||||
return nil, notFound("no subnet %q (give its ID, name or prefix)", ref)
|
||||
}
|
||||
|
||||
func (s *Server) getSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
sn, err := subnetRef(st, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentSubnet(sn, st, s.dhcp.Leases()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySubnetInput sets the given fields.
|
||||
func applySubnetInput(sn *model.Subnet, in *api.SubnetInput) error {
|
||||
if in.Name != nil {
|
||||
sn.Name = strings.TrimSpace(*in.Name)
|
||||
}
|
||||
if in.Interface != nil {
|
||||
sn.Interface = strings.TrimSpace(*in.Interface)
|
||||
}
|
||||
if in.Prefix != nil {
|
||||
pfx, err := netip.ParsePrefix(strings.TrimSpace(*in.Prefix))
|
||||
if err != nil {
|
||||
return badRequest("prefix %q: write the network as 10.0.10.0/24", *in.Prefix)
|
||||
}
|
||||
sn.Prefix = pfx
|
||||
}
|
||||
if in.Pools != nil {
|
||||
sn.Pools = []model.Pool{}
|
||||
for _, ps := range *in.Pools {
|
||||
pool, err := model.ParsePool(ps)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
sn.Pools = append(sn.Pools, pool)
|
||||
}
|
||||
}
|
||||
if in.LeaseTime != nil {
|
||||
sn.LeaseTime = *in.LeaseTime
|
||||
}
|
||||
if in.Options != nil {
|
||||
opts, err := optionsFrom(*in.Options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sn.Options = opts
|
||||
}
|
||||
if in.Disabled != nil {
|
||||
sn.Disabled = *in.Disabled
|
||||
}
|
||||
if in.Comment != nil {
|
||||
sn.Comment = *in.Comment
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var in api.SubnetInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Prefix == nil || in.Interface == nil {
|
||||
return badRequest("a subnet needs its prefix and its interface")
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
|
||||
sn := &model.Subnet{Pools: []model.Pool{}, Options: []model.OptionValue{}}
|
||||
if err := applySubnetInput(sn, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
id = newID()
|
||||
sn.ID = id
|
||||
st.Subnets = append(st.Subnets, sn)
|
||||
return nil
|
||||
}, func(st *model.State) any { return st.SubnetByID(id) })
|
||||
}
|
||||
|
||||
func (s *Server) updateSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var in api.SubnetInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
sn, err := subnetRef(st, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = sn.ID
|
||||
return applySubnetInput(sn, &in)
|
||||
}, func(st *model.State) any { return st.SubnetByID(id) })
|
||||
}
|
||||
|
||||
func (s *Server) deleteSubnet(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
sn, err := subnetRef(st, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var keep []*model.Subnet
|
||||
for _, x := range st.Subnets {
|
||||
if x.ID != sn.ID {
|
||||
keep = append(keep, x)
|
||||
}
|
||||
}
|
||||
st.Subnets = keep
|
||||
// Its reservations go with it (they can't be anywhere else).
|
||||
var kr []*model.Reservation
|
||||
for _, res := range st.Reservations {
|
||||
if !sn.Prefix.Contains(res.IP) || st.SubnetFor(res.IP) != nil {
|
||||
kr = append(kr, res)
|
||||
}
|
||||
}
|
||||
st.Reservations = kr
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ---- reservations
|
||||
|
||||
func (s *Server) presentReservation(res *model.Reservation, st *model.State) api.Reservation {
|
||||
out := api.Reservation{Reservation: res}
|
||||
if sn := st.SubnetFor(res.IP); sn != nil {
|
||||
out.SubnetID, out.SubnetName = sn.ID, sn.Name
|
||||
if out.SubnetName == "" {
|
||||
out.SubnetName = sn.Prefix.String()
|
||||
}
|
||||
}
|
||||
if l := s.dhcp.Lease(res.IP); l != nil && l.ActiveAt(s.nowFn()) {
|
||||
out.Lease = s.presentLease(l, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) listReservations(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
var only *model.Subnet
|
||||
if ref := r.URL.Query().Get("subnet"); ref != "" {
|
||||
var err error
|
||||
if only, err = subnetRef(st, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
out := []api.Reservation{}
|
||||
for _, res := range st.Reservations {
|
||||
if only != nil && !only.Prefix.Contains(res.IP) {
|
||||
continue
|
||||
}
|
||||
out = append(out, s.presentReservation(res, st))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reservationRef finds a reservation by ID, address or MAC address.
|
||||
func reservationRef(st *model.State, ref string) (*model.Reservation, error) {
|
||||
if id, err := strconv.ParseInt(ref, 10, 64); err == nil {
|
||||
if res := st.ReservationByID(id); res != nil {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
if a, err := netip.ParseAddr(ref); err == nil {
|
||||
for _, res := range st.Reservations {
|
||||
if res.IP == a {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
if m, err := model.NormalizeMAC(ref); err == nil {
|
||||
var found []*model.Reservation
|
||||
for _, res := range st.Reservations {
|
||||
if res.MAC == m {
|
||||
found = append(found, res)
|
||||
}
|
||||
}
|
||||
if len(found) == 1 {
|
||||
return found[0], nil
|
||||
}
|
||||
if len(found) > 1 {
|
||||
return nil, badRequest("%s has %d reservations; give the address or the ID", m, len(found))
|
||||
}
|
||||
}
|
||||
return nil, notFound("no reservation %q (give its ID, address or MAC address)", ref)
|
||||
}
|
||||
|
||||
func (s *Server) getReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
res, err := reservationRef(st, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentReservation(res, st))
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyReservationInput(res *model.Reservation, in *api.ReservationInput) error {
|
||||
if in.MAC != nil {
|
||||
res.MAC = ""
|
||||
if strings.TrimSpace(*in.MAC) != "" {
|
||||
m, err := model.NormalizeMAC(*in.MAC)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
res.MAC = m
|
||||
}
|
||||
}
|
||||
if in.ClientID != nil {
|
||||
res.ClientID = ""
|
||||
if strings.TrimSpace(*in.ClientID) != "" {
|
||||
id, err := model.NormalizeClientID(*in.ClientID)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
res.ClientID = id
|
||||
}
|
||||
}
|
||||
if in.IP != nil {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(*in.IP))
|
||||
if err != nil || !a.Is4() {
|
||||
return badRequest("%q is not an IPv4 address", *in.IP)
|
||||
}
|
||||
res.IP = a
|
||||
}
|
||||
if in.Hostname != nil {
|
||||
res.Hostname = strings.TrimSpace(*in.Hostname)
|
||||
}
|
||||
if in.Options != nil {
|
||||
opts, err := optionsFrom(*in.Options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res.Options = opts
|
||||
}
|
||||
if in.Comment != nil {
|
||||
res.Comment = *in.Comment
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var in api.ReservationInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.IP == nil {
|
||||
return badRequest("a reservation needs an address")
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
|
||||
res := &model.Reservation{Options: []model.OptionValue{}}
|
||||
if err := applyReservationInput(res, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
id = newID()
|
||||
res.ID = id
|
||||
st.Reservations = append(st.Reservations, res)
|
||||
return nil
|
||||
}, func(st *model.State) any {
|
||||
if res := st.ReservationByID(id); res != nil {
|
||||
return s.presentReservation(res, st)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) updateReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
var in api.ReservationInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
res, err := reservationRef(st, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
id = res.ID
|
||||
return applyReservationInput(res, &in)
|
||||
}, func(st *model.State) any {
|
||||
if res := st.ReservationByID(id); res != nil {
|
||||
return s.presentReservation(res, st)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) deleteReservation(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
|
||||
res, err := reservationRef(st, r.PathValue("ref"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var keep []*model.Reservation
|
||||
for _, x := range st.Reservations {
|
||||
if x.ID != res.ID {
|
||||
keep = append(keep, x)
|
||||
}
|
||||
}
|
||||
st.Reservations = keep
|
||||
return nil
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// ---- history
|
||||
|
||||
func (s *Server) listHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
hs, err := s.store.ListHistory(r.Context(), queryInt(r, "limit", 100), int64(queryInt(r, "before", 0)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, hs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
h, err := s.store.GetHistory(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return notFound("history entry %d not found", id)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, h)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) revertHistory(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := s.eng.Revert(r.Context(), p, id, engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)})
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, res)
|
||||
return nil
|
||||
}
|
||||
s.respondChange(w, r, res, nil, false)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- catalog and interfaces
|
||||
|
||||
func (s *Server) catalog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
out := api.Catalog{Types: dhcp4.Types, Reserved: map[int]string{}, Roles: map[string][]string{}}
|
||||
for _, d := range dhcp4.Catalog {
|
||||
out.Options = append(out.Options, api.OptionDef{Code: int(d.Code), Name: d.Name, Type: d.Type, Help: d.Help, Common: d.Common})
|
||||
}
|
||||
for c, why := range dhcp4.Reserved {
|
||||
out.Reserved[int(c)] = why
|
||||
}
|
||||
for n, gs := range auth.Roles {
|
||||
out.Roles[n] = grantStrings(gs)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) interfaces(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, false); err != nil {
|
||||
return err
|
||||
}
|
||||
all, err := s.ifaces.All()
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading the interfaces: %w", err)
|
||||
}
|
||||
st := s.eng.State()
|
||||
out := []api.HostInterface{}
|
||||
for name, ps := range all {
|
||||
hi := api.HostInterface{Name: name, Addresses: []string{}, Subnets: []int64{}}
|
||||
for _, a := range ps {
|
||||
hi.Addresses = append(hi.Addresses, a.String())
|
||||
}
|
||||
for _, sn := range st.Subnets {
|
||||
if sn.Interface == name {
|
||||
hi.Subnets = append(hi.Subnets, sn.ID)
|
||||
}
|
||||
}
|
||||
out = append(out, hi)
|
||||
}
|
||||
// Subnets may name interfaces that don't exist (yet).
|
||||
for _, sn := range st.Subnets {
|
||||
if _, ok := all[sn.Interface]; !ok && sn.Interface != "" {
|
||||
found := false
|
||||
for i := range out {
|
||||
if out[i].Name == sn.Interface {
|
||||
out[i].Subnets = append(out[i].Subnets, sn.ID)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
out = append(out, api.HostInterface{Name: sn.Interface, Addresses: []string{}, Subnets: []int64{sn.ID}})
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/auth"
|
||||
"steward/internal/engine"
|
||||
"steward/internal/kea"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// KeaImport is the body of POST /import/kea.
|
||||
type KeaImport struct {
|
||||
// Config is the kea-dhcp4.conf text; Leases the lease files' texts
|
||||
// (kea-leases4.csv, and kea-leases4.csv.2 if there is one, older
|
||||
// first).
|
||||
Config string `json:"config"`
|
||||
Leases []string `json:"leases,omitempty"`
|
||||
// Interfaces gives subnets' interfaces by prefix, for subnets Kea
|
||||
// doesn't tie to one.
|
||||
Interfaces map[string]string `json:"interfaces,omitempty"`
|
||||
// Replace replaces Steward's configuration when it already has
|
||||
// subnets or reservations.
|
||||
Replace bool `json:"replace,omitempty"`
|
||||
}
|
||||
|
||||
// importKea: POST /import/kea makes Steward's configuration Kea's, and
|
||||
// adds Kea's current leases (?dry_run=1 shows what it would do).
|
||||
func (s *Server) importKea(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResConfig, true); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := require(p, auth.ResLeases, true); err != nil {
|
||||
return err
|
||||
}
|
||||
var in KeaImport
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
host, _ := s.ifaces.All()
|
||||
res, err := kea.ParseConfig([]byte(in.Config), kea.Options{HostInterfaces: host, Interfaces: in.Interfaces})
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
var missing []string
|
||||
for _, sn := range res.State.Subnets {
|
||||
if sn.Interface == "" {
|
||||
missing = append(missing, sn.Prefix.String())
|
||||
}
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
return badRequest("Kea doesn't say which interface serves %s, and no interface here has an address in it; give it: --interface %s=IFACE", strings.Join(missing, ", "), missing[0])
|
||||
}
|
||||
cur := s.eng.State()
|
||||
if (len(cur.Subnets) > 0 || len(cur.Reservations) > 0) && !in.Replace {
|
||||
return conflict("Steward already has %d subnets and %d reservations; import with replace (--replace) to replace them with Kea's", len(cur.Subnets), len(cur.Reservations))
|
||||
}
|
||||
// A subnet with the same prefix, or a reservation for the same address,
|
||||
// keeps its ID, so importing again shows only what differs. Kea has no
|
||||
// subnet names: Steward's stay.
|
||||
ids, err := s.store.AllocateIDs(r.Context(), len(res.State.Subnets)+len(res.State.Reservations))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, sn := range res.State.Subnets {
|
||||
sn.ID = ids[i]
|
||||
for _, old := range cur.Subnets {
|
||||
if old.Prefix == sn.Prefix {
|
||||
sn.ID, sn.Name = old.ID, old.Name
|
||||
if sn.Comment == "" {
|
||||
sn.Comment = old.Comment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for i, rv := range res.State.Reservations {
|
||||
rv.ID = ids[len(res.State.Subnets)+i]
|
||||
for _, old := range cur.Reservations {
|
||||
if old.IP == rv.IP {
|
||||
rv.ID = old.ID
|
||||
if rv.Comment == "" {
|
||||
rv.Comment = old.Comment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Steward's own settings that Kea has no word for stay.
|
||||
res.State.Settings.PingCheck = cur.Settings.PingCheck
|
||||
res.State.Settings.PingTimeout = cur.Settings.PingTimeout
|
||||
res.State.Settings.NotAuthoritative = cur.Settings.NotAuthoritative
|
||||
var leases []*model.Lease
|
||||
skippedLeases := 0
|
||||
now := time.Now()
|
||||
for i, text := range in.Leases {
|
||||
ls, err := kea.ParseLeases(strings.NewReader(text), now)
|
||||
if err != nil {
|
||||
return badRequest("lease file %d: %v", i+1, err)
|
||||
}
|
||||
leases = append(leases, ls...)
|
||||
}
|
||||
// A later file's lease for an address replaces an earlier one's.
|
||||
byIP := map[string]*model.Lease{}
|
||||
for _, l := range leases {
|
||||
sn := res.State.SubnetFor(l.IP)
|
||||
if sn == nil {
|
||||
skippedLeases++
|
||||
continue
|
||||
}
|
||||
l.SubnetID, l.Interface = sn.ID, sn.Interface
|
||||
byIP[l.IP.String()] = l
|
||||
}
|
||||
leases = leases[:0]
|
||||
for _, l := range byIP {
|
||||
leases = append(leases, l)
|
||||
}
|
||||
sort.Slice(leases, func(i, j int) bool { return leases[i].IP.Less(leases[j].IP) })
|
||||
if skippedLeases > 0 {
|
||||
res.Notes = append(res.Notes, fmt.Sprintf("%d leases are for addresses outside the imported subnets and were left out", skippedLeases))
|
||||
}
|
||||
result, err := s.eng.Replace(r.Context(), p, "import from Kea", engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}, res.State)
|
||||
if err != nil {
|
||||
s.writeErr(w, r, err, result)
|
||||
return nil
|
||||
}
|
||||
if result.Applied && len(leases) > 0 {
|
||||
if err := s.dhcp.ImportLeases(r.Context(), leases); err != nil {
|
||||
return fmt.Errorf("the configuration was imported, but saving the leases failed: %w", err)
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "lease.import", "kea", "success", fmt.Sprintf("%d leases", len(leases)))
|
||||
}
|
||||
out := api.ImportResult{MutationResponse: api.MutationResponse{Applied: result.Applied, DryRun: result.DryRun, HistoryID: result.HistoryID, Changes: result.Changes, Warnings: result.Warnings},
|
||||
Leases: len(leases), Notes: res.Notes, Skipped: res.Skipped}
|
||||
if out.Changes == nil {
|
||||
out.Changes = []model.Change{}
|
||||
}
|
||||
if out.Notes == nil {
|
||||
out.Notes = []string{}
|
||||
}
|
||||
if out.Skipped == nil {
|
||||
out.Skipped = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/auth"
|
||||
"steward/internal/dhcpd"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
"steward/internal/validate"
|
||||
"steward/internal/version"
|
||||
)
|
||||
|
||||
func (s *Server) nowFn() time.Time { return time.Now() }
|
||||
|
||||
func (s *Server) presentLease(l *model.Lease, st *model.State) *api.Lease {
|
||||
c := *l
|
||||
c.State = l.StateAt(s.nowFn())
|
||||
out := &api.Lease{Lease: &c}
|
||||
if sn := st.SubnetFor(l.IP); sn != nil {
|
||||
out.SubnetName = sn.Name
|
||||
if out.SubnetName == "" {
|
||||
out.SubnetName = sn.Prefix.String()
|
||||
}
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
if r.IP == l.IP {
|
||||
out.Reservation, out.ReservedHostname = r.ID, r.Hostname
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// listLeases: GET /leases?subnet=&state=&interface=&q= (q matches the
|
||||
// address, MAC address or host name).
|
||||
func (s *Server) listLeases(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLeases, false); err != nil {
|
||||
return err
|
||||
}
|
||||
st := s.eng.State()
|
||||
q := r.URL.Query()
|
||||
var only *model.Subnet
|
||||
if ref := q.Get("subnet"); ref != "" {
|
||||
var err error
|
||||
if only, err = subnetRef(st, ref); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
state, iface, search := q.Get("state"), q.Get("interface"), strings.ToLower(q.Get("q"))
|
||||
out := []*api.Lease{}
|
||||
for _, l := range s.dhcp.Leases() {
|
||||
pl := s.presentLease(l, st)
|
||||
if only != nil && !only.Prefix.Contains(l.IP) || state != "" && pl.State != state || iface != "" && l.Interface != iface {
|
||||
continue
|
||||
}
|
||||
if search != "" && !strings.Contains(l.IP.String(), search) && !strings.Contains(l.MAC, search) &&
|
||||
!strings.Contains(strings.ToLower(l.Hostname), search) && !strings.Contains(strings.ToLower(pl.ReservedHostname), search) {
|
||||
continue
|
||||
}
|
||||
out = append(out, pl)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func leaseIP(r *http.Request) (netip.Addr, error) {
|
||||
a, err := netip.ParseAddr(r.PathValue("ip"))
|
||||
if err != nil || !a.Is4() {
|
||||
return netip.Addr{}, badRequest("%q is not an IPv4 address", r.PathValue("ip"))
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Server) getLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLeases, false); err != nil {
|
||||
return err
|
||||
}
|
||||
ip, err := leaseIP(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l := s.dhcp.Lease(ip)
|
||||
if l == nil {
|
||||
return notFound("no lease for %s", ip)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, s.presentLease(l, s.eng.State()))
|
||||
return nil
|
||||
}
|
||||
|
||||
// deleteLease forgets a lease, freeing the address.
|
||||
func (s *Server) deleteLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLeases, true); err != nil {
|
||||
return err
|
||||
}
|
||||
ip, err := leaseIP(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
l := s.dhcp.Lease(ip)
|
||||
if err := s.dhcp.DeleteLease(r.Context(), ip); err != nil {
|
||||
if errors.Is(err, dhcpd.ErrNoLease) {
|
||||
return notFound("no lease for %s", ip)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "lease.delete", "lease "+ip.String(), "success", l.MAC+" "+l.Hostname)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
// reserveLease makes a lease's address the client's reservation.
|
||||
func (s *Server) reserveLease(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
ip, err := leaseIP(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in struct {
|
||||
Hostname *string `json:"hostname,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
}
|
||||
if err := decodeOptional(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
l := s.dhcp.Lease(ip)
|
||||
if l == nil || l.MAC == "" {
|
||||
return notFound("no lease for %s", ip)
|
||||
}
|
||||
host := l.Hostname
|
||||
if in.Hostname != nil {
|
||||
host = *in.Hostname
|
||||
} else if !validate.ValidHostname(host) {
|
||||
host = ""
|
||||
}
|
||||
var id int64
|
||||
return s.change(w, r, p, "reserve "+ip.String()+" for "+l.MAC, func(st *model.State, newID func() int64) error {
|
||||
id = newID()
|
||||
st.Reservations = append(st.Reservations, &model.Reservation{ID: id, MAC: l.MAC, IP: ip, Hostname: host, Options: []model.OptionValue{}, Comment: in.Comment})
|
||||
return nil
|
||||
}, func(st *model.State) any {
|
||||
if res := st.ReservationByID(id); res != nil {
|
||||
return s.presentReservation(res, st)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) listEvents(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResLeases, false); err != nil {
|
||||
return err
|
||||
}
|
||||
evs := s.dhcp.Events(queryInt(r, "limit", 200), r.URL.Query().Get("mac"), r.URL.Query().Get("interface"))
|
||||
out := make([]api.Event, len(evs))
|
||||
for i, e := range evs {
|
||||
out[i] = api.Event(e)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- status and audit
|
||||
|
||||
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if !p.Can(auth.ResConfig, false) && !p.Can(auth.ResLeases, false) {
|
||||
return forbidden("permission denied: this needs config:read or leases:read")
|
||||
}
|
||||
st := s.eng.State()
|
||||
leases := s.dhcp.Leases()
|
||||
out := api.Status{Build: version.Info(), Started: s.started, Interfaces: []api.InterfaceStatus{}, Subnets: []api.Subnet{},
|
||||
Problems: s.eng.Problems(), Leases: map[string]int{}, Messages: s.dhcp.Counts(), Database: s.store.Path()}
|
||||
if out.Problems == nil {
|
||||
out.Problems = []validate.Problem{}
|
||||
}
|
||||
for _, is := range s.dhcp.Status() {
|
||||
out.Interfaces = append(out.Interfaces, api.InterfaceStatus(is))
|
||||
}
|
||||
for _, sn := range st.Subnets {
|
||||
out.Subnets = append(out.Subnets, s.presentSubnet(sn, st, leases))
|
||||
}
|
||||
now := s.nowFn()
|
||||
for _, l := range leases {
|
||||
out.Leases[l.StateAt(now)]++
|
||||
}
|
||||
if s.cfg.PingProblem != nil && st.Settings.PingCheck {
|
||||
out.PingProblem = s.cfg.PingProblem()
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := require(p, auth.ResAudit, false); err != nil {
|
||||
return err
|
||||
}
|
||||
q := r.URL.Query()
|
||||
evs, err := s.store.ListAudit(r.Context(), store.AuditFilter{Username: q.Get("user"), Action: q.Get("action"), Result: q.Get("result"),
|
||||
Search: q.Get("q"), Before: int64(queryInt(r, "before", 0)), Limit: queryInt(r, "limit", 200)})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, evs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,404 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/auth"
|
||||
"steward/internal/config"
|
||||
"steward/internal/model"
|
||||
"steward/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) changeOwnPassword(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.Token != nil {
|
||||
return forbidden("passwords can't be changed with an API token; log in to the web UI")
|
||||
}
|
||||
if p.User.ID == 0 {
|
||||
return badRequest("%s on the local socket isn't a Steward user; use 'steward user update USER --password'", p.Name())
|
||||
}
|
||||
var in api.PasswordChange
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), p.User.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.PasswordHash != "" && !auth.CheckPassword(u.PasswordHash, in.Current) {
|
||||
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "failure", "wrong current password")
|
||||
return &httpError{http.StatusForbidden, "wrong_password", "the current password is wrong"}
|
||||
}
|
||||
hash, err := auth.HashPassword(in.Password)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
u.PasswordHash = hash
|
||||
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
||||
return err
|
||||
}
|
||||
keep := ""
|
||||
if se, ok := r.Context().Value(keySession).(*model.Session); ok {
|
||||
keep = se.IDHash
|
||||
}
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, keep)
|
||||
s.eng.Audit(r.Context(), p, "user.password", "user "+u.Username, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func grantsFrom(strs []string, role string) ([]model.Grant, bool, error) {
|
||||
var out []model.Grant
|
||||
set := false
|
||||
if role != "" {
|
||||
gs, ok := auth.Roles[role]
|
||||
if !ok {
|
||||
return nil, false, badRequest("unknown role %q (use %s)", role, strings.Join(auth.RoleNames(), ", "))
|
||||
}
|
||||
out = append(out, gs...)
|
||||
set = true
|
||||
}
|
||||
for _, g := range strs {
|
||||
gr, err := auth.ParseGrant(g)
|
||||
if err != nil {
|
||||
return nil, false, badRequest("%v", err)
|
||||
}
|
||||
out = append(out, gr)
|
||||
set = true
|
||||
}
|
||||
if set && out == nil {
|
||||
out = []model.Grant{}
|
||||
}
|
||||
return out, set, nil
|
||||
}
|
||||
|
||||
func grantStrings(gs []model.Grant) []string {
|
||||
out := []string{}
|
||||
for _, g := range gs {
|
||||
out = append(out, auth.FormatGrant(g))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func presentToken(t *model.Token) api.Token {
|
||||
out := api.Token{Token: t, GrantStrings: grantStrings(t.Grants)}
|
||||
if t.Grants == nil {
|
||||
out.GrantStrings = []string{"(all of the user's)"}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) createTokenFor(w http.ResponseWriter, r *http.Request, p *auth.Principal, u *model.User) error {
|
||||
var in api.TokenInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(in.Name) == "" {
|
||||
return badRequest("a token needs a name")
|
||||
}
|
||||
grants, set, err := grantsFrom(in.Grants, in.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var exp *time.Time
|
||||
switch {
|
||||
case in.ExpiresAt != nil:
|
||||
if in.ExpiresAt.Before(time.Now()) {
|
||||
return badRequest("expires_at is in the past")
|
||||
}
|
||||
exp = in.ExpiresAt
|
||||
case in.ExpiresIn != "":
|
||||
d, err := config.ParseDuration(in.ExpiresIn)
|
||||
if err != nil || d <= 0 {
|
||||
return badRequest("invalid expires_in %q (use e.g. 90d or 720h)", in.ExpiresIn)
|
||||
}
|
||||
t := time.Now().Add(d)
|
||||
exp = &t
|
||||
}
|
||||
// A restricted token can only make tokens no wider than itself.
|
||||
if p.Token != nil && p.Token.Grants != nil {
|
||||
if !set {
|
||||
grants, set = p.Token.Grants, true
|
||||
} else if !auth.Narrower(grants, p.Token.Grants) {
|
||||
return forbidden("a restricted token can only create tokens with a subset of its own grants")
|
||||
}
|
||||
}
|
||||
secret, pid, hash := auth.NewToken()
|
||||
t := &model.Token{UserID: u.ID, Name: strings.TrimSpace(in.Name), PublicID: pid, SecretHash: hash, ExpiresAt: exp}
|
||||
if set {
|
||||
t.Grants = grants
|
||||
}
|
||||
if err := s.store.CreateToken(r.Context(), t); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Username = u.Username
|
||||
s.eng.Audit(r.Context(), p, "token.create", "token "+u.Username+"/"+t.Name, "success", strings.Join(grantStrings(t.Grants), " "))
|
||||
out := presentToken(t)
|
||||
out.Secret = secret
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createOwnToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.User.ID == 0 {
|
||||
return badRequest("%s on the local socket isn't a Steward user and has no tokens; make the token for a user (steward token create NAME --user admin)", p.Name())
|
||||
}
|
||||
return s.createTokenFor(w, r, p, p.User)
|
||||
}
|
||||
|
||||
func (s *Server) listTokens(w http.ResponseWriter, r *http.Request, userID int64) error {
|
||||
ts, err := s.store.ListTokens(r.Context(), userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.Token{}
|
||||
for _, t := range ts {
|
||||
if t.RevokedAt != nil && !queryBool(r, "all") {
|
||||
continue
|
||||
}
|
||||
out = append(out, presentToken(t))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listOwnTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if p.User.ID == 0 {
|
||||
writeJSON(w, http.StatusOK, []api.Token{})
|
||||
return nil
|
||||
}
|
||||
return s.listTokens(w, r, p.User.ID)
|
||||
}
|
||||
|
||||
func (s *Server) listAllTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.listTokens(w, r, 0)
|
||||
}
|
||||
|
||||
func (s *Server) revokeToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := s.store.GetToken(r.Context(), id)
|
||||
if err != nil || t.UserID != p.User.ID && !p.IsAdmin() {
|
||||
return notFound("token %d not found", id)
|
||||
}
|
||||
if err := s.store.RevokeToken(r.Context(), id); err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return conflict("token %d is already revoked", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "token.revoke", "token "+t.Username+"/"+t.Name, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func presentUser(u *model.User) api.User {
|
||||
return api.User{User: u, GrantStrings: grantStrings(u.Grants)}
|
||||
}
|
||||
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
us, err := s.store.ListUsers(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out := []api.User{}
|
||||
for _, u := range us {
|
||||
out = append(out, presentUser(u))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidUsername checks a user name.
|
||||
func ValidUsername(n string) error {
|
||||
if len(n) < 1 || len(n) > 64 {
|
||||
return badRequest("user names have 1 to 64 characters")
|
||||
}
|
||||
for _, c := range n {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || strings.ContainsRune("._-@", c)) {
|
||||
return badRequest("user names may have letters, digits, '.', '_', '-' and '@'")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyUserInput(u *model.User, in *api.UserInput) error {
|
||||
role := ""
|
||||
if in.Role != nil {
|
||||
role = *in.Role
|
||||
}
|
||||
var strs []string
|
||||
if in.Grants != nil {
|
||||
strs = *in.Grants
|
||||
}
|
||||
grants, set, err := grantsFrom(strs, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if set || in.Grants != nil {
|
||||
if grants == nil {
|
||||
grants = []model.Grant{}
|
||||
}
|
||||
u.Grants = grants
|
||||
}
|
||||
if in.Admin != nil {
|
||||
u.Admin = *in.Admin
|
||||
}
|
||||
if in.Disabled != nil {
|
||||
u.Disabled = *in.Disabled
|
||||
}
|
||||
if in.Comment != nil {
|
||||
u.Comment = *in.Comment
|
||||
}
|
||||
if in.Password != nil {
|
||||
if *in.Password == "" {
|
||||
u.PasswordHash = "" // tokens only
|
||||
} else {
|
||||
h, err := auth.HashPassword(*in.Password)
|
||||
if err != nil {
|
||||
return badRequest("%v", err)
|
||||
}
|
||||
u.PasswordHash = h
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) createUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.UserInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if in.Username == nil {
|
||||
return badRequest("a user needs a username")
|
||||
}
|
||||
u := &model.User{Username: strings.TrimSpace(*in.Username)}
|
||||
if err := ValidUsername(u.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return conflict("user %s already exists", u.Username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.create", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
|
||||
writeJSON(w, http.StatusCreated, presentUser(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) userByPath(r *http.Request) (*model.User, error) {
|
||||
id, err := pathID(r, "id")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u, err := s.store.GetUser(r.Context(), id)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return nil, notFound("user %d not found", id)
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
func (s *Server) updateUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var in api.UserInput
|
||||
if err := decodeJSON(r, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
wasAdmin := u.Admin && !u.Disabled
|
||||
if in.Username != nil {
|
||||
u.Username = strings.TrimSpace(*in.Username)
|
||||
if err := ValidUsername(u.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := applyUserInput(u, &in); err != nil {
|
||||
return err
|
||||
}
|
||||
if wasAdmin && (!u.Admin || u.Disabled) {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator; make someone else an administrator first", u.Username)
|
||||
}
|
||||
}
|
||||
if err := s.store.UpdateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
return conflict("user %s already exists", u.Username)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if u.Disabled || in.Password != nil {
|
||||
s.store.DeleteUserSessions(r.Context(), u.ID, "")
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.update", "user "+u.Username, "success", strings.Join(grantStrings(u.Grants), " "))
|
||||
writeJSON(w, http.StatusOK, presentUser(u))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
if err := requireAdmin(p); err != nil {
|
||||
return err
|
||||
}
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID == p.User.ID {
|
||||
return conflict("you can't delete yourself")
|
||||
}
|
||||
if u.Admin && !u.Disabled {
|
||||
if n, err := s.store.CountAdmins(r.Context()); err == nil && n <= 1 {
|
||||
return conflict("%s is the last administrator", u.Username)
|
||||
}
|
||||
}
|
||||
if err := s.store.DeleteUser(r.Context(), u.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
s.eng.Audit(r.Context(), p, "user.delete", "user "+u.Username, "success", "")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) listUserTokens(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can see other users' tokens")
|
||||
}
|
||||
return s.listTokens(w, r, u.ID)
|
||||
}
|
||||
|
||||
func (s *Server) createUserToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
u, err := s.userByPath(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if u.ID != p.User.ID && !p.IsAdmin() {
|
||||
return forbidden("only administrators can make tokens for other users")
|
||||
}
|
||||
return s.createTokenFor(w, r, p, u)
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/auth"
|
||||
"steward/internal/engine"
|
||||
"steward/internal/store"
|
||||
)
|
||||
|
||||
// httpError is an error with an HTTP status.
|
||||
type httpError struct {
|
||||
status int
|
||||
code string
|
||||
msg string
|
||||
}
|
||||
|
||||
func (e *httpError) Error() string { return e.msg }
|
||||
|
||||
func badRequest(format string, args ...any) error {
|
||||
return &httpError{http.StatusBadRequest, "bad_request", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func notFound(format string, args ...any) error {
|
||||
return &httpError{http.StatusNotFound, "not_found", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func forbidden(format string, args ...any) error {
|
||||
return &httpError{http.StatusForbidden, "forbidden", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func conflict(format string, args ...any) error {
|
||||
return &httpError{http.StatusConflict, "conflict", fmt.Sprintf(format, args...)}
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, code, msg string) {
|
||||
writeJSON(w, status, api.ErrorBody{Error: api.ErrorInfo{Code: code, Message: msg}})
|
||||
}
|
||||
|
||||
func engineStatus(kind string) int {
|
||||
switch kind {
|
||||
case engine.KindBadRequest:
|
||||
return http.StatusBadRequest
|
||||
case engine.KindNotFound:
|
||||
return http.StatusNotFound
|
||||
case engine.KindConflict:
|
||||
return http.StatusConflict
|
||||
case engine.KindValidation:
|
||||
return http.StatusUnprocessableEntity
|
||||
}
|
||||
return http.StatusInternalServerError
|
||||
}
|
||||
|
||||
// writeErr writes an error response. result, if given, is the preview of
|
||||
// a refused change.
|
||||
func (s *Server) writeErr(w http.ResponseWriter, r *http.Request, err error, result any) {
|
||||
var he *httpError
|
||||
if errors.As(err, &he) {
|
||||
writeError(w, he.status, he.code, he.msg)
|
||||
return
|
||||
}
|
||||
if ee, ok := engine.AsError(err); ok {
|
||||
body := api.ErrorBody{Error: api.ErrorInfo{Code: ee.Kind, Message: ee.Message, Problems: ee.Problems}}
|
||||
if result != nil {
|
||||
body.Error.Result, _ = json.Marshal(result)
|
||||
}
|
||||
writeJSON(w, engineStatus(ee.Kind), body)
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "not found")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, store.ErrConflict) {
|
||||
writeError(w, http.StatusConflict, "conflict", "already exists")
|
||||
return
|
||||
}
|
||||
s.log.Error("request failed", "method", r.Method, "path", r.URL.Path, "err", err)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error: "+err.Error())
|
||||
}
|
||||
|
||||
// handler is an authenticated API handler.
|
||||
type handler func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error
|
||||
|
||||
// decodeJSON reads a JSON body into v, refusing unknown fields.
|
||||
func decodeJSON(r *http.Request, v any) error {
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if ct != "" && !strings.HasPrefix(ct, "application/json") {
|
||||
return badRequest("expected a JSON body (Content-Type: application/json)")
|
||||
}
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return badRequest("the request body is empty")
|
||||
}
|
||||
var mbe *http.MaxBytesError
|
||||
if errors.As(err, &mbe) {
|
||||
return &httpError{http.StatusRequestEntityTooLarge, "too_large", "the request body is too large"}
|
||||
}
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeOptional is decodeJSON, but an empty body leaves v alone.
|
||||
func decodeOptional(r *http.Request, v any) error {
|
||||
b, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(string(b)) == "" {
|
||||
return nil
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(b)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(v); err != nil {
|
||||
return badRequest("invalid JSON: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func pathID(r *http.Request, name string) (int64, error) {
|
||||
v := r.PathValue(name)
|
||||
id, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, badRequest("invalid %s %q", name, v)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func queryBool(r *http.Request, name string) bool {
|
||||
switch strings.ToLower(r.URL.Query().Get(name)) {
|
||||
case "1", "true", "yes", "on":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func queryInt(r *http.Request, name string, def int) int {
|
||||
if v := r.URL.Query().Get(name); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func require(p *auth.Principal, resource string, write bool) error {
|
||||
if p.Can(resource, write) {
|
||||
return nil
|
||||
}
|
||||
access := "read"
|
||||
if write {
|
||||
access = "write"
|
||||
}
|
||||
return forbidden("permission denied: this needs %s:%s", resource, access)
|
||||
}
|
||||
|
||||
func requireAdmin(p *auth.Principal) error {
|
||||
if !p.IsAdmin() {
|
||||
return forbidden("this needs an administrator (and not a restricted token)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// via names where a request came from, for the history.
|
||||
func via(r *http.Request, p *auth.Principal) string {
|
||||
switch {
|
||||
case strings.Contains(r.UserAgent(), "steward-cli"):
|
||||
return "cli"
|
||||
case p.Method == "session":
|
||||
return "web"
|
||||
}
|
||||
return "api"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"steward/internal/api"
|
||||
"steward/internal/version"
|
||||
)
|
||||
|
||||
func (s *Server) routes() {
|
||||
m := s.mux
|
||||
p := api.Prefix
|
||||
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": version.String()})
|
||||
})
|
||||
// Authentication, users and tokens
|
||||
m.HandleFunc("POST "+p+"/auth/login", s.public(s.login))
|
||||
m.HandleFunc("POST "+p+"/auth/logout", s.authed(s.logout))
|
||||
m.HandleFunc("GET "+p+"/auth/whoami", s.authed(s.whoami))
|
||||
m.HandleFunc("POST "+p+"/me/password", s.authed(s.changeOwnPassword))
|
||||
m.HandleFunc("GET "+p+"/me/tokens", s.authed(s.listOwnTokens))
|
||||
m.HandleFunc("POST "+p+"/me/tokens", s.authed(s.createOwnToken))
|
||||
m.HandleFunc("GET "+p+"/tokens", s.authed(s.listAllTokens))
|
||||
m.HandleFunc("DELETE "+p+"/tokens/{id}", s.authed(s.revokeToken))
|
||||
m.HandleFunc("GET "+p+"/users", s.authed(s.listUsers))
|
||||
m.HandleFunc("POST "+p+"/users", s.authed(s.createUser))
|
||||
m.HandleFunc("PATCH "+p+"/users/{id}", s.authed(s.updateUser))
|
||||
m.HandleFunc("DELETE "+p+"/users/{id}", s.authed(s.deleteUser))
|
||||
m.HandleFunc("GET "+p+"/users/{id}/tokens", s.authed(s.listUserTokens))
|
||||
m.HandleFunc("POST "+p+"/users/{id}/tokens", s.authed(s.createUserToken))
|
||||
// Configuration
|
||||
m.HandleFunc("GET "+p+"/settings", s.authed(s.getSettings))
|
||||
m.HandleFunc("PATCH "+p+"/settings", s.authed(s.updateSettings))
|
||||
m.HandleFunc("GET "+p+"/subnets", s.authed(s.listSubnets))
|
||||
m.HandleFunc("POST "+p+"/subnets", s.authed(s.createSubnet))
|
||||
m.HandleFunc("GET "+p+"/subnets/{ref}", s.authed(s.getSubnet))
|
||||
m.HandleFunc("PATCH "+p+"/subnets/{ref}", s.authed(s.updateSubnet))
|
||||
m.HandleFunc("DELETE "+p+"/subnets/{ref}", s.authed(s.deleteSubnet))
|
||||
m.HandleFunc("GET "+p+"/reservations", s.authed(s.listReservations))
|
||||
m.HandleFunc("POST "+p+"/reservations", s.authed(s.createReservation))
|
||||
m.HandleFunc("GET "+p+"/reservations/{ref}", s.authed(s.getReservation))
|
||||
m.HandleFunc("PATCH "+p+"/reservations/{ref}", s.authed(s.updateReservation))
|
||||
m.HandleFunc("DELETE "+p+"/reservations/{ref}", s.authed(s.deleteReservation))
|
||||
m.HandleFunc("GET "+p+"/history", s.authed(s.listHistory))
|
||||
m.HandleFunc("GET "+p+"/history/{id}", s.authed(s.getHistory))
|
||||
m.HandleFunc("POST "+p+"/history/{id}/revert", s.authed(s.revertHistory))
|
||||
m.HandleFunc("POST "+p+"/import/kea", s.authed(s.importKea))
|
||||
m.HandleFunc("GET "+p+"/catalog", s.authed(s.catalog))
|
||||
m.HandleFunc("GET "+p+"/interfaces", s.authed(s.interfaces))
|
||||
// Leases and activity
|
||||
m.HandleFunc("GET "+p+"/leases", s.authed(s.listLeases))
|
||||
m.HandleFunc("GET "+p+"/leases/{ip}", s.authed(s.getLease))
|
||||
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))
|
||||
// Status and audit
|
||||
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
|
||||
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
|
||||
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
||||
})
|
||||
// Web UI
|
||||
if s.web != nil {
|
||||
files := http.FileServer(http.FS(s.web))
|
||||
etags := contentETags(s.web)
|
||||
m.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.Header().Set("Allow", "GET, HEAD")
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if r.URL.Path == "/" || r.URL.Path == "/index.html" {
|
||||
b, err := fs.ReadFile(s.web, "index.html")
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("ETag", etags["index.html"])
|
||||
http.ServeContent(w, r, "index.html", time.Time{}, bytes.NewReader(b))
|
||||
return
|
||||
}
|
||||
if strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
if tag, ok := etags[strings.TrimPrefix(r.URL.Path, "/")]; ok {
|
||||
w.Header().Set("ETag", tag)
|
||||
}
|
||||
files.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// contentETags gives every embedded file an ETag from its content.
|
||||
func contentETags(fsys fs.FS) map[string]string {
|
||||
tags := map[string]string{}
|
||||
fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
b, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
sum := sha256.Sum256(b)
|
||||
tags[path] = `"` + hex.EncodeToString(sum[:12]) + `"`
|
||||
return nil
|
||||
})
|
||||
return tags
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
// Package server is Steward's HTTP API and serves the web UI.
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"steward/internal/auth"
|
||||
"steward/internal/dhcpd"
|
||||
"steward/internal/engine"
|
||||
"steward/internal/store"
|
||||
"steward/internal/version"
|
||||
)
|
||||
|
||||
// Config configures the HTTP server.
|
||||
type Config struct {
|
||||
Listen []string
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
TrustedProxies []netip.Prefix
|
||||
SessionLifetime time.Duration
|
||||
SessionIdle time.Duration
|
||||
// CookieSecure is auto (secure over HTTPS), always or never.
|
||||
CookieSecure string
|
||||
MaxBodyBytes int64
|
||||
// Socket is the local unix socket ("" for none).
|
||||
Socket string
|
||||
// PingProblem says why the ping check can't run (nil: always fine).
|
||||
PingProblem func() string
|
||||
}
|
||||
|
||||
// Interfaces lists the host's interfaces.
|
||||
type Interfaces interface {
|
||||
All() (map[string][]netip.Prefix, error)
|
||||
}
|
||||
|
||||
// Server is the HTTP server.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
eng *engine.Engine
|
||||
dhcp *dhcpd.Server
|
||||
ifaces Interfaces
|
||||
store *store.Store
|
||||
log *slog.Logger
|
||||
mux *http.ServeMux
|
||||
limiter *rateLimiter
|
||||
web fs.FS
|
||||
started time.Time
|
||||
certs *certReloader
|
||||
|
||||
mu sync.Mutex
|
||||
srvs []*http.Server
|
||||
}
|
||||
|
||||
// New makes a server. web holds the web UI (nil: API only).
|
||||
func New(cfg Config, eng *engine.Engine, dhcp *dhcpd.Server, ifaces Interfaces, log *slog.Logger, web fs.FS) *Server {
|
||||
if cfg.SessionLifetime == 0 {
|
||||
cfg.SessionLifetime = 12 * time.Hour
|
||||
}
|
||||
if cfg.SessionIdle == 0 {
|
||||
cfg.SessionIdle = 2 * time.Hour
|
||||
}
|
||||
if cfg.MaxBodyBytes == 0 {
|
||||
cfg.MaxBodyBytes = 8 << 20
|
||||
}
|
||||
if cfg.CookieSecure == "" {
|
||||
cfg.CookieSecure = "auto"
|
||||
}
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
s := &Server{cfg: cfg, eng: eng, dhcp: dhcp, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(), web: web, started: time.Now()}
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
|
||||
// Handler returns the HTTP handler with all middleware.
|
||||
func (s *Server) Handler() http.Handler {
|
||||
return s.recoverer(s.securityHeaders(s.logRequests(s.mux)))
|
||||
}
|
||||
|
||||
func lowPort(addr string) bool {
|
||||
_, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
n, err := strconv.Atoi(port)
|
||||
return err == nil && n > 0 && n < 1024
|
||||
}
|
||||
|
||||
// Serve listens until ctx ends. ready is called once listening.
|
||||
func (s *Server) Serve(ctx context.Context, ready func()) error {
|
||||
if len(s.cfg.Listen) == 0 {
|
||||
return errors.New("no listen addresses configured")
|
||||
}
|
||||
var tlsCfg *tls.Config
|
||||
if s.cfg.TLSCertFile != "" {
|
||||
cr, err := newCertReloader(s.cfg.TLSCertFile, s.cfg.TLSKeyFile, s.log)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.certs = cr
|
||||
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: cr.get, NextProtos: []string{"h2", "http/1.1"}}
|
||||
}
|
||||
errc := make(chan error, len(s.cfg.Listen)+1)
|
||||
var listeners []net.Listener
|
||||
for _, addr := range s.cfg.Listen {
|
||||
ln, err := net.Listen("tcp", addr)
|
||||
if err != nil {
|
||||
for _, l := range listeners {
|
||||
l.Close()
|
||||
}
|
||||
if errors.Is(err, syscall.EACCES) && lowPort(addr) {
|
||||
return fmt.Errorf("listening on %s: %w (ports below 1024 need CAP_NET_BIND_SERVICE, which steward.service grants)", addr, err)
|
||||
}
|
||||
return fmt.Errorf("listening on %s: %w", addr, err)
|
||||
}
|
||||
listeners = append(listeners, ln)
|
||||
}
|
||||
for i, ln := range listeners {
|
||||
srv := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
ReadTimeout: 2 * time.Minute,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
TLSConfig: tlsCfg,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.mu.Unlock()
|
||||
scheme := "http"
|
||||
if tlsCfg != nil {
|
||||
scheme = "https"
|
||||
ln = tls.NewListener(ln, tlsCfg)
|
||||
} else if host, _, err := net.SplitHostPort(s.cfg.Listen[i]); err == nil {
|
||||
if ip, err := netip.ParseAddr(host); host == "" || err == nil && !ip.IsLoopback() {
|
||||
s.log.Warn("serving plain HTTP on a non-loopback address: passwords and API tokens travel unencrypted; configure tls or a TLS reverse proxy", "addr", s.cfg.Listen[i])
|
||||
}
|
||||
}
|
||||
s.log.Info("listening", "addr", s.cfg.Listen[i], "url", scheme+"://"+ln.Addr().String())
|
||||
go func(srv *http.Server, ln net.Listener) {
|
||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}(srv, ln)
|
||||
}
|
||||
s.serveSocket(errc)
|
||||
if ready != nil {
|
||||
ready()
|
||||
}
|
||||
go s.housekeeping(ctx)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
case err := <-errc:
|
||||
s.shutdown()
|
||||
return err
|
||||
}
|
||||
s.shutdown()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) serveSocket(errc chan error) {
|
||||
if s.cfg.Socket == "" {
|
||||
return
|
||||
}
|
||||
ln, err := listenSocket(s.cfg.Socket)
|
||||
if err != nil {
|
||||
s.log.Warn("not listening on the local socket", "path", s.cfg.Socket, "err", err)
|
||||
return
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: s.Handler(),
|
||||
ConnContext: socketConnContext,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
WriteTimeout: 5 * time.Minute,
|
||||
IdleTimeout: 2 * time.Minute,
|
||||
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelWarn),
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.srvs = append(s.srvs, srv)
|
||||
s.mu.Unlock()
|
||||
s.log.Info("listening", "socket", s.cfg.Socket)
|
||||
go func() {
|
||||
if err := srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
|
||||
errc <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Server) shutdown() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for _, srv := range s.srvs {
|
||||
srv.Shutdown(ctx)
|
||||
}
|
||||
if s.cfg.Socket != "" {
|
||||
os.Remove(s.cfg.Socket)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) housekeeping(ctx context.Context) {
|
||||
t := time.NewTicker(10 * time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
if err := s.store.PurgeSessions(ctx, time.Now()); err != nil {
|
||||
s.log.Warn("purging sessions failed", "err", err)
|
||||
}
|
||||
s.limiter.cleanup()
|
||||
if s.certs != nil {
|
||||
s.certs.reloadIfChanged()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadTLS rereads the certificate (on SIGHUP).
|
||||
func (s *Server) ReloadTLS() {
|
||||
if s.certs != nil {
|
||||
s.certs.reload()
|
||||
}
|
||||
}
|
||||
|
||||
// certReloader serves a certificate that is reread when its file changes.
|
||||
type certReloader struct {
|
||||
certFile, keyFile string
|
||||
log *slog.Logger
|
||||
mu sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
func newCertReloader(certFile, keyFile string, log *slog.Logger) (*certReloader, error) {
|
||||
cr := &certReloader{certFile: certFile, keyFile: keyFile, log: log}
|
||||
if err := cr.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
func (cr *certReloader) load() error {
|
||||
cert, err := tls.LoadX509KeyPair(cr.certFile, cr.keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading the TLS certificate: %w", err)
|
||||
}
|
||||
fi, _ := os.Stat(cr.certFile)
|
||||
cr.mu.Lock()
|
||||
cr.cert = &cert
|
||||
if fi != nil {
|
||||
cr.modTime = fi.ModTime()
|
||||
}
|
||||
cr.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cr *certReloader) reload() {
|
||||
if err := cr.load(); err != nil {
|
||||
cr.log.Error("reloading the TLS certificate failed", "err", err)
|
||||
return
|
||||
}
|
||||
cr.log.Info("TLS certificate reloaded", "file", cr.certFile)
|
||||
}
|
||||
|
||||
func (cr *certReloader) reloadIfChanged() {
|
||||
fi, err := os.Stat(cr.certFile)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cr.mu.RLock()
|
||||
changed := fi.ModTime().After(cr.modTime)
|
||||
cr.mu.RUnlock()
|
||||
if changed {
|
||||
cr.reload()
|
||||
}
|
||||
}
|
||||
|
||||
func (cr *certReloader) get(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
cr.mu.RLock()
|
||||
defer cr.mu.RUnlock()
|
||||
return cr.cert, nil
|
||||
}
|
||||
|
||||
// ---- middleware
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const (
|
||||
keyPrincipal ctxKey = iota
|
||||
keySession
|
||||
)
|
||||
|
||||
func (s *Server) recoverer(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if v := recover(); v != nil {
|
||||
if v == http.ErrAbortHandler {
|
||||
panic(v)
|
||||
}
|
||||
s.log.Error("panic serving a request", "path", r.URL.Path, "panic", v)
|
||||
writeError(w, http.StatusInternalServerError, "internal", "internal server error")
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) securityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
h := w.Header()
|
||||
h.Set("Server", "Steward/"+version.Short())
|
||||
h.Set("X-Content-Type-Options", "nosniff")
|
||||
h.Set("X-Frame-Options", "DENY")
|
||||
h.Set("Referrer-Policy", "no-referrer")
|
||||
h.Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
|
||||
h.Set("Cross-Origin-Opener-Policy", "same-origin")
|
||||
if s.isHTTPS(r) {
|
||||
h.Set("Strict-Transport-Security", "max-age=31536000")
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
type statusWriter struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (w *statusWriter) WriteHeader(code int) {
|
||||
w.status = code
|
||||
w.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }
|
||||
|
||||
func (s *Server) logRequests(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
sw := &statusWriter{ResponseWriter: w, status: 200}
|
||||
next.ServeHTTP(sw, r)
|
||||
if r.URL.Path == "/healthz" || strings.HasPrefix(r.URL.Path, "/assets/") {
|
||||
return
|
||||
}
|
||||
user := ""
|
||||
if p, ok := r.Context().Value(keyPrincipal).(*auth.Principal); ok {
|
||||
user = p.Name()
|
||||
}
|
||||
s.log.Debug("http", "method", r.Method, "path", r.URL.Path, "status", sw.status, "duration", time.Since(start).Round(time.Millisecond), "ip", s.clientIP(r), "user", user)
|
||||
})
|
||||
}
|
||||
|
||||
// clientIP is the client's address, from X-Forwarded-For when the request
|
||||
// came through a trusted proxy.
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
if peerOf(r.Context()) != nil {
|
||||
return "local"
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
host = r.RemoteAddr
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil || !s.trusted(peer) {
|
||||
return host
|
||||
}
|
||||
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
a, err := netip.ParseAddr(strings.TrimSpace(parts[i]))
|
||||
if err != nil {
|
||||
return host
|
||||
}
|
||||
if !s.trusted(a) {
|
||||
return a.String()
|
||||
}
|
||||
}
|
||||
return host
|
||||
}
|
||||
|
||||
func (s *Server) trusted(a netip.Addr) bool {
|
||||
a = a.Unmap()
|
||||
for _, p := range s.cfg.TrustedProxies {
|
||||
if p.Contains(a) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isHTTPS reports whether the request reached Steward over HTTPS: TLS to
|
||||
// stewardd, the local socket, or a trusted proxy that says so (the last
|
||||
// X-Forwarded-Proto value).
|
||||
func (s *Server) isHTTPS(r *http.Request) bool {
|
||||
if r.TLS != nil || peerOf(r.Context()) != nil {
|
||||
return true
|
||||
}
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
peer, err := netip.ParseAddr(host)
|
||||
if err != nil || !s.trusted(peer) {
|
||||
return false
|
||||
}
|
||||
vals := r.Header.Values("X-Forwarded-Proto")
|
||||
if len(vals) == 0 {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(strings.Join(vals, ","), ",")
|
||||
return strings.EqualFold(strings.TrimSpace(parts[len(parts)-1]), "https")
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package server
|
||||
|
||||
// The local unix socket (socket in steward.yaml, /run/steward/steward.sock by
|
||||
// default). It serves the same API as the network listeners, but a request
|
||||
// on it without an Authorization header is trusted by who sent it: the
|
||||
// kernel reports the connecting process's user (SO_PEERCRED), and root and
|
||||
// stewardd's own user get administrator rights without a token. Both can
|
||||
// already do everything (read the database, run 'stewardd user', stop the
|
||||
// service), so this adds convenience, not power: 'sudo steward status'
|
||||
// works on the server with nothing configured, and a lost token or
|
||||
// password never locks out root.
|
||||
//
|
||||
// Anyone else who can reach the socket must send an API token, which then
|
||||
// gives that token's permissions, as on the network. The socket file is
|
||||
// mode 0600 in a 0750 directory, so normally only root and the steward user
|
||||
// can connect at all; the user check doesn't rely on that.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"syscall"
|
||||
|
||||
"steward/internal/auth"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// peer is the process at the other end of a unix socket connection.
|
||||
type peer struct {
|
||||
UID int
|
||||
PID int
|
||||
}
|
||||
|
||||
type peerKey struct{}
|
||||
|
||||
// peerOf returns the socket peer of a request, or nil for network
|
||||
// requests.
|
||||
func peerOf(ctx context.Context) *peer {
|
||||
p, _ := ctx.Value(peerKey{}).(*peer)
|
||||
return p
|
||||
}
|
||||
|
||||
// socketConnContext records the peer's credentials on each connection to
|
||||
// the unix socket.
|
||||
func socketConnContext(ctx context.Context, c net.Conn) context.Context {
|
||||
uc, ok := c.(*net.UnixConn)
|
||||
if !ok {
|
||||
return ctx
|
||||
}
|
||||
p := &peer{UID: -1}
|
||||
if raw, err := uc.SyscallConn(); err == nil {
|
||||
raw.Control(func(fd uintptr) {
|
||||
if cred, err := syscall.GetsockoptUcred(int(fd), syscall.SOL_SOCKET, syscall.SO_PEERCRED); err == nil {
|
||||
p.UID, p.PID = int(cred.Uid), int(cred.Pid)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Even when the credentials can't be read, the request is marked as a
|
||||
// socket request (with no trusted user).
|
||||
return context.WithValue(ctx, peerKey{}, p)
|
||||
}
|
||||
|
||||
// trustedUID reports whether a socket peer gets administrator rights
|
||||
// without a token: root and the user stewardd runs as.
|
||||
func trustedUID(uid int) bool {
|
||||
return uid == 0 || uid == os.Getuid()
|
||||
}
|
||||
|
||||
// socketPrincipal is the administrator for a trusted socket peer. It isn't
|
||||
// a Steward user (ID 0), so it has no tokens or password; the audit log and
|
||||
// history show it by its system user name, e.g. "root", with method
|
||||
// "socket".
|
||||
func socketPrincipal(p *peer, agent string) *auth.Principal {
|
||||
name := "uid " + strconv.Itoa(p.UID)
|
||||
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
|
||||
name = u.Username
|
||||
}
|
||||
return &auth.Principal{User: &model.User{ID: 0, Username: name, Admin: true}, Method: "socket", IP: "local", Agent: agent}
|
||||
}
|
||||
|
||||
// listenSocket creates the unix socket, replacing a stale one left by a
|
||||
// previous run.
|
||||
func listenSocket(path string) (net.Listener, error) {
|
||||
if len(path) > 107 {
|
||||
return nil, fmt.Errorf("the path is %d bytes long; unix sockets allow at most 107", len(path))
|
||||
}
|
||||
// Under systemd the directory is RuntimeDirectory=steward; elsewhere
|
||||
// make it.
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if fi, err := os.Lstat(path); err == nil {
|
||||
if fi.Mode()&fs.ModeSocket == 0 {
|
||||
return nil, fmt.Errorf("%s exists and isn't a socket", path)
|
||||
}
|
||||
// A socket nobody answers on is left over from a previous run.
|
||||
if c, err := net.Dial("unix", path); err == nil {
|
||||
c.Close()
|
||||
return nil, fmt.Errorf("%s is in use by another stewardd", path)
|
||||
}
|
||||
os.Remove(path)
|
||||
}
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.Chmod(path, 0o600); err != nil {
|
||||
ln.Close()
|
||||
return nil, err
|
||||
}
|
||||
return ln, nil
|
||||
}
|
||||
|
||||
// socketDenied explains a refused tokenless request on the socket.
|
||||
func socketDenied(p *peer) error {
|
||||
who := "an unknown user"
|
||||
if p.UID >= 0 {
|
||||
who = "uid " + strconv.Itoa(p.UID)
|
||||
if u, err := user.LookupId(strconv.Itoa(p.UID)); err == nil {
|
||||
who = u.Username
|
||||
}
|
||||
}
|
||||
return &httpError{http.StatusUnauthorized, "unauthenticated", "the local socket gives administrator rights without a token only to root and stewardd's own user, not to " + who + "; send an API token (--token), or run the command as root"}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func scanHistory(sc interface{ Scan(...any) error }) (*model.HistoryEntry, error) {
|
||||
e := &model.HistoryEntry{}
|
||||
var t, changes string
|
||||
var uid, revertOf, revertedBy sql.NullInt64
|
||||
if err := sc.Scan(&e.ID, &t, &uid, &e.Username, &e.Via, &e.Description, &changes, &revertOf, &revertedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Time = parseTS(t)
|
||||
e.UserID, e.RevertOf, e.RevertedBy = uid.Int64, revertOf.Int64, revertedBy.Int64
|
||||
if err := json.Unmarshal([]byte(changes), &e.Changes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
const historyCols = `id, time, user_id, username, via, description, changes, revert_of, reverted_by`
|
||||
|
||||
// ListHistory lists entries, newest first; before > 0 pages back.
|
||||
func (s *Store) ListHistory(ctx context.Context, limit int, before int64) ([]*model.HistoryEntry, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
q := `SELECT ` + historyCols + ` FROM history`
|
||||
var args []any
|
||||
if before > 0 {
|
||||
q += ` WHERE id < ?`
|
||||
args = append(args, before)
|
||||
}
|
||||
q += ` ORDER BY id DESC LIMIT ?`
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*model.HistoryEntry{}
|
||||
for rows.Next() {
|
||||
e, err := scanHistory(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GetHistory loads one entry.
|
||||
func (s *Store) GetHistory(ctx context.Context, id int64) (*model.HistoryEntry, error) {
|
||||
e, err := scanHistory(s.db.QueryRowContext(ctx, `SELECT `+historyCols+` FROM history WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return e, err
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
const leaseCols = `ip, subnet_id, mac, client_id, hostname, state, start, end, interface, updated`
|
||||
|
||||
// LoadLeases reads every lease.
|
||||
func (s *Store) LoadLeases(ctx context.Context) ([]*model.Lease, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+leaseCols+` FROM leases`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*model.Lease
|
||||
for rows.Next() {
|
||||
l := &model.Lease{}
|
||||
var ip, start, end, updated string
|
||||
if err := rows.Scan(&ip, &l.SubnetID, &l.MAC, &l.ClientID, &l.Hostname, &l.State, &start, &end, &l.Interface, &updated); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
l.IP, _ = netip.ParseAddr(ip)
|
||||
l.Start, l.End, l.Updated = parseTS(start), parseTS(end), parseTS(updated)
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PutLease writes a lease (replacing the address's previous one). It
|
||||
// returns once the write is durable enough that a crash doesn't lose it:
|
||||
// the DHCP server answers the client only afterwards.
|
||||
func (s *Store) PutLease(ctx context.Context, l *model.Lease) error {
|
||||
return s.PutLeases(ctx, []*model.Lease{l})
|
||||
}
|
||||
|
||||
// PutLeases writes several leases in one transaction (imports).
|
||||
func (s *Store) PutLeases(ctx context.Context, ls []*model.Lease) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, l := range ls {
|
||||
if _, err := tx.ExecContext(ctx, `INSERT INTO leases (`+leaseCols+`) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(ip) DO UPDATE SET subnet_id = excluded.subnet_id, mac = excluded.mac, client_id = excluded.client_id, hostname = excluded.hostname,
|
||||
state = excluded.state, start = excluded.start, end = excluded.end, interface = excluded.interface, updated = excluded.updated`,
|
||||
l.IP.String(), l.SubnetID, l.MAC, l.ClientID, l.Hostname, l.State, ts(l.Start), ts(l.End), l.Interface, ts(l.Updated)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteLease removes the lease of an address.
|
||||
func (s *Store) DeleteLease(ctx context.Context, ip netip.Addr) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE ip = ?`, ip.String())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PruneLeases deletes leases that ended before a time and aren't active
|
||||
// (they are kept a while so returning clients get their old address).
|
||||
func (s *Store) PruneLeases(ctx context.Context, before time.Time) (int64, error) {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM leases WHERE end < ?`, ts(before))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
const settingsKey = "settings"
|
||||
|
||||
// LoadState reads the whole configuration.
|
||||
func (s *Store) LoadState(ctx context.Context) (*model.State, error) {
|
||||
st := &model.State{Settings: model.DefaultSettings(), Subnets: []*model.Subnet{}, Reservations: []*model.Reservation{}}
|
||||
if v, err := s.GetMeta(ctx, settingsKey); err != nil {
|
||||
return nil, err
|
||||
} else if v != "" {
|
||||
if err := json.Unmarshal([]byte(v), &st.Settings); err != nil {
|
||||
return nil, fmt.Errorf("reading settings: %w", err)
|
||||
}
|
||||
}
|
||||
if st.Settings.Options == nil {
|
||||
st.Settings.Options = []model.OptionValue{}
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, name, interface, prefix, pools, lease_time, options, 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 disabled int
|
||||
if err := rows.Scan(&sn.ID, &sn.Name, &sn.Interface, &prefix, &pools, &sn.LeaseTime, &opts, &disabled, &sn.Comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sn.Prefix, _ = netip.ParsePrefix(prefix)
|
||||
sn.Disabled = disabled != 0
|
||||
if err := json.Unmarshal([]byte(pools), &sn.Pools); err != nil {
|
||||
return nil, fmt.Errorf("subnet %d pools: %w", sn.ID, err)
|
||||
}
|
||||
if err := json.Unmarshal([]byte(opts), &sn.Options); err != nil {
|
||||
return nil, fmt.Errorf("subnet %d options: %w", sn.ID, err)
|
||||
}
|
||||
st.Subnets = append(st.Subnets, sn)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err = s.db.QueryContext(ctx, `SELECT id, mac, client_id, ip, hostname, options, comment FROM reservations ORDER BY id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
r := &model.Reservation{}
|
||||
var ip, opts string
|
||||
if err := rows.Scan(&r.ID, &r.MAC, &r.ClientID, &ip, &r.Hostname, &opts, &r.Comment); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.IP, _ = netip.ParseAddr(ip)
|
||||
if err := json.Unmarshal([]byte(opts), &r.Options); err != nil {
|
||||
return nil, fmt.Errorf("reservation %d options: %w", r.ID, err)
|
||||
}
|
||||
st.Reservations = append(st.Reservations, r)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, sn := range st.Subnets {
|
||||
if sn.Pools == nil {
|
||||
sn.Pools = []model.Pool{}
|
||||
}
|
||||
if sn.Options == nil {
|
||||
sn.Options = []model.OptionValue{}
|
||||
}
|
||||
}
|
||||
for _, r := range st.Reservations {
|
||||
if r.Options == nil {
|
||||
r.Options = []model.OptionValue{}
|
||||
}
|
||||
}
|
||||
st.Sort()
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// SaveChanges writes configuration changes and their history entry in one
|
||||
// transaction. The entry gets its ID and, when it undoes another, that
|
||||
// entry is marked reverted.
|
||||
func (s *Store) SaveChanges(ctx context.Context, changes []model.Change, e *model.HistoryEntry) error {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
for _, c := range changes {
|
||||
if err := applyChange(ctx, tx, c); err != nil {
|
||||
return fmt.Errorf("saving %s %d: %w", c.Kind, c.ID, err)
|
||||
}
|
||||
}
|
||||
if e != nil {
|
||||
var uid any
|
||||
if e.UserID != 0 {
|
||||
uid = e.UserID
|
||||
}
|
||||
var revertOf any
|
||||
if e.RevertOf != 0 {
|
||||
revertOf = e.RevertOf
|
||||
}
|
||||
res, err := tx.ExecContext(ctx, `INSERT INTO history (time, user_id, username, via, description, changes, revert_of) VALUES (?,?,?,?,?,?,?)`,
|
||||
ts(e.Time), uid, e.Username, e.Via, e.Description, jsonText(e.Changes), revertOf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if e.ID, err = res.LastInsertId(); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.RevertOf != 0 {
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE history SET reverted_by = ? WHERE id = ?`, e.ID, e.RevertOf); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func applyChange(ctx context.Context, tx *sql.Tx, c model.Change) error {
|
||||
switch c.Kind {
|
||||
case model.KindSettings:
|
||||
if c.After == nil {
|
||||
return fmt.Errorf("settings can't be deleted")
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, settingsKey, string(c.After))
|
||||
return err
|
||||
case model.KindSubnet:
|
||||
if c.After == nil {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM subnets WHERE id = ?`, c.ID)
|
||||
return err
|
||||
}
|
||||
var sn model.Subnet
|
||||
if err := json.Unmarshal(c.After, &sn); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO subnets (id, name, interface, prefix, pools, lease_time, options, disabled, comment) VALUES (?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET name = excluded.name, interface = excluded.interface, prefix = excluded.prefix, pools = excluded.pools,
|
||||
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.LeaseTime, jsonText(nonNilOpts(sn.Options)), boolInt(sn.Disabled), sn.Comment)
|
||||
return err
|
||||
case model.KindReservation:
|
||||
if c.After == nil {
|
||||
_, err := tx.ExecContext(ctx, `DELETE FROM reservations WHERE id = ?`, c.ID)
|
||||
return err
|
||||
}
|
||||
var r model.Reservation
|
||||
if err := json.Unmarshal(c.After, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `INSERT INTO reservations (id, mac, client_id, ip, hostname, options, comment) VALUES (?,?,?,?,?,?,?)
|
||||
ON CONFLICT(id) DO UPDATE SET mac = excluded.mac, client_id = excluded.client_id, ip = excluded.ip, hostname = excluded.hostname,
|
||||
options = excluded.options, comment = excluded.comment`,
|
||||
r.ID, r.MAC, r.ClientID, r.IP.String(), r.Hostname, jsonText(nonNilOpts(r.Options)), r.Comment)
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("unknown kind %q", c.Kind)
|
||||
}
|
||||
|
||||
func nonNil(p []model.Pool) []model.Pool {
|
||||
if p == nil {
|
||||
return []model.Pool{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func nonNilOpts(o []model.OptionValue) []model.OptionValue {
|
||||
if o == nil {
|
||||
return []model.OptionValue{}
|
||||
}
|
||||
return o
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
// Package store keeps Steward's data in SQLite: the configuration, leases,
|
||||
// the history of changes, users, tokens, sessions and the audit log.
|
||||
//
|
||||
// The configuration is loaded into a model.State as a whole and saved as
|
||||
// changes (model.Change); leases are read once at start and written one by
|
||||
// one as the DHCP server hands them out.
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when a row does not exist.
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
// ErrConflict is returned for unique constraint violations.
|
||||
var ErrConflict = errors.New("already exists")
|
||||
|
||||
// Store is a Steward database.
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// DB exposes the database (backups, tests).
|
||||
func (s *Store) DB() *sql.DB { return s.db }
|
||||
|
||||
// Path is the database file ("" in memory).
|
||||
func (s *Store) Path() string {
|
||||
var file string
|
||||
if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil {
|
||||
return ""
|
||||
}
|
||||
return file
|
||||
}
|
||||
|
||||
// Open opens (creating if needed) the database at path and applies
|
||||
// migrations. ":memory:" makes a throwaway database.
|
||||
func Open(path string) (*Store, error) {
|
||||
dsn := "file:" + path + "?_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_pragma=synchronous(NORMAL)&_txlock=immediate&_pragma=journal_mode(WAL)"
|
||||
if path == ":memory:" {
|
||||
dsn = "file::memory:?_pragma=foreign_keys(1)&_txlock=immediate"
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if path == ":memory:" {
|
||||
db.SetMaxOpenConns(1)
|
||||
}
|
||||
s := &Store{db: db}
|
||||
if err := s.migrate(context.Background()); err != nil {
|
||||
db.Close()
|
||||
return nil, fmt.Errorf("migrating database: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// OpenExisting opens a database without creating or migrating it, for
|
||||
// commands that run next to a running stewardd ('stewardd backup').
|
||||
func OpenExisting(path string) (*Store, error) {
|
||||
db, err := sql.Open("sqlite", "file:"+path+"?mode=rw&_pragma=busy_timeout(10000)&_pragma=foreign_keys(1)&_txlock=immediate")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.Ping(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &Store{db: db}, nil
|
||||
}
|
||||
|
||||
// Close closes the database.
|
||||
func (s *Store) Close() error { return s.db.Close() }
|
||||
|
||||
// SchemaVersion is the schema version this Steward uses.
|
||||
func SchemaVersion() int { return len(migrations) }
|
||||
|
||||
// Append new migrations; never edit one that has shipped.
|
||||
var migrations = []string{
|
||||
// 1: initial schema
|
||||
`
|
||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE seq (name TEXT PRIMARY KEY, value INTEGER NOT NULL);
|
||||
INSERT INTO seq (name, value) VALUES ('object', 0);
|
||||
|
||||
CREATE TABLE subnets (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
interface TEXT NOT NULL,
|
||||
prefix TEXT NOT NULL,
|
||||
pools TEXT NOT NULL DEFAULT '[]',
|
||||
lease_time INTEGER NOT NULL DEFAULT 0,
|
||||
options TEXT NOT NULL DEFAULT '[]',
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
comment TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE reservations (
|
||||
id INTEGER PRIMARY KEY,
|
||||
mac TEXT NOT NULL DEFAULT '',
|
||||
client_id TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
options TEXT NOT NULL DEFAULT '[]',
|
||||
comment TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
CREATE TABLE leases (
|
||||
ip TEXT PRIMARY KEY,
|
||||
subnet_id INTEGER NOT NULL,
|
||||
mac TEXT NOT NULL DEFAULT '',
|
||||
client_id TEXT NOT NULL DEFAULT '',
|
||||
hostname TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL,
|
||||
start TEXT NOT NULL,
|
||||
end TEXT NOT NULL,
|
||||
interface TEXT NOT NULL DEFAULT '',
|
||||
updated TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX leases_mac ON leases (mac);
|
||||
|
||||
CREATE TABLE history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
username TEXT NOT NULL,
|
||||
via TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
changes TEXT NOT NULL,
|
||||
revert_of INTEGER,
|
||||
reverted_by INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL DEFAULT '',
|
||||
admin INTEGER NOT NULL DEFAULT 0,
|
||||
grants TEXT NOT NULL DEFAULT '[]',
|
||||
disabled INTEGER NOT NULL DEFAULT 0,
|
||||
comment TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
last_login_at TEXT
|
||||
);
|
||||
CREATE TABLE tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
public_id TEXT NOT NULL UNIQUE,
|
||||
secret_hash TEXT NOT NULL,
|
||||
grants TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT,
|
||||
last_used_ip TEXT NOT NULL DEFAULT '',
|
||||
revoked_at TEXT
|
||||
);
|
||||
CREATE TABLE sessions (
|
||||
id_hash TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
csrf_token TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
user_agent TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
time TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
auth TEXT NOT NULL DEFAULT '',
|
||||
token_id INTEGER,
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
object TEXT NOT NULL DEFAULT '',
|
||||
result TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX audit_time ON audit (time);
|
||||
`,
|
||||
}
|
||||
|
||||
func (s *Store) migrate(ctx context.Context) error {
|
||||
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL)`); err != nil {
|
||||
return err
|
||||
}
|
||||
var v int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
if _, err := s.db.ExecContext(ctx, `INSERT INTO schema_version (version) VALUES (0)`); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
if v > len(migrations) {
|
||||
return fmt.Errorf("database schema version %d is newer than this Steward (%d); upgrade Steward", v, len(migrations))
|
||||
}
|
||||
for i := v; i < len(migrations); i++ {
|
||||
tx, err := s.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, migrations[i]); err != nil {
|
||||
tx.Rollback()
|
||||
return fmt.Errorf("migration %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx, `UPDATE schema_version SET version = ?`, i+1); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Version returns the database's schema version.
|
||||
func (s *Store) Version(ctx context.Context) (int, error) {
|
||||
var v int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT version FROM schema_version`).Scan(&v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// AllocateIDs reserves n object IDs (subnets and reservations share one
|
||||
// sequence, so an ID names one object).
|
||||
func (s *Store) AllocateIDs(ctx context.Context, n int) ([]int64, error) {
|
||||
if n <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var last int64
|
||||
if err := s.db.QueryRowContext(ctx, `UPDATE seq SET value = value + ? WHERE name = 'object' RETURNING value`, n).Scan(&last); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]int64, n)
|
||||
for i := range ids {
|
||||
ids[i] = last - int64(n) + 1 + int64(i)
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
// GetMeta reads a meta value ("" when unset).
|
||||
func (s *Store) GetMeta(ctx context.Context, key string) (string, error) {
|
||||
var v string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT value FROM meta WHERE key = ?`, key).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
|
||||
// SetMeta writes a meta value.
|
||||
func (s *Store) SetMeta(ctx context.Context, key, value string) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
|
||||
return err
|
||||
}
|
||||
|
||||
// Backup writes a consistent copy of the database to path.
|
||||
func (s *Store) Backup(ctx context.Context, path string) error {
|
||||
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, path)
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
|
||||
const tsLayout = "2006-01-02T15:04:05.000000000Z07:00"
|
||||
|
||||
func ts(t time.Time) string { return t.UTC().Format(tsLayout) }
|
||||
|
||||
func tsPtr(t *time.Time) any {
|
||||
if t == nil {
|
||||
return nil
|
||||
}
|
||||
return ts(*t)
|
||||
}
|
||||
|
||||
func parseTS(s string) time.Time {
|
||||
t, err := time.Parse(time.RFC3339Nano, s)
|
||||
if err != nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func parseTSPtr(s sql.NullString) *time.Time {
|
||||
if !s.Valid || s.String == "" {
|
||||
return nil
|
||||
}
|
||||
t := parseTS(s.String)
|
||||
return &t
|
||||
}
|
||||
|
||||
func boolInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func jsonText(v any) string {
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func isUnique(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "UNIQUE constraint failed")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/netip"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func raw(v any) json.RawMessage {
|
||||
b, _ := json.Marshal(v)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestStateHistoryAndLeases(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(filepath.Join(t.TempDir(), "steward.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
st, err := s.LoadState(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Settings.EffectiveLeaseTime() != model.DefaultLeaseTime || len(st.Subnets) != 0 || !st.Settings.PingCheck {
|
||||
t.Fatalf("new database state: %+v", st)
|
||||
}
|
||||
ids, _ := s.AllocateIDs(ctx, 2)
|
||||
sn := &model.Subnet{ID: ids[0], Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"),
|
||||
Pools: []model.Pool{{Start: netip.MustParseAddr("10.0.10.100"), End: netip.MustParseAddr("10.0.10.199")}},
|
||||
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}}
|
||||
r := &model.Reservation{ID: ids[1], MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"}
|
||||
set := st.Settings
|
||||
set.LeaseTime = 3600
|
||||
e := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "cli", Description: "add office",
|
||||
Changes: []model.Change{
|
||||
{Kind: model.KindSubnet, ID: sn.ID, After: raw(sn)},
|
||||
{Kind: model.KindReservation, ID: r.ID, After: raw(r)},
|
||||
{Kind: model.KindSettings, Before: raw(st.Settings), After: raw(set)},
|
||||
}}
|
||||
if err := s.SaveChanges(ctx, e.Changes, e); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ = s.LoadState(ctx)
|
||||
if len(st.Subnets) != 1 || st.Subnets[0].Pools[0].Size() != 100 || st.Subnets[0].Options[0].Value != "10.0.10.1" {
|
||||
t.Errorf("subnets %+v", st.Subnets)
|
||||
}
|
||||
if len(st.Reservations) != 1 || st.Reservations[0].Hostname != "nas" || st.Settings.LeaseTime != 3600 {
|
||||
t.Errorf("reservations %+v settings %+v", st.Reservations, st.Settings)
|
||||
}
|
||||
// Undo: the inverse changes, marked as a revert.
|
||||
var inv []model.Change
|
||||
for _, c := range e.Changes {
|
||||
inv = append(inv, c.Inverse())
|
||||
}
|
||||
u := &model.HistoryEntry{Time: time.Now(), Username: "admin", Via: "web", Description: "undo", Changes: inv, RevertOf: e.ID}
|
||||
if err := s.SaveChanges(ctx, inv, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, _ = s.LoadState(ctx)
|
||||
if len(st.Subnets) != 0 || len(st.Reservations) != 0 || st.Settings.LeaseTime != model.DefaultLeaseTime {
|
||||
t.Errorf("after undo: %+v", st)
|
||||
}
|
||||
h, err := s.GetHistory(ctx, e.ID)
|
||||
if err != nil || h.RevertedBy != u.ID || len(h.Changes) != 3 {
|
||||
t.Errorf("history %+v %v", h, err)
|
||||
}
|
||||
list, _ := s.ListHistory(ctx, 10, 0)
|
||||
if len(list) != 2 || list[0].ID != u.ID {
|
||||
t.Errorf("history list %+v", list)
|
||||
}
|
||||
|
||||
now := time.Now().Truncate(time.Millisecond)
|
||||
l := &model.Lease{IP: netip.MustParseAddr("10.0.10.100"), SubnetID: sn.ID, MAC: "aa:bb:cc:dd:ee:02", Hostname: "laptop", State: model.LeaseActive,
|
||||
Start: now, End: now.Add(time.Hour), Interface: "eth0", Updated: now}
|
||||
if err := s.PutLease(ctx, l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l.End = now.Add(2 * time.Hour)
|
||||
if err := s.PutLease(ctx, l); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ls, _ := s.LoadLeases(ctx)
|
||||
if len(ls) != 1 || !ls[0].End.Equal(l.End) || ls[0].Hostname != "laptop" {
|
||||
t.Errorf("leases %+v", ls)
|
||||
}
|
||||
if n, _ := s.PruneLeases(ctx, now.Add(3*time.Hour)); n != 1 {
|
||||
t.Errorf("pruned %d", n)
|
||||
}
|
||||
if err := s.DeleteLease(ctx, l.IP); !errors.Is(err, ErrNotFound) {
|
||||
t.Errorf("delete of a pruned lease: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUsersAndAudit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s, err := Open(":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &model.User{Username: "admin", PasswordHash: "x", Admin: true}
|
||||
if err := s.CreateUser(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.CreateUser(ctx, &model.User{Username: "ADMIN"}); !errors.Is(err, ErrConflict) {
|
||||
t.Errorf("duplicate user: %v", err)
|
||||
}
|
||||
tok := &model.Token{UserID: u.ID, Name: "ci", PublicID: "p1", SecretHash: "h"}
|
||||
if err := s.CreateToken(ctx, tok); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := s.GetTokenByPublicID(ctx, "p1")
|
||||
if err != nil || got.Username != "admin" || got.Grants != nil {
|
||||
t.Errorf("token %+v %v", got, err)
|
||||
}
|
||||
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "auth.login", Result: "success"})
|
||||
s.AddAudit(ctx, &model.AuditEvent{Username: "admin", Action: "lease.delete", Object: "10.0.10.5", Result: "success"})
|
||||
evs, _ := s.ListAudit(ctx, AuditFilter{Action: "lease."})
|
||||
if len(evs) != 1 || evs[0].Object != "10.0.10.5" {
|
||||
t.Errorf("audit %+v", evs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
const userCols = `id, username, password_hash, admin, grants, disabled, comment, created_at, updated_at, last_login_at`
|
||||
|
||||
func scanUser(sc interface{ Scan(...any) error }) (*model.User, error) {
|
||||
u := &model.User{}
|
||||
var grants, ca, ua string
|
||||
var admin, disabled int
|
||||
var ll sql.NullString
|
||||
if err := sc.Scan(&u.ID, &u.Username, &u.PasswordHash, &admin, &grants, &disabled, &u.Comment, &ca, &ua, &ll); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
u.Admin, u.Disabled = admin != 0, disabled != 0
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
if err := json.Unmarshal([]byte(grants), &u.Grants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if u.Grants == nil {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
u.CreatedAt, u.UpdatedAt, u.LastLoginAt = parseTS(ca), parseTS(ua), parseTSPtr(ll)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// CreateUser inserts a user.
|
||||
func (s *Store) CreateUser(ctx context.Context, u *model.User) error {
|
||||
now := time.Now()
|
||||
u.CreatedAt, u.UpdatedAt = now, now
|
||||
if u.Grants == nil {
|
||||
u.Grants = []model.Grant{}
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO users (username, password_hash, admin, grants, disabled, comment, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(now), ts(now))
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.ID, err = res.LastInsertId()
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateUser saves a user.
|
||||
func (s *Store) UpdateUser(ctx context.Context, u *model.User) error {
|
||||
u.UpdatedAt = time.Now()
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE users SET username = ?, password_hash = ?, admin = ?, grants = ?, disabled = ?, comment = ?, updated_at = ? WHERE id = ?`,
|
||||
u.Username, u.PasswordHash, boolInt(u.Admin), jsonText(u.Grants), boolInt(u.Disabled), u.Comment, ts(u.UpdatedAt), u.ID)
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
u.HasPassword = u.PasswordHash != ""
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchLogin records a successful login.
|
||||
func (s *Store) TouchLogin(ctx context.Context, id int64) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE users SET last_login_at = ? WHERE id = ?`, ts(time.Now()), id)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetUser loads a user by ID.
|
||||
func (s *Store) GetUser(ctx context.Context, id int64) (*model.User, error) {
|
||||
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// GetUserByName loads a user by name (case-insensitive).
|
||||
func (s *Store) GetUserByName(ctx context.Context, name string) (*model.User, error) {
|
||||
u, err := scanUser(s.db.QueryRowContext(ctx, `SELECT `+userCols+` FROM users WHERE username = ?`, name))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return u, err
|
||||
}
|
||||
|
||||
// ListUsers lists all users by name.
|
||||
func (s *Store) ListUsers(ctx context.Context) ([]*model.User, error) {
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT `+userCols+` FROM users ORDER BY username COLLATE NOCASE`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*model.User
|
||||
for rows.Next() {
|
||||
u, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountAdmins counts the enabled administrators.
|
||||
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE admin = 1 AND disabled = 0`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// DeleteUser removes a user with their tokens and sessions.
|
||||
func (s *Store) DeleteUser(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
const tokenCols = `t.id, t.user_id, u.username, t.name, t.public_id, t.secret_hash, t.grants, t.expires_at, t.created_at, t.last_used_at, t.last_used_ip, t.revoked_at`
|
||||
|
||||
func scanToken(sc interface{ Scan(...any) error }) (*model.Token, error) {
|
||||
t := &model.Token{}
|
||||
var grants, exp, lu, rev sql.NullString
|
||||
var ca string
|
||||
if err := sc.Scan(&t.ID, &t.UserID, &t.Username, &t.Name, &t.PublicID, &t.SecretHash, &grants, &exp, &ca, &lu, &t.LastUsedIP, &rev); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if grants.Valid {
|
||||
if err := json.Unmarshal([]byte(grants.String), &t.Grants); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.Grants == nil {
|
||||
t.Grants = []model.Grant{}
|
||||
}
|
||||
}
|
||||
t.ExpiresAt, t.LastUsedAt, t.RevokedAt = parseTSPtr(exp), parseTSPtr(lu), parseTSPtr(rev)
|
||||
t.CreatedAt = parseTS(ca)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// CreateToken inserts a token.
|
||||
func (s *Store) CreateToken(ctx context.Context, t *model.Token) error {
|
||||
t.CreatedAt = time.Now()
|
||||
var grants any
|
||||
if t.Grants != nil {
|
||||
grants = jsonText(t.Grants)
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO tokens (user_id, name, public_id, secret_hash, grants, expires_at, created_at) VALUES (?,?,?,?,?,?,?)`,
|
||||
t.UserID, t.Name, t.PublicID, t.SecretHash, grants, tsPtr(t.ExpiresAt), ts(t.CreatedAt))
|
||||
if isUnique(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.ID, err = res.LastInsertId()
|
||||
return err
|
||||
}
|
||||
|
||||
// GetTokenByPublicID loads a token by its public ID.
|
||||
func (s *Store) GetTokenByPublicID(ctx context.Context, pid string) (*model.Token, error) {
|
||||
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.public_id = ?`, pid))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// GetToken loads a token by ID.
|
||||
func (s *Store) GetToken(ctx context.Context, id int64) (*model.Token, error) {
|
||||
t, err := scanToken(s.db.QueryRowContext(ctx, `SELECT `+tokenCols+` FROM tokens t JOIN users u ON u.id = t.user_id WHERE t.id = ?`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return t, err
|
||||
}
|
||||
|
||||
// ListTokens lists the tokens of one user (userID > 0) or of all.
|
||||
func (s *Store) ListTokens(ctx context.Context, userID int64) ([]*model.Token, error) {
|
||||
q := `SELECT ` + tokenCols + ` FROM tokens t JOIN users u ON u.id = t.user_id`
|
||||
var args []any
|
||||
if userID > 0 {
|
||||
q += ` WHERE t.user_id = ?`
|
||||
args = append(args, userID)
|
||||
}
|
||||
q += ` ORDER BY t.id`
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*model.Token
|
||||
for rows.Next() {
|
||||
t, err := scanToken(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, t)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// RevokeToken marks a token revoked.
|
||||
func (s *Store) RevokeToken(ctx context.Context, id int64) error {
|
||||
res, err := s.db.ExecContext(ctx, `UPDATE tokens SET revoked_at = ? WHERE id = ? AND revoked_at IS NULL`, ts(time.Now()), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// TouchToken records a token's use.
|
||||
func (s *Store) TouchToken(ctx context.Context, id int64, ip string) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE tokens SET last_used_at = ?, last_used_ip = ? WHERE id = ?`, ts(time.Now()), ip, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// CreateSession stores a session.
|
||||
func (s *Store) CreateSession(ctx context.Context, se *model.Session) error {
|
||||
_, err := s.db.ExecContext(ctx, `INSERT INTO sessions (id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
se.IDHash, se.UserID, se.CSRFToken, ts(se.CreatedAt), ts(se.ExpiresAt), ts(se.LastSeenAt), se.IP, se.UserAgent)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetSession loads a session by the hash of its ID.
|
||||
func (s *Store) GetSession(ctx context.Context, idHash string) (*model.Session, error) {
|
||||
se := &model.Session{}
|
||||
var ca, ea, ls string
|
||||
err := s.db.QueryRowContext(ctx, `SELECT id_hash, user_id, csrf_token, created_at, expires_at, last_seen_at, ip, user_agent FROM sessions WHERE id_hash = ?`, idHash).
|
||||
Scan(&se.IDHash, &se.UserID, &se.CSRFToken, &ca, &ea, &ls, &se.IP, &se.UserAgent)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
se.CreatedAt, se.ExpiresAt, se.LastSeenAt = parseTS(ca), parseTS(ea), parseTS(ls)
|
||||
return se, nil
|
||||
}
|
||||
|
||||
// TouchSession updates a session's last-seen time.
|
||||
func (s *Store) TouchSession(ctx context.Context, idHash string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `UPDATE sessions SET last_seen_at = ? WHERE id_hash = ?`, ts(at), idHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteSession removes a session.
|
||||
func (s *Store) DeleteSession(ctx context.Context, idHash string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE id_hash = ?`, idHash)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteUserSessions removes a user's sessions, except keep (if set).
|
||||
func (s *Store) DeleteUserSessions(ctx context.Context, userID int64, keep string) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE user_id = ? AND id_hash != ?`, userID, keep)
|
||||
return err
|
||||
}
|
||||
|
||||
// PurgeSessions removes expired sessions.
|
||||
func (s *Store) PurgeSessions(ctx context.Context, now time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE expires_at < ?`, ts(now))
|
||||
return err
|
||||
}
|
||||
|
||||
// AddAudit stores an audit event.
|
||||
func (s *Store) AddAudit(ctx context.Context, ev *model.AuditEvent) error {
|
||||
if ev.Time.IsZero() {
|
||||
ev.Time = time.Now()
|
||||
}
|
||||
var uid, tid any
|
||||
if ev.UserID != 0 {
|
||||
uid = ev.UserID
|
||||
}
|
||||
if ev.TokenID != 0 {
|
||||
tid = ev.TokenID
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx, `INSERT INTO audit (time, user_id, username, auth, token_id, ip, action, object, result, detail) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
ts(ev.Time), uid, ev.Username, ev.Auth, tid, ev.IP, ev.Action, ev.Object, ev.Result, ev.Detail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ev.ID, _ = res.LastInsertId()
|
||||
return nil
|
||||
}
|
||||
|
||||
// AuditFilter selects audit events.
|
||||
type AuditFilter struct {
|
||||
Username string
|
||||
Action string // prefix: "auth." or "lease.delete"
|
||||
Result string
|
||||
Search string // in the object or the detail
|
||||
Before int64 // IDs below this (paging)
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListAudit lists audit events, newest first.
|
||||
func (s *Store) ListAudit(ctx context.Context, f AuditFilter) ([]*model.AuditEvent, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
if f.Username != "" {
|
||||
where = append(where, "username = ? COLLATE NOCASE")
|
||||
args = append(args, f.Username)
|
||||
}
|
||||
if f.Action != "" {
|
||||
where = append(where, "action LIKE ? ESCAPE '\\'")
|
||||
args = append(args, likeEscape(f.Action)+"%")
|
||||
}
|
||||
if f.Result != "" {
|
||||
where = append(where, "result = ?")
|
||||
args = append(args, f.Result)
|
||||
}
|
||||
if f.Search != "" {
|
||||
where = append(where, "(object LIKE ? ESCAPE '\\' OR detail LIKE ? ESCAPE '\\')")
|
||||
p := "%" + likeEscape(f.Search) + "%"
|
||||
args = append(args, p, p)
|
||||
}
|
||||
if f.Before > 0 {
|
||||
where = append(where, "id < ?")
|
||||
args = append(args, f.Before)
|
||||
}
|
||||
q := `SELECT id, time, user_id, username, auth, token_id, ip, action, object, result, detail FROM audit`
|
||||
if len(where) > 0 {
|
||||
q += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > 1000 {
|
||||
limit = 200
|
||||
}
|
||||
q += " ORDER BY id DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
rows, err := s.db.QueryContext(ctx, q, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []*model.AuditEvent{}
|
||||
for rows.Next() {
|
||||
ev := &model.AuditEvent{}
|
||||
var t string
|
||||
var uid, tid sql.NullInt64
|
||||
if err := rows.Scan(&ev.ID, &t, &uid, &ev.Username, &ev.Auth, &tid, &ev.IP, &ev.Action, &ev.Object, &ev.Result, &ev.Detail); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ev.Time = parseTS(t)
|
||||
ev.UserID, ev.TokenID = uid.Int64, tid.Int64
|
||||
out = append(out, ev)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func likeEscape(s string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(s)
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
// Package validate checks a whole configuration. Every problem has a
|
||||
// stable code (docs/validation.md); errors stop a change from being saved,
|
||||
// warnings are reported with it.
|
||||
package validate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"steward/internal/dhcp4"
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
// Severities.
|
||||
const (
|
||||
Error = "error"
|
||||
Warning = "warning"
|
||||
)
|
||||
|
||||
// Problem is one finding.
|
||||
type Problem struct {
|
||||
Severity string `json:"severity"`
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
// Object names what it is about: "subnet office (10.0.10.0/24)".
|
||||
Object string `json:"object,omitempty"`
|
||||
}
|
||||
|
||||
// Env describes the host, for checks against its interfaces. A nil Env
|
||||
// skips them (tests, imports for another machine).
|
||||
type Env struct {
|
||||
// Interfaces maps each interface name to its IPv4 prefixes
|
||||
// (10.0.10.1/24).
|
||||
Interfaces map[string][]netip.Prefix
|
||||
}
|
||||
|
||||
// HasErrors reports whether any problem is an error.
|
||||
func HasErrors(ps []Problem) bool {
|
||||
for _, p := range ps {
|
||||
if p.Severity == Error {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Errors returns only the errors.
|
||||
func Errors(ps []Problem) []Problem {
|
||||
var out []Problem
|
||||
for _, p := range ps {
|
||||
if p.Severity == Error {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
out []Problem
|
||||
}
|
||||
|
||||
func (c *checker) add(sev, code, object, format string, args ...any) {
|
||||
c.out = append(c.out, Problem{Severity: sev, Code: code, Object: object, Message: fmt.Sprintf(format, args...)})
|
||||
}
|
||||
|
||||
// Check validates a configuration.
|
||||
func Check(st *model.State, env *Env) []Problem {
|
||||
c := &checker{}
|
||||
c.settings(st.Settings)
|
||||
c.subnets(st, env)
|
||||
c.reservations(st, env)
|
||||
return c.out
|
||||
}
|
||||
|
||||
func (c *checker) settings(s model.Settings) {
|
||||
if s.LeaseTime != 0 {
|
||||
c.leaseTime(s.LeaseTime, "settings")
|
||||
}
|
||||
if s.PingTimeout != 0 && (s.PingTimeout < 50 || s.PingTimeout > 5000) {
|
||||
c.add(Error, "ping_timeout_range", "settings", "the ping timeout is %d ms; use 50 to 5000 ms", s.PingTimeout)
|
||||
}
|
||||
if s.DeclineHold != 0 && (s.DeclineHold < 60 || s.DeclineHold > 30*24*3600) {
|
||||
c.add(Error, "decline_hold_range", "settings", "the decline hold is %d seconds; use 1 minute to 30 days", s.DeclineHold)
|
||||
}
|
||||
c.options(s.Options, "settings")
|
||||
}
|
||||
|
||||
func (c *checker) leaseTime(sec int, object string) {
|
||||
if sec < model.MinLeaseTime || sec > model.MaxLeaseTime {
|
||||
c.add(Error, "lease_time_range", object, "the lease time is %d seconds; use 1 minute to 365 days", sec)
|
||||
}
|
||||
}
|
||||
|
||||
// options checks option values: known types, valid values, no reserved
|
||||
// codes, no code twice.
|
||||
func (c *checker) options(opts []model.OptionValue, object string) {
|
||||
seen := map[int]bool{}
|
||||
for _, o := range opts {
|
||||
if o.Code < 1 || o.Code > 254 {
|
||||
c.add(Error, "option_code", object, "option code %d is out of range (1 to 254)", o.Code)
|
||||
continue
|
||||
}
|
||||
def := dhcp4.ByCode(byte(o.Code))
|
||||
if why, ok := dhcp4.Reserved[byte(o.Code)]; ok {
|
||||
c.add(Error, "option_reserved", object, "option %d can't be set: %s", o.Code, why)
|
||||
continue
|
||||
}
|
||||
if seen[o.Code] {
|
||||
c.add(Error, "option_duplicate", object, "option %s is set twice", def.Name)
|
||||
}
|
||||
seen[o.Code] = true
|
||||
typ := def.Type
|
||||
if !dhcp4.Known(byte(o.Code)) && o.Type != "" {
|
||||
typ = o.Type
|
||||
}
|
||||
if _, err := dhcp4.Encode(typ, o.Value); err != nil {
|
||||
c.add(Error, "option_invalid", object, "option %s: %v", def.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) subnets(st *model.State, env *Env) {
|
||||
names := map[string]*model.Subnet{}
|
||||
for i, s := range st.Subnets {
|
||||
obj := "subnet " + s.Label()
|
||||
p := s.Prefix
|
||||
switch {
|
||||
case !p.IsValid() || !p.Addr().Is4():
|
||||
c.add(Error, "subnet_prefix", obj, "the prefix must be an IPv4 network such as 10.0.10.0/24")
|
||||
continue
|
||||
case p.Masked() != p:
|
||||
c.add(Error, "subnet_prefix", obj, "%s has host bits set; the network is %s", p, p.Masked())
|
||||
continue
|
||||
case p.Bits() < 8 || p.Bits() > 30:
|
||||
c.add(Error, "subnet_size", obj, "a /%d is not a subnet DHCP can serve; use /8 to /30", p.Bits())
|
||||
continue
|
||||
}
|
||||
if s.Name != "" {
|
||||
if strings.ContainsAny(s.Name, " \t/,") {
|
||||
c.add(Error, "subnet_name", obj, "the name %q has spaces, slashes or commas; use letters, digits, '-' and '_'", s.Name)
|
||||
}
|
||||
key := strings.ToLower(s.Name)
|
||||
if o := names[key]; o != nil {
|
||||
c.add(Error, "subnet_name_taken", obj, "the name %s is already used by subnet %s", s.Name, o.Prefix)
|
||||
}
|
||||
names[key] = s
|
||||
}
|
||||
if s.Interface == "" {
|
||||
c.add(Error, "subnet_interface_missing", obj, "the subnet needs the network interface it is served on (eth0, eth0.10, br-lan)")
|
||||
} else if !validIfName(s.Interface) {
|
||||
c.add(Error, "subnet_interface_name", obj, "%q isn't an interface name", s.Interface)
|
||||
}
|
||||
for _, o := range st.Subnets[:i] {
|
||||
if o.Prefix.IsValid() && o.Prefix.Overlaps(p) {
|
||||
c.add(Error, "subnet_overlap", obj, "it overlaps subnet %s", o.Label())
|
||||
}
|
||||
}
|
||||
if s.LeaseTime != 0 {
|
||||
c.leaseTime(s.LeaseTime, obj)
|
||||
}
|
||||
c.pools(s, obj)
|
||||
c.options(s.Options, obj)
|
||||
c.routers(s, st.Settings.Options, obj)
|
||||
if env != nil && !s.Disabled && s.Interface != "" {
|
||||
c.hostInterface(s, env, obj)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) pools(s *model.Subnet, obj string) {
|
||||
if len(s.Pools) == 0 && !s.Disabled {
|
||||
c.add(Warning, "subnet_no_pools", obj, "the subnet has no pools: only clients with a reservation get an address")
|
||||
}
|
||||
network, broadcast := s.Prefix.Addr(), model.Last(s.Prefix)
|
||||
for i, p := range s.Pools {
|
||||
if !p.Start.Is4() || !p.End.Is4() || p.End.Less(p.Start) {
|
||||
c.add(Error, "pool_invalid", obj, "pool %s is not a range of IPv4 addresses", p)
|
||||
continue
|
||||
}
|
||||
if !s.Prefix.Contains(p.Start) || !s.Prefix.Contains(p.End) {
|
||||
c.add(Error, "pool_outside_subnet", obj, "pool %s is not inside %s", p, s.Prefix)
|
||||
continue
|
||||
}
|
||||
if p.Contains(network) || p.Contains(broadcast) {
|
||||
c.add(Error, "pool_network_address", obj, "pool %s includes the network address %s or the broadcast address %s; start it at %s and end it at %s at most", p, network, broadcast, network.Next(), broadcast.Prev())
|
||||
}
|
||||
for _, o := range s.Pools[:i] {
|
||||
if o.Start.Is4() && o.End.Is4() && !(p.End.Less(o.Start) || o.End.Less(p.Start)) {
|
||||
c.add(Error, "pool_overlap", obj, "pools %s and %s overlap", o, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 := ""
|
||||
for _, o := range global {
|
||||
if o.Code == dhcp4.OptRouters {
|
||||
val = o.Value
|
||||
}
|
||||
}
|
||||
for _, o := range s.Options {
|
||||
if o.Code == dhcp4.OptRouters {
|
||||
val = o.Value
|
||||
}
|
||||
}
|
||||
if val == "" {
|
||||
return
|
||||
}
|
||||
b, err := dhcp4.Encode(dhcp4.TypeIPs, val)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for i := 0; i+4 <= len(b); i += 4 {
|
||||
a := netip.AddrFrom4([4]byte(b[i : i+4]))
|
||||
if !s.Prefix.Contains(a) {
|
||||
c.add(Warning, "router_outside_subnet", obj, "the router %s is outside %s, so clients can't reach it", a, s.Prefix)
|
||||
}
|
||||
for _, p := range s.Pools {
|
||||
if p.Contains(a) {
|
||||
c.add(Warning, "router_in_pool", obj, "the router %s is inside pool %s; it is never handed out while it answers pings, but take it out of the pool", a, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) hostInterface(s *model.Subnet, env *Env, obj string) {
|
||||
addrs, ok := env.Interfaces[s.Interface]
|
||||
if !ok {
|
||||
var names []string
|
||||
for n := range env.Interfaces {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
c.add(Warning, "interface_unknown", obj, "this server has no interface %s (it has %s); the subnet is served once it appears", s.Interface, strings.Join(names, ", "))
|
||||
return
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if s.Prefix.Contains(a.Addr()) {
|
||||
for _, p := range s.Pools {
|
||||
if p.Contains(a.Addr()) {
|
||||
c.add(Warning, "pool_includes_server", obj, "pool %s includes this server's own address %s; it is never handed out, but take it out of the pool", p, a.Addr())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
c.add(Warning, "interface_no_address", obj, "interface %s has no address in %s; give it one (the clients renew their leases with it), or check the interface", s.Interface, s.Prefix)
|
||||
}
|
||||
|
||||
func (c *checker) reservations(st *model.State, env *Env) {
|
||||
byIP := map[netip.Addr]*model.Reservation{}
|
||||
type key struct {
|
||||
subnet int64
|
||||
id string
|
||||
}
|
||||
byClient := map[key]*model.Reservation{}
|
||||
for _, r := range st.Reservations {
|
||||
obj := "reservation " + r.Label()
|
||||
if r.MAC == "" && r.ClientID == "" {
|
||||
c.add(Error, "reservation_no_client", obj, "a reservation needs the client's MAC address or client identifier")
|
||||
}
|
||||
if !r.IP.Is4() {
|
||||
c.add(Error, "reservation_ip", obj, "the address must be an IPv4 address")
|
||||
continue
|
||||
}
|
||||
s := st.SubnetFor(r.IP)
|
||||
if s == nil {
|
||||
c.add(Error, "reservation_no_subnet", obj, "%s is in none of the subnets; add the subnet first", r.IP)
|
||||
continue
|
||||
}
|
||||
if r.IP == s.Prefix.Addr() || r.IP == model.Last(s.Prefix) {
|
||||
c.add(Error, "reservation_network_address", obj, "%s is the network or broadcast address of %s", r.IP, s.Prefix)
|
||||
}
|
||||
if o := byIP[r.IP]; o != nil {
|
||||
c.add(Error, "reservation_ip_taken", obj, "%s is already reserved for %s", r.IP, o.Label())
|
||||
}
|
||||
byIP[r.IP] = r
|
||||
k := key{s.ID, "mac " + r.MAC}
|
||||
if r.ClientID != "" {
|
||||
k.id = "id " + r.ClientID
|
||||
}
|
||||
if o := byClient[k]; o != nil {
|
||||
c.add(Error, "reservation_client_taken", obj, "this client already has a reservation in %s: %s", s.Label(), o.Label())
|
||||
}
|
||||
byClient[k] = r
|
||||
if r.Hostname != "" && !validHostname(r.Hostname) {
|
||||
c.add(Error, "reservation_hostname", obj, "%q isn't a valid host name (letters, digits and '-', labels up to 63 characters, separated by dots)", r.Hostname)
|
||||
}
|
||||
c.options(r.Options, obj)
|
||||
if env != nil && s.Interface != "" {
|
||||
for _, a := range env.Interfaces[s.Interface] {
|
||||
if a.Addr() == r.IP {
|
||||
c.add(Warning, "reservation_server_address", obj, "%s is this server's own address on %s", r.IP, s.Interface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validIfName(n string) bool {
|
||||
if n == "" || len(n) > 15 {
|
||||
return false
|
||||
}
|
||||
for _, c := range n {
|
||||
if c <= ' ' || c == '/' || c == ':' || c > '~' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validHostname(n string) bool {
|
||||
n = strings.TrimSuffix(n, ".")
|
||||
if n == "" || len(n) > 253 {
|
||||
return false
|
||||
}
|
||||
for _, l := range strings.Split(n, ".") {
|
||||
if l == "" || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' {
|
||||
return false
|
||||
}
|
||||
for _, c := range l {
|
||||
if !(c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidHostname reports whether n can be sent as a host name.
|
||||
func ValidHostname(n string) bool { return validHostname(n) }
|
||||
@@ -0,0 +1,129 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"steward/internal/model"
|
||||
)
|
||||
|
||||
func pool(s string) model.Pool {
|
||||
p, err := model.ParsePool(s)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func good() *model.State {
|
||||
return &model.State{
|
||||
Settings: model.DefaultSettings(),
|
||||
Subnets: []*model.Subnet{
|
||||
{ID: 1, Name: "office", Interface: "eth0", Prefix: netip.MustParsePrefix("10.0.10.0/24"), Pools: []model.Pool{pool("10.0.10.100-10.0.10.199")},
|
||||
Options: []model.OptionValue{{Code: 3, Value: "10.0.10.1"}}},
|
||||
{ID: 2, Name: "iot", Interface: "eth0.20", Prefix: netip.MustParsePrefix("10.0.20.0/24")},
|
||||
},
|
||||
Reservations: []*model.Reservation{
|
||||
{ID: 3, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.20"), Hostname: "nas"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func codes(ps []Problem) string {
|
||||
var out []string
|
||||
for _, p := range ps {
|
||||
out = append(out, p.Severity+":"+p.Code)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
func TestGoodConfig(t *testing.T) {
|
||||
st := good()
|
||||
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
|
||||
if ps := Check(st, nil); len(ps) != 0 {
|
||||
t.Errorf("problems: %v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblems(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
edit func(st *model.State)
|
||||
want string
|
||||
}{
|
||||
{"host bits", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.5/24") }, "error:subnet_prefix"},
|
||||
{"too small", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("10.0.10.0/31") }, "error:subnet_size"},
|
||||
{"ipv6", func(st *model.State) { st.Subnets[0].Prefix = netip.MustParsePrefix("2001:db8::/64") }, "error:subnet_prefix"},
|
||||
{"overlap", func(st *model.State) { st.Subnets[1].Prefix = netip.MustParsePrefix("10.0.0.0/16") }, "error:subnet_overlap"},
|
||||
{"name taken", func(st *model.State) { st.Subnets[1].Name = "Office" }, "error:subnet_name_taken"},
|
||||
{"no interface", func(st *model.State) { st.Subnets[0].Interface = "" }, "error:subnet_interface_missing"},
|
||||
{"bad interface", func(st *model.State) { st.Subnets[0].Interface = "eth 0" }, "error:subnet_interface_name"},
|
||||
{"no pools", func(st *model.State) {}, "warning:subnet_no_pools"},
|
||||
{"pool outside", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.11.1-10.0.11.9")} }, "error:pool_outside_subnet"},
|
||||
{"pool network", func(st *model.State) { st.Subnets[0].Pools = []model.Pool{pool("10.0.10.0/24")} }, "error:pool_network_address"},
|
||||
{"pool overlap", func(st *model.State) {
|
||||
st.Subnets[0].Pools = append(st.Subnets[0].Pools, pool("10.0.10.150-10.0.10.250"))
|
||||
}, "error:pool_overlap"},
|
||||
{"lease time", func(st *model.State) { st.Subnets[0].LeaseTime = 10 }, "error:lease_time_range"},
|
||||
{"reserved option", func(st *model.State) { st.Settings.Options = []model.OptionValue{{Code: 51, Value: "1"}} }, "error:option_reserved"},
|
||||
{"bad option", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 6, Value: "dns.example"}} }, "error:option_invalid"},
|
||||
{"duplicate option", func(st *model.State) {
|
||||
st.Subnets[0].Options = append(st.Subnets[0].Options, model.OptionValue{Code: 3, Value: "10.0.10.2"})
|
||||
}, "error:option_duplicate"},
|
||||
{"router outside", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.11.1"}} }, "warning:router_outside_subnet"},
|
||||
{"router in pool", func(st *model.State) { st.Subnets[0].Options = []model.OptionValue{{Code: 3, Value: "10.0.10.150"}} }, "warning:router_in_pool"},
|
||||
{"no client", func(st *model.State) { st.Reservations[0].MAC = "" }, "error:reservation_no_client"},
|
||||
{"no subnet", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("192.168.1.5") }, "error:reservation_no_subnet"},
|
||||
{"broadcast", func(st *model.State) { st.Reservations[0].IP = netip.MustParseAddr("10.0.10.255") }, "error:reservation_network_address"},
|
||||
{"ip taken", func(st *model.State) {
|
||||
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:02", IP: netip.MustParseAddr("10.0.10.20")})
|
||||
}, "error:reservation_ip_taken"},
|
||||
{"client taken", func(st *model.State) {
|
||||
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.10.21")})
|
||||
}, "error:reservation_client_taken"},
|
||||
{"hostname", func(st *model.State) { st.Reservations[0].Hostname = "my_nas" }, "error:reservation_hostname"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
st := good()
|
||||
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
|
||||
if c.name == "no pools" {
|
||||
st.Subnets[1].Pools = nil
|
||||
}
|
||||
c.edit(st)
|
||||
got := codes(Check(st, nil))
|
||||
if !strings.Contains(" "+got+" ", " "+c.want+" ") {
|
||||
t.Errorf("%s: got %q, want %s", c.name, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameMACInTwoSubnets(t *testing.T) {
|
||||
st := good()
|
||||
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
|
||||
st.Reservations = append(st.Reservations, &model.Reservation{ID: 9, MAC: "aa:bb:cc:dd:ee:01", IP: netip.MustParseAddr("10.0.20.20")})
|
||||
if ps := Check(st, nil); len(ps) != 0 {
|
||||
t.Errorf("a laptop on two VLANs: %v", ps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostInterfaces(t *testing.T) {
|
||||
st := good()
|
||||
st.Subnets[1].Pools = []model.Pool{pool("10.0.20.100-10.0.20.200")}
|
||||
st.Subnets[0].Pools = []model.Pool{pool("10.0.10.1-10.0.10.199")}
|
||||
st.Subnets[0].Options = nil
|
||||
env := &Env{Interfaces: map[string][]netip.Prefix{
|
||||
"eth0": {netip.MustParsePrefix("10.0.10.2/24")},
|
||||
"wg0": {netip.MustParsePrefix("10.9.0.1/24")},
|
||||
}}
|
||||
got := codes(Check(st, env))
|
||||
for _, want := range []string{"warning:pool_includes_server", "warning:interface_unknown"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("got %q, want %s", got, want)
|
||||
}
|
||||
}
|
||||
env.Interfaces["eth0.20"] = []netip.Prefix{netip.MustParsePrefix("192.168.5.1/24")}
|
||||
if got := codes(Check(st, env)); !strings.Contains(got, "warning:interface_no_address") {
|
||||
t.Errorf("got %q, want interface_no_address", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
// Package version holds Steward's version, set at build time with
|
||||
//
|
||||
// go build -ldflags "-X steward/internal/version.Version=1.2.3 -X steward/internal/version.Tag=v1.2.3"
|
||||
//
|
||||
// The Makefile sets Version from the VERSION file, and Tag only when the
|
||||
// build is of that release: HEAD is the tag v<VERSION> and no tracked file
|
||||
// is changed. Only such a build reports a version number. Other builds
|
||||
// are development builds, identified by their commit (which Go records
|
||||
// from git by itself) and by where they stand relative to the last tag
|
||||
// (Describe, from git describe).
|
||||
package version
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Version is the Steward version (the VERSION file).
|
||||
var Version = "0.1.0-dev"
|
||||
|
||||
// Tag is the release tag the build is of ("" when it isn't a release).
|
||||
var Tag = ""
|
||||
|
||||
// Commit is the git commit the build is of. It is read from the build
|
||||
// information when not set at build time.
|
||||
var Commit = ""
|
||||
|
||||
// Describe is 'git describe --tags --long' of the build's commit
|
||||
// ("v0.2.0-rc.1-3-gca0f148b78b6"), set by the Makefile; it says how many
|
||||
// commits a development build is past the last tag.
|
||||
var Describe = ""
|
||||
|
||||
// Repo is Steward's source repository.
|
||||
const Repo = "https://gitea.adbraun.lol/adam/steward"
|
||||
|
||||
// Build describes what a binary was built from.
|
||||
type Build struct {
|
||||
Version string `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Commit string `json:"commit,omitempty"`
|
||||
Modified bool `json:"modified,omitempty"` // built with uncommitted changes
|
||||
// Since is the last tag before a development build's commit, and
|
||||
// Ahead how many commits the build is past it (0: the tag's commit,
|
||||
// with uncommitted changes).
|
||||
Since string `json:"since,omitempty"`
|
||||
Ahead int `json:"ahead,omitempty"`
|
||||
// Label is the short form to show: the tag of a release, otherwise
|
||||
// the abbreviated commit (or the version when the commit is unknown).
|
||||
Label string `json:"label"`
|
||||
// URL is the release or commit in the repository.
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
var info = sync.OnceValue(func() Build {
|
||||
commit, modified := Commit, false
|
||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, s := range bi.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
if commit == "" {
|
||||
commit = s.Value
|
||||
}
|
||||
case "vcs.modified":
|
||||
modified = s.Value == "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
b := describe(Version, Tag, commit, modified)
|
||||
b.Since, b.Ahead = parseDescribe(Describe, b.Commit)
|
||||
return b
|
||||
})
|
||||
|
||||
// parseDescribe splits git describe --long output, if it is of commit.
|
||||
func parseDescribe(d, commit string) (since string, ahead int) {
|
||||
m := describeRe.FindStringSubmatch(d)
|
||||
if m == nil || commit == "" || !strings.HasPrefix(commit, m[3]) {
|
||||
return "", 0
|
||||
}
|
||||
n, _ := strconv.Atoi(m[2])
|
||||
return m[1], n
|
||||
}
|
||||
|
||||
var describeRe = regexp.MustCompile(`^(v.+)-(\d+)-g([0-9a-f]+)$`)
|
||||
|
||||
func describe(version, tag, commit string, modified bool) Build {
|
||||
b := Build{Version: version, Tag: tag, Commit: commit, Modified: modified}
|
||||
switch {
|
||||
case tag != "":
|
||||
b.Modified = false // the Makefile only sets Tag for clean trees
|
||||
b.Label, b.URL = tag, Repo+"/releases/tag/"+tag
|
||||
case commit != "":
|
||||
b.Label, b.URL = short(commit), Repo+"/commit/"+commit
|
||||
if modified {
|
||||
b.Label += "-modified"
|
||||
}
|
||||
default:
|
||||
b.Label, b.URL = version, Repo
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Info describes this build.
|
||||
func Info() Build { return info() }
|
||||
|
||||
// String is the version for logs and 'version' commands. Only a release
|
||||
// build gives a version ("0.2.0"); any other build says it's a
|
||||
// development build: "dev a1b2c3d4e5f6 (3 commits after v0.2.0-rc.1)".
|
||||
func String() string { return Info().String() }
|
||||
|
||||
func (b Build) String() string {
|
||||
switch {
|
||||
case b.Tag != "":
|
||||
return b.Version
|
||||
case b.Commit == "":
|
||||
// No git information (built from a source archive).
|
||||
return b.Version + " (source build)"
|
||||
}
|
||||
s := "dev " + b.Label
|
||||
switch {
|
||||
case b.Since == "":
|
||||
case b.Ahead == 1:
|
||||
s += " (1 commit after " + b.Since + ")"
|
||||
case b.Ahead > 1:
|
||||
s += " (" + strconv.Itoa(b.Ahead) + " commits after " + b.Since + ")"
|
||||
case b.Modified:
|
||||
s += " (" + b.Since + " with uncommitted changes)"
|
||||
default:
|
||||
s += " (at " + b.Since + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Short is the version for a User-Agent: the version of a release,
|
||||
// otherwise the label ("a1b2c3d4e5f6").
|
||||
func Short() string {
|
||||
if b := Info(); b.Tag == "" && b.Commit != "" {
|
||||
return b.Label
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
func short(commit string) string {
|
||||
if len(commit) > 12 {
|
||||
return commit[:12]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package version
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDescribe(t *testing.T) {
|
||||
const c = "4d00be5e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c"
|
||||
for _, tc := range []struct {
|
||||
tag, commit string
|
||||
modified bool
|
||||
label, url string
|
||||
}{
|
||||
{"v0.1.0", c, true, "v0.1.0", Repo + "/releases/tag/v0.1.0"},
|
||||
{"", c, false, "4d00be5e1f0a", Repo + "/commit/" + c},
|
||||
{"", c, true, "4d00be5e1f0a-modified", Repo + "/commit/" + c},
|
||||
{"", "", false, "0.1.0", Repo},
|
||||
} {
|
||||
b := describe("0.1.0", tc.tag, tc.commit, tc.modified)
|
||||
if b.Label != tc.label || b.URL != tc.url {
|
||||
t.Errorf("describe(%q, %q, %v) = %q %q, want %q %q", tc.tag, tc.commit, tc.modified, b.Label, b.URL, tc.label, tc.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestString(t *testing.T) {
|
||||
const c = "ca0f148b78b6e1f0a9b8c7d6e5f4a3b2c1d0e9f8"
|
||||
build := func(tag, commit string, modified bool, desc string) Build {
|
||||
b := describe("0.2.0-rc.1", tag, commit, modified)
|
||||
b.Since, b.Ahead = parseDescribe(desc, b.Commit)
|
||||
return b
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
b Build
|
||||
want string
|
||||
}{
|
||||
{build("v0.2.0-rc.1", c, false, "v0.2.0-rc.1-0-gca0f148b78b6"), "0.2.0-rc.1"},
|
||||
{build("", c, false, "v0.2.0-rc.1-3-gca0f148b78b6"), "dev ca0f148b78b6 (3 commits after v0.2.0-rc.1)"},
|
||||
{build("", c, true, "v0.2.0-rc.1-1-gca0f148b78b6"), "dev ca0f148b78b6-modified (1 commit after v0.2.0-rc.1)"},
|
||||
{build("", c, true, "v0.2.0-rc.1-0-gca0f148b78b6"), "dev ca0f148b78b6-modified (v0.2.0-rc.1 with uncommitted changes)"},
|
||||
{build("", c, false, "v0.1.0-0-gca0f148b78b6"), "dev ca0f148b78b6 (at v0.1.0)"},
|
||||
{build("", c, false, ""), "dev ca0f148b78b6"},
|
||||
// A describe of another commit (stale ldflags) is ignored.
|
||||
{build("", c, false, "v0.2.0-rc.1-3-gdeadbeef0000"), "dev ca0f148b78b6"},
|
||||
{build("", "", false, ""), "0.2.0-rc.1 (source build)"},
|
||||
} {
|
||||
if got := tc.b.String(); got != tc.want {
|
||||
t.Errorf("String() = %q, want %q", got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// API client and shared application state.
|
||||
import { toast } from "./ui.js";
|
||||
|
||||
export const state = {
|
||||
me: null, // whoami response
|
||||
csrf: "",
|
||||
catalog: null,
|
||||
};
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(status, body) {
|
||||
const e = (body && body.error) || {};
|
||||
super(e.message || ("HTTP " + status));
|
||||
this.status = status;
|
||||
this.code = e.code || "";
|
||||
this.details = e;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized = () => {};
|
||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
|
||||
// api calls the REST API. opts: {body, query}.
|
||||
export async function api(method, path, opts = {}) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
|
||||
let url = "/api/v1" + path;
|
||||
if (opts.query) {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
|
||||
const s = q.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
||||
if (resp.status === 204) return null;
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
|
||||
throw new APIError(resp.status, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const get = (path, query) => api("GET", path, { query });
|
||||
|
||||
// change sends a configuration change and reports it: a toast with the
|
||||
// history entry (to undo it from History) and one per new warning. It
|
||||
// returns the response.
|
||||
export async function change(method, path, body, opts = {}) {
|
||||
const r = await api(method, path, { body, query: opts.query });
|
||||
if (!opts.quiet) report(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
export function report(r) {
|
||||
if (!r) return;
|
||||
if (r.dry_run) toast("Checked: nothing was changed");
|
||||
else if (r.applied) toast("Saved (history #" + r.history_id + ")");
|
||||
else if (!(r.changes || []).length) toast("Nothing to change");
|
||||
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
||||
}
|
||||
|
||||
// can reports whether the user may read (or write) a resource.
|
||||
export function can(resource, write) {
|
||||
const me = state.me;
|
||||
if (!me) return false;
|
||||
if (me.admin) return true;
|
||||
const perms = me.permissions || [];
|
||||
return perms.includes(resource + ":write") || (!write && perms.includes(resource + ":read"));
|
||||
}
|
||||
|
||||
// catalog returns the option catalog (cached).
|
||||
export async function catalog() {
|
||||
if (!state.catalog) state.catalog = await get("/catalog");
|
||||
return state.catalog;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Steward web UI: router, layout and login.
|
||||
import { h, mount, toast, errorBox } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler } from "./api.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as subnets from "./pages/subnets.js";
|
||||
import * as subnet from "./pages/subnet.js";
|
||||
import * as leases from "./pages/leases.js";
|
||||
import * as reservations from "./pages/reservations.js";
|
||||
import * as activity from "./pages/activity.js";
|
||||
import * as settings from "./pages/settings.js";
|
||||
import * as history from "./pages/history.js";
|
||||
import * as audit from "./pages/audit.js";
|
||||
import * as users from "./pages/users.js";
|
||||
import * as account from "./pages/account.js";
|
||||
|
||||
const routes = [
|
||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||
{ pattern: /^subnets$/, page: subnets, nav: "subnets" },
|
||||
{ pattern: /^subnets\/(\d+)(?:\/(\w+))?$/, page: subnet, nav: "subnets", params: ["id", "tab"] },
|
||||
{ pattern: /^leases$/, page: leases, nav: "leases" },
|
||||
{ pattern: /^reservations$/, page: reservations, nav: "reservations" },
|
||||
{ pattern: /^activity$/, page: activity, nav: "activity" },
|
||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||
{ pattern: /^history$/, page: history, nav: "history" },
|
||||
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
|
||||
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
||||
{ pattern: /^users$/, page: users, nav: "users" },
|
||||
{ pattern: /^account$/, page: account, nav: "account" },
|
||||
];
|
||||
|
||||
// The top bar. ["key", "#/href", "Label", permission?]
|
||||
const navItems = [
|
||||
["dashboard", "#/", "Dashboard"],
|
||||
["subnets", "#/subnets", "Subnets", "config"],
|
||||
["leases", "#/leases", "Leases", "leases"],
|
||||
["reservations", "#/reservations", "Reservations", "config"],
|
||||
["activity", "#/activity", "Activity", "leases"],
|
||||
["history", "#/history", "History", "config"],
|
||||
{ menu: "Server", items: [
|
||||
["settings", "#/settings", "Settings and options", "config"],
|
||||
["audit", "#/audit", "Audit log", "audit"],
|
||||
] },
|
||||
];
|
||||
|
||||
const app = document.getElementById("app");
|
||||
let mainEl, navEl, current = { key: "" }, routeGen = 0;
|
||||
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#\/?/, "");
|
||||
const [path, qs] = raw.split("?");
|
||||
return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") };
|
||||
}
|
||||
|
||||
function renderLogin(message) {
|
||||
const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true });
|
||||
const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true });
|
||||
const err = h("div");
|
||||
const form = h("form", { onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
try {
|
||||
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
|
||||
await loggedIn(me);
|
||||
} catch (ex) {
|
||||
mount(err, errorBox(ex));
|
||||
pass.select();
|
||||
}
|
||||
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
|
||||
mount(app, h("div", { class: "login panel" },
|
||||
logo(104, "login-logo"),
|
||||
h("h1", null, wordmark()),
|
||||
h("div", { class: "tag" }, "Keeper of the hold's addresses"),
|
||||
message ? h("div", { class: "msg warn" }, message) : null,
|
||||
form));
|
||||
user.focus();
|
||||
}
|
||||
|
||||
async function loggedIn(me) {
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
layout();
|
||||
window.addEventListener("hashchange", route);
|
||||
await route();
|
||||
}
|
||||
|
||||
function layout() {
|
||||
navEl = h("nav", { class: "main" });
|
||||
mainEl = h("main");
|
||||
const me = state.me;
|
||||
const header = h("header", { class: "top" },
|
||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||
logo(30),
|
||||
wordmark({ rule: false, height: 17, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "header-right" },
|
||||
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||
h("a", { href: "#/account" }, "Account"),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||
mount(app, h("div", { class: "shell" }, header, mainEl, footer(me.build)));
|
||||
renderNav();
|
||||
}
|
||||
|
||||
// footer names the build: the release, or the commit for other builds.
|
||||
function footer(b) {
|
||||
if (!b) return null;
|
||||
let title = b.tag ? "Steward " + b.version + " (release " + b.tag + ")" : "Steward " + b.version;
|
||||
if (!b.tag && b.commit) {
|
||||
title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : "");
|
||||
if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since;
|
||||
}
|
||||
return h("footer", { class: "site" }, "Powered by ",
|
||||
h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Steward " + (b.tag || b.commit ? b.label : b.version)));
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const allowed = (n) => !n[3] || can(n[3], false);
|
||||
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||
mount(navEl, navItems.map((n) => {
|
||||
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||
const items = n.items.filter(allowed);
|
||||
if (!items.length) return null;
|
||||
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
|
||||
}));
|
||||
}
|
||||
|
||||
// menu is a drop-down that closes when an item is picked, on a click
|
||||
// elsewhere and on Escape.
|
||||
function menu(label, items, opts = {}) {
|
||||
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
|
||||
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
|
||||
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
|
||||
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
|
||||
return d;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
try { await api("POST", "/auth/logout"); } catch { /* ignore */ }
|
||||
state.me = null;
|
||||
state.csrf = "";
|
||||
window.removeEventListener("hashchange", route);
|
||||
renderLogin("You have logged out.");
|
||||
}
|
||||
|
||||
// stopTimers lets a page that refreshes itself stop when it is left.
|
||||
let pageCleanup = null;
|
||||
export function onLeave(fn) { pageCleanup = fn; }
|
||||
|
||||
async function route() {
|
||||
if (!state.me) return;
|
||||
if (pageCleanup) { pageCleanup(); pageCleanup = null; }
|
||||
const { path, query } = parseHash();
|
||||
for (const r of routes) {
|
||||
const m = path.match(r.pattern);
|
||||
if (!m) continue;
|
||||
const params = {};
|
||||
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
|
||||
current = { key: path, nav: r.nav, page: r.page, params, query };
|
||||
renderNav();
|
||||
const gen = ++routeGen;
|
||||
const old = mainEl.firstChild;
|
||||
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
|
||||
try {
|
||||
await r.page.render(mainEl, params, query);
|
||||
} catch (e) {
|
||||
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
|
||||
} finally {
|
||||
clearTimeout(slow);
|
||||
}
|
||||
return;
|
||||
}
|
||||
mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard")));
|
||||
}
|
||||
|
||||
// rerender draws the current page again (after a change).
|
||||
export function rerender() {
|
||||
if (current.page) route();
|
||||
}
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
if (state.me) {
|
||||
state.me = null;
|
||||
window.removeEventListener("hashchange", route);
|
||||
renderLogin("Your session has expired. Please log in again.");
|
||||
}
|
||||
});
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
await loggedIn(await get("/auth/whoami"));
|
||||
} catch (e) {
|
||||
if (e.status === 401) renderLogin();
|
||||
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Steward"), errorBox(e)));
|
||||
}
|
||||
})();
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err");
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// The Steward logo and the login page's wordmark, drawn as inline SVG so
|
||||
// they appear together with the page instead of loading afterwards.
|
||||
//
|
||||
// The "STEWARD" wordmark: the angular letters of Jarl's wordmark (its A
|
||||
// and R are the same), drawn as paths so no font is needed. The login page
|
||||
// shows it over a rule with a DHCP node; the header shows the letters
|
||||
// alone. The letters use the text color and the rule the accent color.
|
||||
import * as logoData from "./logo.js";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function el(tag, attrs, ...children) {
|
||||
const e = document.createElementNS(NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
|
||||
for (const c of children) e.append(c instanceof Node ? c : document.createTextNode(c));
|
||||
return e;
|
||||
}
|
||||
|
||||
// Letters on a 100-unit cap height; counters are cut with evenodd.
|
||||
const letters = [
|
||||
// S: squared, with a downward barb on the top bar and an upward one on
|
||||
// the bottom bar (Jarl's L turned around)
|
||||
{ x: 0, d: "M10 0 H54 L58 30 L40 16 H18 V41 H48 L58 51 V90 L48 100 H4 L0 70 L18 84 H40 V59 H10 L0 49 V10 Z" },
|
||||
// T: a bar with barbed ends
|
||||
{ x: 70, d: "M0 0 H70 L66 22 L52 18 H44 V100 H26 V18 H18 L4 22 Z" },
|
||||
// E: the bottom arm ends in Jarl's L spike
|
||||
{ x: 152, d: "M0 0 H52 L56 18 H18 V41 H44 V59 H18 V82 H40 L58 70 L54 100 H0 Z" },
|
||||
// W: flat-topped middle peak
|
||||
{ x: 222, d: "M0 0 H18 L28 66 L38 30 H52 L62 66 L72 0 H90 L76 100 H54 L45 66 L36 100 H14 Z" },
|
||||
// A and R: Jarl's
|
||||
{ x: 324, d: "M35 0 L46 10 L64 90 L70 100 H50 L44 76 L35 62 L26 76 L20 100 H0 L6 90 L24 10 Z M35 26 L41 40 L35 54 L29 40 Z" },
|
||||
{ x: 406, d: "M0 0 H48 L60 12 V40 L50 50 L66 100 H46 L34 60 H18 V100 H0 Z M18 14 H41 L46 19 V35 L41 46 H18 Z" },
|
||||
// D: cut corners, like R's bowl
|
||||
{ x: 484, d: "M0 0 H44 L64 20 V80 L44 100 H0 Z M18 16 H38 L46 24 V76 L38 84 H18 Z" },
|
||||
];
|
||||
|
||||
const W = 548; // the letters' width
|
||||
|
||||
// wordmark returns the login page's wordmark, letters over the rule.
|
||||
// wordmark({ rule: false, height: 19, cls: "brand-name" }) returns just the
|
||||
// letters, height pixels tall.
|
||||
export function wordmark({ rule = true, height, cls = "wordmark" } = {}) {
|
||||
const letterG = el("g", { fill: "currentColor", "fill-rule": "evenodd" },
|
||||
...letters.map((l) => el("path", { d: l.d, transform: `translate(${l.x} 0)` })));
|
||||
if (!rule) {
|
||||
const attrs = { class: cls, viewBox: `-2 -2 ${W + 4} 104`, role: "img", "aria-label": "Steward" };
|
||||
if (height) Object.assign(attrs, { height, width: Math.round(height * (W + 4) / 104) });
|
||||
return el("svg", attrs, el("title", null, "Steward"), letterG);
|
||||
}
|
||||
const c = W / 2;
|
||||
const ruleG = el("g", { class: "wordmark-rule", transform: "translate(0 118)" },
|
||||
el("path", { d: `M2 18 L${c - 60} 15.6 V20.4 Z M${W - 2} 18 L${c + 60} 15.6 V20.4 Z`, class: "fill" }),
|
||||
el("circle", { cx: c - 52, cy: 18, r: 4.6, class: "line" }),
|
||||
el("path", { d: `M${c - 47.4} 18 H${c - 30} M${c + 30} 18 H${c + 47.4}`, class: "line" }),
|
||||
el("circle", { cx: c, cy: 18, r: 30, class: "line" }),
|
||||
el("circle", { cx: c + 52, cy: 18, r: 4.6, class: "line" }),
|
||||
el("text", { x: c, y: 24.5, "text-anchor": "middle", class: "tag" }, "DHCP"));
|
||||
return el("svg", { class: cls, viewBox: `-4 -4 ${W + 8} 162`, role: "img", "aria-label": "Steward" },
|
||||
el("title", null, "Steward"), letterG, ruleG);
|
||||
}
|
||||
|
||||
// logo returns the Steward logo (artwork/logo.svg, via scripts/logo.py) as
|
||||
// an SVG of the given size in pixels.
|
||||
export function logo(size, cls) {
|
||||
return el("svg", { class: cls || "logo", width: size, height: size, viewBox: logoData.viewBox, "aria-hidden": "true", focusable: "false" },
|
||||
...logoData.shapes.map(([tag, attrs]) => el(tag, attrs)));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// The Steward logo, from artwork/logo.svg by scripts/logo.py; don't edit.
|
||||
// brand.js draws it; logo.svg is the same picture.
|
||||
export const viewBox = "30 22 940 940";
|
||||
export const shapes = [
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M452 540 V890 L478 940 H522 L548 890 V540 Z"}],
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M548 690 H688 V742 H640 V782 H712 V832 H664 V890 H548 Z"}],
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M420 450 H580 L606 498 L580 546 H420 L394 498 Z"}],
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M305 490 L245 135 L382 238 L500 45 L618 238 L755 135 L695 490 Q500 445 305 490 Z"}],
|
||||
["path", {"d": "M287 418 Q500 368 713 418", "stroke": "#1d5287", "stroke-width": "22", "fill": "none"}],
|
||||
["path", {"stroke": "#1d5287", "stroke-width": "22", "fill": "none", "d": "M500 200 V258 M463 312 L414 334 M537 312 L586 334"}],
|
||||
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "500", "cy": "170", "r": "30"}],
|
||||
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "390", "cy": "344", "r": "27"}],
|
||||
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "610", "cy": "344", "r": "27"}],
|
||||
["circle", {"cx": "500", "cy": "296", "r": "40", "fill": "#1d5287"}],
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="30 22 940 940">
|
||||
<!-- The Steward logo: a key whose bow is a crown with Jarl's network nodes. The master; scripts/logo.py builds the web UI's copies. -->
|
||||
<g fill="#fff" stroke="#1d5287" stroke-width="26" stroke-linejoin="miter">
|
||||
<path d="M452 540 V890 L478 940 H522 L548 890 V540 Z"/>
|
||||
<path d="M548 690 H688 V742 H640 V782 H712 V832 H664 V890 H548 Z"/>
|
||||
<path d="M420 450 H580 L606 498 L580 546 H420 L394 498 Z"/>
|
||||
<path d="M305 490 L245 135 L382 238 L500 45 L618 238 L755 135 L695 490 Q500 445 305 490 Z"/>
|
||||
</g>
|
||||
<path d="M287 418 Q500 368 713 418" stroke="#1d5287" stroke-width="22" fill="none"/>
|
||||
<g stroke="#1d5287" stroke-width="22" fill="none">
|
||||
<path d="M500 200 V258 M463 312 L414 334 M537 312 L586 334"/>
|
||||
<circle cx="500" cy="170" r="30" fill="#fff"/>
|
||||
<circle cx="390" cy="344" r="27" fill="#fff"/>
|
||||
<circle cx="610" cy="344" r="27" fill="#fff"/>
|
||||
</g>
|
||||
<circle cx="500" cy="296" r="40" fill="#1d5287"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1,63 @@
|
||||
// Your account: password and API tokens.
|
||||
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
|
||||
import { get, api, state, catalog } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { revoke } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
const me = state.me;
|
||||
const [tokens, cat] = await Promise.all([get("/me/tokens"), catalog()]);
|
||||
const cur = input({ type: "password", autocomplete: "current-password" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password" });
|
||||
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
||||
const err = h("div");
|
||||
const change = async () => {
|
||||
mount(err);
|
||||
if (pw.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
|
||||
try {
|
||||
await api("POST", "/me/password", { body: { current: cur.value, password: pw.value } });
|
||||
toast("Password changed; your other sessions are logged out");
|
||||
cur.value = pw.value = pw2.value = "";
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, me.user.username),
|
||||
h("div", { class: "muted" }, me.admin ? "Administrator" : "May: " + (me.permissions.join(", ") || "nothing")))),
|
||||
me.method === "session" ? h("div", { class: "panel" },
|
||||
h("h2", null, "Password"),
|
||||
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw), field("Repeat", pw2)), err,
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: change }, "Change password"))) : null,
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
|
||||
h("div", { class: "muted" }, "For the steward CLI and scripts: 'steward config set token'.")),
|
||||
h("button", { onclick: () => newToken(cat) }, "New token")),
|
||||
table([
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
async function newToken(cat) {
|
||||
const name = input({ placeholder: "laptop, backup script…" });
|
||||
const role = select([["", "everything you may do"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
|
||||
const err = h("div");
|
||||
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
|
||||
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Activity: the latest DHCP messages, refreshed every few seconds.
|
||||
import { h, mount, input, select, debounce } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const ifaces = await get("/interfaces");
|
||||
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");
|
||||
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(), 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("tbody", null, evs.map((e) => {
|
||||
const t = e.type.replace(/^DHCP/, "");
|
||||
return h("tr", null,
|
||||
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)),
|
||||
h("td", { class: "mono" }, e.mac ? h("a", { href: "#", onclick: (ev) => { ev.preventDefault(); mac.value = e.mac; draw(); } }, e.mac) : ""),
|
||||
h("td", { class: "mono" }, e.ip || ""),
|
||||
h("td", null, e.hostname || ""),
|
||||
h("td", { class: "detail" }, e.detail || ""));
|
||||
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || iface.value ? " that match." : ".")));
|
||||
};
|
||||
mac.addEventListener("input", debounce(draw, 300));
|
||||
iface.addEventListener("change", draw);
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"),
|
||||
h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. Click a MAC address to follow one client."))),
|
||||
h("div", { class: "filter-row" }, mac, iface, pause),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// The audit log: logins, users, tokens, changes, lease deletions.
|
||||
import { h, mount, table, time, input, debounce, badge } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
|
||||
export async function render(el) {
|
||||
const search = input({ type: "search", placeholder: "user, action, object or detail" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const q = search.value.trim();
|
||||
const evs = await get("/audit", { q, limit: 300 });
|
||||
mount(out, table([
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "User", render: (e) => [e.username || "–", e.auth ? h("span", { class: "muted small" }, " " + e.auth) : null] },
|
||||
{ label: "From", render: (e) => h("span", { class: "mono small" }, e.ip || "") },
|
||||
{ label: "Action", render: (e) => h("span", { class: "mono" }, e.action) },
|
||||
{ label: "Object", render: (e) => e.object },
|
||||
{ label: "Result", render: (e) => badge(e.result, e.result === "success" ? "ok" : "err") },
|
||||
{ label: "Detail", render: (e) => h("span", { class: "small" }, e.detail) },
|
||||
], evs, { empty: "Nothing logged" + (q ? " that matches." : " yet.") }));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"))),
|
||||
h("div", { class: "filter-row" }, search), h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// The dashboard: interfaces, subnets and how full they are, problems,
|
||||
// recent activity.
|
||||
import { h, mount, problemsList, table, time, plural } from "../ui.js";
|
||||
import { get, can } from "../api.js";
|
||||
import { usageBar, subnetLabel } from "../widgets.js";
|
||||
import { onLeave } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const draw = async () => {
|
||||
const st = await get("/status");
|
||||
const events = can("leases") ? await get("/events", { limit: 8 }) : [];
|
||||
const leased = st.leases.active || 0;
|
||||
const reservations = st.subnets.reduce((n, s) => n + s.usage.reservations, 0);
|
||||
const poolSize = st.subnets.reduce((n, s) => n + (s.disabled ? 0 : s.usage.pool_size), 0);
|
||||
const notServing = st.interfaces.filter((i) => !i.listening);
|
||||
const errors = st.problems.filter((p) => p.severity === "error");
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Dashboard"))),
|
||||
h("div", { class: "cards" },
|
||||
card("Subnets", st.subnets.length, plural(st.interfaces.length, "interface"), "#/subnets"),
|
||||
card("Active leases", leased, poolSize ? Math.round(leased * 100 / poolSize) + "% of " + poolSize + " pool addresses" : "no pools yet", "#/leases"),
|
||||
card("Reservations", reservations, "fixed addresses", "#/reservations"),
|
||||
card("Problems", st.problems.length, errors.length ? plural(errors.length, "error") : notServing.length ? plural(notServing.length, "interface") + " not serving" : "none", null)),
|
||||
st.subnets.length === 0 ? h("div", { class: "panel" },
|
||||
h("h2", null, "Getting started"),
|
||||
h("p", null, "Steward serves no subnets yet. Add one for each network, on the interface its clients are on: ", h("a", { href: "#/subnets?new=1" }, "New subnet"), "."),
|
||||
h("p", null, "Moving from Kea? On the server, ", h("code", null, "sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv"),
|
||||
" brings over the subnets, reservations, options and current leases.")) : null,
|
||||
h("div", { class: "panel" },
|
||||
h("h2", null, "Interfaces"),
|
||||
st.interfaces.length ? table([
|
||||
{ label: "Interface", tdClass: "nowrap", render: (i) => [h("span", { class: "dot " + (i.listening ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
|
||||
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ") || "none") },
|
||||
{ label: "DHCP", render: (i) => i.listening ? h("span", { class: "muted" }, "serving") : h("span", { class: "err-text small" }, "not serving: " + i.error) },
|
||||
{ label: "Subnets", render: (i) => i.subnets.map((id, n) => {
|
||||
const s = st.subnets.find((x) => x.id === id);
|
||||
return [n ? ", " : "", s ? h("a", { href: "#/subnets/" + id }, subnetLabel(s)) : "#" + id];
|
||||
}) },
|
||||
], st.interfaces) : h("div", { class: "muted" }, "DHCP is served on the interfaces that have subnets.")),
|
||||
st.subnets.length ? h("div", { class: "panel" },
|
||||
h("h2", null, "Subnets"),
|
||||
table([
|
||||
{ label: "Subnet", render: (s) => h("a", { href: "#/subnets/" + s.id }, subnetLabel(s)) },
|
||||
{ label: "Interface", render: (s) => h("span", { class: "mono" }, s.interface) },
|
||||
{ label: "Pools in use", render: (s) => s.disabled ? h("span", { class: "badge off" }, "disabled") : usageBar(s.usage) },
|
||||
{ label: "Reservations", render: (s) => s.usage.reservations ? s.usage.reservations_leased + " of " + s.usage.reservations + " online" : "–" },
|
||||
], st.subnets)) : null,
|
||||
st.problems.length || st.ping_problem ? h("div", { class: "panel" },
|
||||
h("h2", null, "Problems"),
|
||||
problemsList(st.problems),
|
||||
st.ping_problem ? h("div", { class: "msg warn" }, st.ping_problem) : null) : null,
|
||||
events.length ? h("div", { class: "panel" },
|
||||
h("h2", null, "Latest DHCP messages ", h("a", { href: "#/activity", class: "small" }, "all")),
|
||||
table([
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "Interface", render: (e) => e.interface },
|
||||
{ label: "Message", render: (e) => h("span", { class: "type-" + e.type.replace(/^DHCP/, "").split(" ")[0] }, e.type.replace(/^DHCP/, "")) },
|
||||
{ label: "Client", render: (e) => h("span", { class: "mono" }, e.mac || "") },
|
||||
{ label: "Address", render: (e) => h("span", { class: "mono" }, e.ip || "") },
|
||||
{ label: "Host name", render: (e) => e.hostname || "" },
|
||||
], events, { })) : null);
|
||||
};
|
||||
await draw();
|
||||
const t = setInterval(() => { draw().catch(() => {}); }, 15000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
|
||||
function card(label, value, sub, href) {
|
||||
const body = [h("div", { class: "label" }, label), h("div", { class: "value" }, String(value)), h("div", { class: "sub" }, sub)];
|
||||
return href ? h("a", { class: "card link", href }, body) : h("div", { class: "card" }, body);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// History: every configuration change, with undo.
|
||||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el, params) {
|
||||
if (params.id) return entry(el, params.id);
|
||||
const hs = await get("/history", { limit: 200 });
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"),
|
||||
h("div", { class: "muted" }, "Every configuration change, newest first. Undo applies a change backwards, if nothing it touched has changed since."))),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "#", render: (e) => h("a", { href: "#/history/" + e.id }, String(e.id)) },
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "By", render: (e) => [e.username, h("span", { class: "muted small" }, " via " + e.via)] },
|
||||
{ label: "Change", render: (e) => [h("a", { href: "#/history/" + e.id }, e.description),
|
||||
e.reverted_by ? [" ", badge("undone by #" + e.reverted_by, "")] : null, e.revert_of ? [" ", badge("undoes #" + e.revert_of, "accent")] : null] },
|
||||
can("config", true) ? { label: "", tdClass: "actions-cell", render: (e) => e.reverted_by ? null : h("button", { class: "small reveal", onclick: () => undo(e) }, "Undo") } : null,
|
||||
].filter(Boolean), hs, { empty: "No changes yet." })));
|
||||
}
|
||||
|
||||
async function undo(e) {
|
||||
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
|
||||
try {
|
||||
await change("POST", "/history/" + e.id + "/revert");
|
||||
rerender();
|
||||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||||
}
|
||||
|
||||
async function entry(el, id) {
|
||||
const e = await get("/history/" + id);
|
||||
mount(el,
|
||||
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "#" + e.id + ": " + e.description),
|
||||
h("div", { class: "muted" }, new Date(e.time).toLocaleString(), " by ", e.username, " via ", e.via,
|
||||
e.reverted_by ? [" · undone by ", h("a", { href: "#/history/" + e.reverted_by }, "#" + e.reverted_by)] : null,
|
||||
e.revert_of ? [" · undoes ", h("a", { href: "#/history/" + e.revert_of }, "#" + e.revert_of)] : null)),
|
||||
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
|
||||
e.changes.map((c) => h("div", { class: "panel" },
|
||||
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
|
||||
fieldsTable(c))));
|
||||
}
|
||||
|
||||
function action(c) {
|
||||
if (!c.before) return "Created";
|
||||
if (!c.after) return "Deleted";
|
||||
return "Changed";
|
||||
}
|
||||
|
||||
// fieldsTable lists the fields that differ (all of them for a create or
|
||||
// delete).
|
||||
function fieldsTable(c) {
|
||||
const a = c.before || {}, b = c.after || {};
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
const show = (v, k) => {
|
||||
if (v === undefined || v === "") return "–";
|
||||
if (k === "options") return v.length ? v.map((o) => o.name + "=" + o.value).join("; ") : "none";
|
||||
if (k === "pools") return v.length ? v.map((p) => p.start + "-" + p.end).join(", ") : "none";
|
||||
if (k === "lease_time" || k === "decline_hold") return v ? v + "s" : "default";
|
||||
return typeof v === "string" ? v : JSON.stringify(v);
|
||||
};
|
||||
const rows = keys.filter((k) => k !== "id").filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]) || !c.before || !c.after);
|
||||
return table([
|
||||
{ label: "Field", render: (k) => h("strong", null, k) },
|
||||
c.before ? { label: "Before", render: (k) => h("span", { class: "mono small" }, show(a[k], k)) } : null,
|
||||
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
|
||||
].filter(Boolean), rows);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Leases: every address handed out, with filters; reserve or delete one.
|
||||
import { h, mount, table, time, input, select, confirmDialog, toast, modal, errorBox, debounce, badge } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { stateText, subnetLabel } from "../widgets.js";
|
||||
|
||||
// leaseHost is the host name to show: the reservation's, or what the
|
||||
// client said.
|
||||
function leaseHost(l) {
|
||||
if (l.reserved_hostname && l.hostname && l.reserved_hostname !== l.hostname) return [l.reserved_hostname, h("span", { class: "muted small" }, " (says " + l.hostname + ")")];
|
||||
return l.reserved_hostname || l.hostname || h("span", { class: "muted" }, "–");
|
||||
}
|
||||
|
||||
// leasesTable shows leases with their actions; after a change, done() is
|
||||
// called.
|
||||
export function leasesTable(leases, done, opts = {}) {
|
||||
const write = can("leases", true), config = can("config", true);
|
||||
return table([
|
||||
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) },
|
||||
{ label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") },
|
||||
{ label: "Host name", render: leaseHost },
|
||||
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
|
||||
{ label: "Ends", render: (l) => time(l.end) },
|
||||
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
|
||||
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
|
||||
{ label: "", tdClass: "actions-cell", render: (l) => [
|
||||
config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
|
||||
write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null,
|
||||
] },
|
||||
].filter(Boolean), leases, { empty: opts.empty || "No leases." });
|
||||
}
|
||||
|
||||
function stateBadge(st) {
|
||||
return badge(stateText(st), { active: "ok", declined: "warn" }[st] || "");
|
||||
}
|
||||
|
||||
async function reserve(l, done) {
|
||||
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
|
||||
const err = h("div");
|
||||
await modal("Reserve " + l.ip, h("div", null,
|
||||
h("p", null, l.mac + " keeps " + l.ip + " from now on."),
|
||||
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Reserve", kind: "primary", onclick: async () => {
|
||||
try {
|
||||
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim() });
|
||||
done();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
async function del(l, done) {
|
||||
if (!await confirmDialog("Delete lease", "Forget the lease of " + l.ip + (l.mac ? " (" + l.mac + ")" : "") + "? The address is free at once; if the client still uses it, it gets a NAK when it next renews and asks for a new address.", "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/leases/" + l.ip);
|
||||
toast("Deleted the lease of " + l.ip);
|
||||
done();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const subnets = await get("/subnets");
|
||||
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
|
||||
const stateSel = select([["current", "active and held back"], ["active", "active"], ["expired", "expired"], ["released", "released"], ["declined", "held back"], ["", "all"]], query.get("state") || "current");
|
||||
const search = input({ type: "search", placeholder: "address, MAC or host name", value: query.get("q") || "" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const st = stateSel.value === "current" ? "" : stateSel.value;
|
||||
let leases = await get("/leases", { subnet: subnetSel.value, state: st, q: search.value.trim() });
|
||||
if (stateSel.value === "current") leases = leases.filter((l) => l.state === "active" || l.state === "declined");
|
||||
const q = new URLSearchParams();
|
||||
if (subnetSel.value) q.set("subnet", subnetSel.value);
|
||||
if (stateSel.value !== "current") q.set("state", stateSel.value);
|
||||
if (search.value.trim()) q.set("q", search.value.trim());
|
||||
history.replaceState(null, "", "#/leases" + (q.toString() ? "?" + q : ""));
|
||||
mount(out, h("div", { class: "muted small" }, leases.length + (leases.length === 1 ? " lease" : " leases")), leasesTable(leases, draw));
|
||||
};
|
||||
subnetSel.addEventListener("change", draw);
|
||||
stateSel.addEventListener("change", draw);
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Leases"),
|
||||
h("div", { class: "muted" }, "Addresses handed out. Expired and released leases are kept a while, so a client that comes back gets its old address."))),
|
||||
h("div", { class: "filter-row" }, subnetSel, stateSel, search, h("button", { onclick: draw }, "Refresh")),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Reservations: fixed addresses for clients.
|
||||
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { optionsEditor, subnetLabel } from "../widgets.js";
|
||||
|
||||
// reservationsTable lists reservations with Edit and Delete.
|
||||
export function reservationsTable(rs, done, opts = {}) {
|
||||
const write = can("config", true);
|
||||
return table([
|
||||
{ label: "Address", render: (r) => h("span", { class: "mono" }, r.ip) },
|
||||
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) },
|
||||
{ label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") },
|
||||
opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" },
|
||||
{ label: "Online", render: (r) => r.lease ? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, "yes, on " + r.lease.interface) : h("span", { class: "muted" }, "no") },
|
||||
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" },
|
||||
{ label: "Comment", render: (r) => r.comment },
|
||||
write ? { label: "", tdClass: "actions-cell", render: (r) => [
|
||||
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
|
||||
] } : null,
|
||||
].filter(Boolean), rs, { empty: opts.empty || "No reservations." });
|
||||
}
|
||||
|
||||
async function del(r, done) {
|
||||
if (!await confirmDialog("Delete reservation", "Delete the reservation of " + r.ip + " for " + (r.hostname || r.mac || r.client_id) + "? The client keeps its address until its lease runs out.", "Delete", "danger")) return;
|
||||
try {
|
||||
await change("DELETE", "/reservations/" + r.id);
|
||||
done();
|
||||
} catch (e) { modal("Not deleted", errorBox(e)); }
|
||||
}
|
||||
|
||||
// reservationDialog adds (r null) or edits a reservation. preset may give
|
||||
// {ip, mac, hostname} for a new one.
|
||||
export async function reservationDialog(r, done, preset = {}) {
|
||||
const [settings, subnets] = await Promise.all([get("/settings"), get("/subnets")]);
|
||||
// What the client gets from above: the settings', then its subnet's.
|
||||
const sub = subnets.find((x) => x.id === (r && r.subnet_id));
|
||||
const inherited = [...settings.options.filter((o) => !(sub && sub.options.some((x) => x.code === o.code))), ...(sub ? sub.options : [])];
|
||||
const ip = input({ value: r ? r.ip : preset.ip || "", placeholder: "10.0.10.20", class: "mono" });
|
||||
const mac = input({ value: r ? r.mac || "" : preset.mac || "", placeholder: "aa:bb:cc:dd:ee:ff", class: "mono" });
|
||||
const cid = input({ value: r ? r.client_id || "" : "", placeholder: "only for clients known by a client identifier", class: "mono" });
|
||||
const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
|
||||
const opts = await optionsEditor(r ? r.options : [], inherited);
|
||||
const comment = input({ value: r ? r.comment : "" });
|
||||
const err = h("div");
|
||||
const body = h("div", null, h("div", { class: "form-grid" },
|
||||
field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."),
|
||||
field("MAC address", mac),
|
||||
field("Client identifier", cid, "Optional: match the client's identifier (option 61, hex) instead of its MAC address."),
|
||||
field("Host name", host, "Sent to the client."),
|
||||
field("Options", opts, "Only for this client; they win over the subnet's."),
|
||||
field("Comment", comment)), err);
|
||||
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), comment: comment.value };
|
||||
try {
|
||||
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
|
||||
done();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
}
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const subnets = await get("/subnets");
|
||||
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
|
||||
const search = input({ type: "search", placeholder: "address, MAC or host name" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const rs = (await get("/reservations", { subnet: subnetSel.value })).filter((r) =>
|
||||
!q || r.ip.includes(q) || (r.mac || "").includes(q) || (r.hostname || "").toLowerCase().includes(q) || (r.client_id || "").includes(q));
|
||||
mount(out, reservationsTable(rs, draw, { empty: q || subnetSel.value ? "None match." : "No reservations yet." }));
|
||||
};
|
||||
subnetSel.addEventListener("change", draw);
|
||||
search.addEventListener("input", debounce(draw, 200));
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier.")),
|
||||
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => reservationDialog(null, draw) }, "New reservation")) : null),
|
||||
h("div", { class: "filter-row" }, subnetSel, search),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Settings: the default lease time, conflict checks, authority, and the
|
||||
// options every subnet gets.
|
||||
import { h, mount, input, field, checkbox, errorBox } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const s = await get("/settings");
|
||||
const write = can("config", true);
|
||||
const lease = input({ value: fmtSeconds(s.lease_time || 43200) });
|
||||
const ping = checkbox("Ping an address before offering it, and skip it if something answers", s.ping_check);
|
||||
const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
|
||||
timeout.style.width = "7em";
|
||||
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) });
|
||||
const auth = checkbox("Authoritative: refuse (NAK) requests for addresses that don't belong on the interface", !s.not_authoritative);
|
||||
const opts = await optionsEditor(s.options);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
const lt = parseSeconds(lease.value), dh = parseSeconds(hold.value);
|
||||
if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; }
|
||||
try {
|
||||
const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0,
|
||||
decline_hold: dh, not_authoritative: !auth.input.checked, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and options"),
|
||||
h("div", { class: "muted" }, "Server-wide settings, and the options every subnet sends. A subnet's or a reservation's own option of the same name wins."))),
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "form-grid" },
|
||||
field("Default lease time", lease, "How long clients keep an address unless a subnet says otherwise (12h, 1d). They renew halfway."),
|
||||
field("Options for every subnet", opts, "Usually domain-name-servers and domain-name; routers belong to each subnet."),
|
||||
field("Conflict check", h("div", null, ping, h("div", { class: "small" }, "wait ", timeout, " ms for an answer"))),
|
||||
field("Hold back for", hold, "How long an address a client declined (or one that answered a ping) isn't handed out."),
|
||||
field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")),
|
||||
err,
|
||||
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
|
||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// A subnet's page: its leases, its reservations and its settings.
|
||||
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel } 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"]];
|
||||
|
||||
export async function render(el, params) {
|
||||
const id = params.id;
|
||||
const tab = params.tab || "leases";
|
||||
const s = await get("/subnets/" + id);
|
||||
const body = h("div");
|
||||
mount(el,
|
||||
h("nav", { class: "back-link" }, h("a", { href: "#/subnets" }, "← Subnets")),
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" },
|
||||
h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null),
|
||||
h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ",
|
||||
s.pools.length ? h("span", { class: "mono" }, s.pools.map((p) => p.start + "-" + p.end).join(", ")) : "none",
|
||||
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
|
||||
h("div", { class: "subnet-usage" }, usageBar(s.usage))),
|
||||
h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))),
|
||||
body);
|
||||
if (tab === "leases") await leasesTab(body, s);
|
||||
else if (tab === "reservations") await reservationsTab(body, s);
|
||||
else await settingsTab(body, s);
|
||||
}
|
||||
|
||||
async function leasesTab(el, s) {
|
||||
const search = input({ type: "search", placeholder: "address, MAC or host name" });
|
||||
const out = h("div");
|
||||
let all = false;
|
||||
const toggle = h("a", { href: "#", class: "small", onclick: (e) => { e.preventDefault(); all = !all; toggle.textContent = all ? "Only current leases" : "Also expired and released"; draw(); } }, "Also expired and released");
|
||||
const draw = async () => {
|
||||
let ls = await get("/leases", { subnet: s.id, q: search.value.trim() });
|
||||
if (!all) ls = ls.filter((l) => l.state === "active" || l.state === "declined");
|
||||
mount(out, leasesTable(ls, rerender, { subnet: true, empty: all ? "No leases." : "No current leases." }));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el, h("div", { class: "filter-row" }, search, toggle), h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
|
||||
async function reservationsTab(el, s) {
|
||||
const rs = await get("/reservations", { subnet: s.id });
|
||||
mount(el,
|
||||
can("config", true) ? h("div", { class: "filter-row" }, h("button", { class: "primary", onclick: () => reservationDialog(null, rerender) }, "New reservation")) : null,
|
||||
h("div", { class: "panel" }, reservationsTable(rs, rerender, { subnet: true, empty: "No reservations in this subnet." })));
|
||||
}
|
||||
|
||||
async function settingsTab(el, s) {
|
||||
const settings = await get("/settings");
|
||||
if (!can("config", true)) {
|
||||
mount(el, h("div", { class: "panel" }, h("pre", null, JSON.stringify(s, null, 2))));
|
||||
return;
|
||||
}
|
||||
const form = await subnetForm(s, settings);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
try {
|
||||
const r = await change("PATCH", "/subnets/" + s.id, form.values(), { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
const del = async () => {
|
||||
const n = s.usage.reservations;
|
||||
if (!await confirmDialog("Delete subnet", "Delete " + subnetLabel(s) + (n ? " and its " + n + (n === 1 ? " reservation" : " reservations") : "") + "? Its clients get no answers from then on; their leases are kept until they run out. History can undo it.", "Delete", "danger")) return;
|
||||
try {
|
||||
await change("DELETE", "/subnets/" + s.id);
|
||||
location.hash = "#/subnets";
|
||||
} catch (e) { modal("Not deleted", errorBox(e)); }
|
||||
};
|
||||
mount(el, h("div", { class: "panel" }, form, err,
|
||||
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"),
|
||||
h("span", { class: "grow" }),
|
||||
h("button", { class: "danger", onclick: del }, "Delete subnet"))));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// The subnets, and the New subnet dialog.
|
||||
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const subnets = await get("/subnets");
|
||||
const poolsText = (s) => s.pools.length ? s.pools.map((p) => p.start + "-" + p.end).join(", ") : "–";
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Subnets"),
|
||||
h("div", { class: "muted" }, "Each subnet is served on one interface: only clients on that interface get its addresses.")),
|
||||
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => newSubnet() }, "New subnet")) : null),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "Subnet", render: (s) => h("a", { href: "#/subnets/" + s.id }, subnetLabel(s)) },
|
||||
{ label: "Interface", render: (s) => h("span", { class: "mono" }, s.interface) },
|
||||
{ label: "Pools", render: (s) => h("span", { class: "mono small" }, poolsText(s)) },
|
||||
{ label: "In use", render: (s) => usageBar(s.usage) },
|
||||
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
|
||||
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
|
||||
{ label: "", render: (s) => s.disabled ? badge("disabled", "off") : null },
|
||||
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve." })));
|
||||
if (query.get("new") === "1" && can("config", true)) newSubnet();
|
||||
}
|
||||
|
||||
export async function newSubnet() {
|
||||
const settings = await get("/settings");
|
||||
const form = await subnetForm(null, settings);
|
||||
const err = h("div");
|
||||
const res = await modal("New subnet", h("div", null, form, err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Add subnet", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const r = await change("POST", "/subnets", form.values());
|
||||
location.hash = "#/subnets/" + (r.object ? r.object.id : "");
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Users and everyone's tokens (administrators).
|
||||
import { h, mount, table, time, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge } from "../ui.js";
|
||||
import { get, api, catalog } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const [users, tokens, cat] = await Promise.all([get("/users"), get("/tokens"), catalog()]);
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Users"), h("div", { class: "muted" }, "Administrators may do everything. Others get a role or grants: config, leases and audit, each read or write.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, cat) }, "New user"))),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (u) => [h("strong", null, u.username), u.disabled ? [" ", badge("disabled", "off")] : null] },
|
||||
{ label: "May", render: (u) => u.admin ? badge("administrator", "accent") : h("span", { class: "mono small" }, u.grant_strings.join(" ") || "nothing") },
|
||||
{ label: "Password", render: (u) => u.has_password ? "yes" : h("span", { class: "muted" }, "tokens only") },
|
||||
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
|
||||
{ label: "Comment", render: (u) => u.comment },
|
||||
{ label: "", tdClass: "actions-cell", render: (u) => [
|
||||
h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
|
||||
], users)),
|
||||
h("h2", null, "API tokens"),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (t) => t.username },
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), h("span", { class: "muted small" }, " from " + t.last_used_ip)] : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
export async function revoke(t) {
|
||||
if (!await confirmDialog("Revoke token", "Revoke " + t.username + "'s token “" + t.name + "”? Anything using it is refused from now on.", "Revoke", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Revoked");
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function del(u) {
|
||||
if (!await confirmDialog("Delete user", "Delete " + u.username + " and their tokens?", "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/users/" + u.id);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function userDialog(u, cat) {
|
||||
const name = input({ value: u ? u.username : "", autocomplete: "off" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "unchanged" : "at least 8 characters" });
|
||||
const admin = checkbox("Administrator", u ? u.admin : false);
|
||||
const role = select([["", "custom grants"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const grants = input({ value: u ? u.grant_strings.join(" ") : "", placeholder: "config:read leases:write", class: "mono" });
|
||||
const disabled = checkbox("Disabled (logs them out)", u ? u.disabled : false);
|
||||
const comment = input({ value: u ? u.comment : "" });
|
||||
const err = h("div");
|
||||
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Username", name),
|
||||
field("Password", pw, u ? "Leave empty to keep it." : "Leave empty for a service account that only uses API tokens."),
|
||||
h("label"), admin,
|
||||
field("Role", role),
|
||||
field("Grants", grants, "resource:access, separated by spaces; resources config, leases, audit; access read or write."),
|
||||
u ? [h("label"), disabled] : null,
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: u ? "Save" : "Add user", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
const body = { username: name.value.trim(), admin: admin.input.checked, comment: comment.value };
|
||||
if (role.value) body.role = role.value;
|
||||
else body.grants = grants.value.split(/[\s,]+/).filter(Boolean);
|
||||
if (pw.value || !u) body.password = pw.value;
|
||||
if (u) body.disabled = disabled.input.checked;
|
||||
try {
|
||||
await api(u ? "PATCH" : "POST", u ? "/users/" + u.id : "/users", { body });
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { sticky: true });
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/* Steward web UI. Plain CSS, light and dark themes (the same as Jarl's). */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #1d2330;
|
||||
--muted: #5f6b7d;
|
||||
--hatch: rgba(95, 107, 125, 0.13);
|
||||
--border: #d9dee6;
|
||||
--accent: #2f5fb3;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #e5edfa;
|
||||
--ok: #1e7a46;
|
||||
--ok-soft: #e3f4ea;
|
||||
--warn: #8a5a00;
|
||||
--warn-soft: #fdf2d9;
|
||||
--err: #b3261e;
|
||||
--err-soft: #fbe7e5;
|
||||
--info-soft: #eef1f6;
|
||||
--code-bg: #f0f2f5;
|
||||
--add: #e6f6ea;
|
||||
--add-text: #135c2c;
|
||||
--del: #fde8e7;
|
||||
--del-text: #8c1d18;
|
||||
--hunk: #e8eefb;
|
||||
--shadow: 0 1px 2px rgba(20, 30, 50, 0.08);
|
||||
--radius: 6px;
|
||||
--vc-green: #3d8160; --vc-teal: #2f7c80; --vc-purple: #7a5ba8; --vc-pink: #a8557f;
|
||||
--vc-orange: #a8652f; --vc-yellow: #8a7424; --vc-red: #b0534c; --vc-grey: #6b7280;
|
||||
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #14171c;
|
||||
--panel: #1c2027;
|
||||
--text: #e3e7ee;
|
||||
--muted: #9aa5b5;
|
||||
--hatch: rgba(154, 165, 181, 0.12);
|
||||
--border: #2f3540;
|
||||
--accent: #6d9be8;
|
||||
--accent-text: #0f1520;
|
||||
--accent-soft: #23314a;
|
||||
--ok: #59c287;
|
||||
--ok-soft: #173326;
|
||||
--warn: #e5b450;
|
||||
--warn-soft: #3a2f15;
|
||||
--err: #f08077;
|
||||
--err-soft: #3c1f1d;
|
||||
--info-soft: #232833;
|
||||
--code-bg: #242933;
|
||||
--add: #183a25;
|
||||
--add-text: #9fe3b5;
|
||||
--del: #43201e;
|
||||
--del-text: #f7b2ac;
|
||||
--hunk: #23314a;
|
||||
--shadow: none;
|
||||
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540; --hatch: rgba(154, 165, 181, 0.12);
|
||||
--accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326;
|
||||
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
|
||||
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
|
||||
--shadow: none; color-scheme: dark;
|
||||
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code, pre, .mono, textarea.mono, input.mono { font-family: var(--mono); font-size: 13px; }
|
||||
pre { background: var(--code-bg); padding: 10px 12px; border-radius: var(--radius); overflow: auto; margin: 0; white-space: pre; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
h2 { font-size: 16px; margin: 18px 0 8px; }
|
||||
h3 { font-size: 14px; margin: 14px 0 6px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.warn-text { color: var(--warn); }
|
||||
.right { text-align: right; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Layout */
|
||||
header.top {
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
padding: 8px 16px; position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
header.top .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; }
|
||||
header.top .brand .logo { display: block; transition: transform 0.15s; }
|
||||
header.top .brand .brand-name { display: block; color: var(--text); }
|
||||
header.top .brand:hover { text-decoration: none; }
|
||||
header.top .brand:hover .brand-name { color: var(--accent); }
|
||||
header.top .brand:hover .logo { transform: scale(1.06); }
|
||||
nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
|
||||
nav.main a:hover { background: var(--info-soft); text-decoration: none; }
|
||||
nav.main a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
header.top .spacer { flex: 1; }
|
||||
header.top form.search input { width: 220px; }
|
||||
header.top .user { color: var(--muted); }
|
||||
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.page-head .grow { flex: 1; min-width: 200px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* Panels and cards */
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
|
||||
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
|
||||
/* Tiles share a row and grow to fill it, so a wrapped tile never sits
|
||||
alone beside an empty space. */
|
||||
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
|
||||
.cards > * { flex: 1 1 170px; min-width: 0; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
|
||||
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
|
||||
.card .sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; background: var(--panel); }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
|
||||
tr:hover td { background: var(--info-soft); }
|
||||
td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; }
|
||||
td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
|
||||
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
|
||||
tr.editing td { background: var(--accent-soft); }
|
||||
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
|
||||
/* Disabled records: hatched, so they stand out from live and pending rows. */
|
||||
tr.disabled td { background-image: repeating-linear-gradient(135deg, transparent 0 7px, var(--hatch) 7px 9px); }
|
||||
.badge.off { background: var(--muted); color: var(--panel); }
|
||||
button.toggle { min-width: 5.2em; }
|
||||
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
||||
|
||||
/* Forms */
|
||||
input, select, textarea, button {
|
||||
font: inherit; color: var(--text);
|
||||
}
|
||||
input[type=text], input[type=password], input[type=number], input[type=search], input:not([type]), select, textarea {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; min-width: 0;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
|
||||
textarea { width: 100%; min-height: 80px; resize: vertical; }
|
||||
.form-grid { display: grid; grid-template-columns: minmax(140px, 200px) 1fr; gap: 8px 14px; align-items: start; }
|
||||
.form-grid > label { padding-top: 6px; color: var(--muted); }
|
||||
.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; }
|
||||
.form-grid input, .form-grid select { width: 100%; max-width: 560px; }
|
||||
.form-grid input[type=checkbox] { width: auto; }
|
||||
.inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
|
||||
.inline-form .f label:not(.check) { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.inline-form .grow { flex: 1; min-width: 220px; }
|
||||
.inline-form .grow input { width: 100%; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
|
||||
|
||||
button, .button {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 5px 12px; cursor: pointer; white-space: nowrap; color: var(--text);
|
||||
}
|
||||
button:hover, .button:hover { background: var(--info-soft); text-decoration: none; }
|
||||
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||
button.primary:hover { filter: brightness(1.08); background: var(--accent); }
|
||||
button.danger { color: var(--err); border-color: var(--err); }
|
||||
button.danger:hover { background: var(--err-soft); }
|
||||
button.small, .button.small { padding: 2px 8px; font-size: 12px; }
|
||||
button.link { border: none; background: none; color: var(--accent); padding: 0 4px; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Badges and messages */
|
||||
.badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; background: var(--info-soft); color: var(--muted); white-space: nowrap; }
|
||||
.badge.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.err { background: var(--err-soft); color: var(--err); }
|
||||
.badge.accent { background: var(--accent-soft); color: var(--accent); }
|
||||
.msg { border-radius: var(--radius); padding: 8px 12px; margin: 8px 0; border: 1px solid transparent; }
|
||||
.msg.err { background: var(--err-soft); color: var(--err); border-color: var(--err); }
|
||||
.msg.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.msg.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.msg.info { background: var(--info-soft); }
|
||||
.msg ul { margin: 4px 0 0; padding-left: 18px; }
|
||||
.problems { list-style: none; padding: 0; margin: 6px 0; }
|
||||
.problems li { padding: 4px 8px; border-left: 3px solid var(--border); margin-bottom: 3px; background: var(--info-soft); border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
.problems li.error { border-color: var(--err); background: var(--err-soft); }
|
||||
.problems li.warning { border-color: var(--warn); background: var(--warn-soft); }
|
||||
.problems li .where { color: var(--muted); font-size: 12px; margin-left: 6px; }
|
||||
|
||||
/* Diff */
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.tabs a { padding: 7px 12px; border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; color: var(--muted); }
|
||||
.tabs a.active { background: var(--panel); border-color: var(--border); color: var(--text); font-weight: 600; margin-bottom: -1px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-back { position: fixed; inset: 0; background: rgba(10, 15, 25, 0.45); display: flex; align-items: flex-start; justify-content: center; padding: 60px 16px; z-index: 50; overflow: auto; }
|
||||
.modal { background: var(--panel); border-radius: 8px; border: 1px solid var(--border); width: min(760px, 100%); padding: 18px 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.25); }
|
||||
.modal.wide { width: min(1100px, 100%); }
|
||||
.modal h2 { margin-top: 0; }
|
||||
.modal .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
|
||||
/* Toasts */
|
||||
#toasts { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; max-width: min(480px, calc(100% - 32px)); }
|
||||
.toast { background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent); padding: 10px 14px; border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.warn { border-left-color: var(--warn); }
|
||||
|
||||
/* Login */
|
||||
.login { max-width: 360px; margin: 10vh auto; }
|
||||
.login-logo { display: block; margin: 8px auto 10px; }
|
||||
.login h1 { text-align: center; margin: 0 0 8px; }
|
||||
.wordmark { display: block; width: 220px; height: auto; margin: 0 auto; color: var(--text); }
|
||||
.wordmark-rule .fill { fill: var(--accent); }
|
||||
.wordmark-rule .line { fill: none; stroke: var(--accent); stroke-width: 2.6; }
|
||||
.login .tag { text-align: center; color: var(--muted); margin-bottom: 18px; }
|
||||
.login form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.login input { width: 100%; padding: 8px 10px; }
|
||||
.login button { justify-content: center; padding: 8px; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; }
|
||||
.grants { font-family: var(--mono); font-size: 12px; }
|
||||
.copy-box { display: flex; gap: 6px; align-items: center; }
|
||||
.login.wide { max-width: 520px; }
|
||||
.copy-box input { flex: 1; font-family: var(--mono); }
|
||||
.copy-box input { min-width: 0; }
|
||||
td.break { word-break: break-all; }
|
||||
@media (max-width: 700px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-grid .help { grid-column: 1; }
|
||||
header.top form.search input { width: 140px; }
|
||||
}
|
||||
|
||||
|
||||
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
||||
/* Secondary row actions show when the row is hovered or focused; touch
|
||||
screens, which can't hover, always show them. Opacity keeps them in the
|
||||
layout (no shifting) and reachable with Tab. */
|
||||
tr .reveal { opacity: 0; transition: opacity 0.1s; }
|
||||
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
|
||||
@media (hover: none) { tr .reveal { opacity: 1; } }
|
||||
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
||||
.segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; }
|
||||
.segmented a:last-child { border-right: none; }
|
||||
.segmented a:hover { background: var(--info-soft); }
|
||||
.segmented a.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||
/* the "← Zones" link above a page's heading */
|
||||
.back-link { font-size: 13px; margin-bottom: 2px; }
|
||||
|
||||
tr.selected td { background: var(--accent-soft); }
|
||||
/* the page footer with the Steward build */
|
||||
.shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.shell > main { flex: 1; width: 100%; }
|
||||
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
|
||||
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
|
||||
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }
|
||||
|
||||
/* a request in progress */
|
||||
.spinner { display: inline-block; width: 11px; height: 11px; margin-right: 6px; vertical-align: -1px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-top-color: var(--border); background: var(--accent-soft); } }
|
||||
|
||||
.err-text { color: var(--err); }
|
||||
ul.plain { list-style: none; margin: 8px 0; padding: 0; }
|
||||
ul.plain li { padding: 3px 0; }
|
||||
/* Drop-down menus in the top bar (Server, the user). */
|
||||
details.menu { position: relative; }
|
||||
details.menu > summary { list-style: none; cursor: pointer; padding: 6px 10px; border-radius: var(--radius); color: var(--text); white-space: nowrap; }
|
||||
details.menu > summary::-webkit-details-marker { display: none; }
|
||||
details.menu > summary:hover, details.menu[open] > summary { background: var(--info-soft); }
|
||||
details.menu > summary.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
details.menu > summary .caret { margin-left: 4px; font-size: 0.8em; opacity: 0.7; }
|
||||
details.menu > summary .user { color: inherit; }
|
||||
details.menu .menu-list {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
|
||||
display: flex; flex-direction: column; padding: 4px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
details.menu.right .menu-list { left: auto; right: 0; }
|
||||
details.menu .menu-list a, details.menu .menu-list .menu-item {
|
||||
display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text); text-align: left;
|
||||
background: none; border: 0; font: inherit; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
details.menu .menu-list a:hover, details.menu .menu-list .menu-item:hover { background: var(--info-soft); text-decoration: none; }
|
||||
details.menu .menu-list a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
nav.main { align-items: center; }
|
||||
header.top .header-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
@media (max-width: 1200px) {
|
||||
header.top form.search input { width: 160px; }
|
||||
header.top { gap: 10px; }
|
||||
}
|
||||
|
||||
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
|
||||
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||
|
||||
/* ---- Steward */
|
||||
.wordmark-rule .tag { fill: var(--accent); font: 700 17px system-ui, sans-serif; letter-spacing: 0.5px; }
|
||||
.login .wordmark { width: 290px; }
|
||||
.subnet-usage { width: 260px; }
|
||||
/* How full a subnet's pools are. */
|
||||
.usage { min-width: 120px; }
|
||||
.usage-bar { display: flex; height: 8px; border-radius: 4px; background: var(--info-soft); overflow: hidden; border: 1px solid var(--border); margin-top: 3px; }
|
||||
.usage-bar .seg.leased { background: var(--accent); }
|
||||
.usage-bar .seg.declined { background: var(--warn); }
|
||||
.usage-bar.full .seg.leased { background: var(--err); }
|
||||
.usage-text { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
/* Interfaces: serving or not. */
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; background: var(--muted); }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.err { background: var(--err); }
|
||||
.iface-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.iface-list .iface { display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline; }
|
||||
.iface-list .iface .name { font-family: var(--mono); font-weight: 600; min-width: 8ch; }
|
||||
/* The options editor: a row per option. */
|
||||
.opt-rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.opt-row { display: flex; gap: 6px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.opt-row select { width: 15em; }
|
||||
.opt-row input.value { flex: 1; min-width: 14em; font-family: var(--mono); }
|
||||
.opt-row select.type { width: 8em; }
|
||||
.opt-row .help { flex-basis: 100%; color: var(--muted); font-size: 12px; margin: -2px 0 2px; }
|
||||
.opt-inherited { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
.opt-inherited code { font-family: var(--mono); }
|
||||
/* Pools editor. */
|
||||
.pool-rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pool-row { display: flex; gap: 6px; align-items: center; }
|
||||
.pool-row input { font-family: var(--mono); width: 18em; }
|
||||
/* Activity: one line per message. */
|
||||
table.activity td { white-space: nowrap; }
|
||||
table.activity td.detail { white-space: normal; color: var(--muted); }
|
||||
.type-OFFER, .type-ACK { color: var(--ok); }
|
||||
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
|
||||
.type-RELEASE { color: var(--muted); }
|
||||
td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
|
||||
@@ -0,0 +1,228 @@
|
||||
// Small DOM helpers and widgets used by every page.
|
||||
|
||||
// h creates an element. attrs may contain class, style, on<event>
|
||||
// handlers, dataset, value/checked/disabled and any other attribute.
|
||||
export function h(tag, attrs, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (k === "class") el.className = v;
|
||||
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
|
||||
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
||||
else if (k === "dataset") Object.assign(el.dataset, v);
|
||||
else if (k === "value") el.value = v;
|
||||
else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v;
|
||||
else el.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
}
|
||||
append(el, children);
|
||||
return el;
|
||||
}
|
||||
|
||||
function append(el, children) {
|
||||
for (const c of children.flat(Infinity)) {
|
||||
if (c === null || c === undefined || c === false) continue;
|
||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(el) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function mount(el, ...children) {
|
||||
clear(el);
|
||||
append(el, children);
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---- formatting
|
||||
|
||||
export function fmtTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||
}
|
||||
|
||||
export function relTime(iso) {
|
||||
if (!iso) return "";
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
const s = Math.abs(diff);
|
||||
if (s < 60) return "just now";
|
||||
let n;
|
||||
if (s < 3600) n = Math.floor(s / 60) + " min";
|
||||
else if (s < 86400) n = Math.floor(s / 3600) + " h";
|
||||
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
|
||||
else return fmtTime(iso);
|
||||
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
|
||||
}
|
||||
|
||||
export function time(iso) {
|
||||
return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso));
|
||||
}
|
||||
|
||||
export function plural(n, word, words) {
|
||||
return n + " " + (n === 1 ? word : (words || word + "s"));
|
||||
}
|
||||
|
||||
export function badge(text, kind) {
|
||||
return h("span", { class: "badge" + (kind ? " " + kind : "") }, text);
|
||||
}
|
||||
|
||||
// ---- toasts
|
||||
|
||||
export function toast(message, kind = "ok", ms = 3500) {
|
||||
const box = document.getElementById("toasts");
|
||||
const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message);
|
||||
// A repeated message replaces the earlier one instead of stacking.
|
||||
for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove();
|
||||
box.append(t);
|
||||
while (box.children.length > 3) box.firstChild.remove();
|
||||
setTimeout(() => t.remove(), ms);
|
||||
}
|
||||
|
||||
// ---- modal dialogs
|
||||
|
||||
// modal shows a dialog. buttons: [{label, kind, value, onclick}].
|
||||
// onclick may return false to keep the dialog open, or a promise.
|
||||
export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const back = h("div", { class: "modal-back" });
|
||||
const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
|
||||
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
const btns = buttons.map((b) =>
|
||||
h("button", {
|
||||
class: b.kind || "", type: "button",
|
||||
onclick: async (e) => {
|
||||
if (b.onclick) {
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const r = await b.onclick();
|
||||
if (r === false) return;
|
||||
} finally { e.target.disabled = false; }
|
||||
}
|
||||
close(b.value === undefined ? b.label : b.value);
|
||||
},
|
||||
}, b.label));
|
||||
const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns));
|
||||
back.append(dialog);
|
||||
back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); });
|
||||
document.body.append(back);
|
||||
const first = dialog.querySelector("input, select, textarea");
|
||||
if (first) first.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") {
|
||||
const r = await modal(title, h("p", null, message), [
|
||||
{ label: "Cancel", value: false },
|
||||
{ label: okLabel, kind, value: true },
|
||||
]);
|
||||
return r === true;
|
||||
}
|
||||
|
||||
// ---- forms
|
||||
|
||||
// field returns a label + control pair for .form-grid layouts.
|
||||
export function field(label, control, help) {
|
||||
// The label points at the control itself, also when it is wrapped.
|
||||
const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
|
||||
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
|
||||
target.id = id;
|
||||
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
|
||||
}
|
||||
|
||||
export function input(attrs = {}) {
|
||||
return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs });
|
||||
}
|
||||
|
||||
export function select(options, value, attrs = {}) {
|
||||
const s = h("select", attrs);
|
||||
for (const o of options) {
|
||||
const [v, label] = Array.isArray(o) ? o : [o, o];
|
||||
s.append(h("option", { value: v, selected: String(v) === String(value) }, label));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function textarea(value, attrs = {}) {
|
||||
return h("textarea", { spellcheck: "false", ...attrs, value: value || "" });
|
||||
}
|
||||
|
||||
export function checkbox(label, checked, attrs = {}) {
|
||||
const c = h("input", { type: "checkbox", checked, ...attrs });
|
||||
const l = h("label", { class: "check" }, c, label);
|
||||
l.input = c;
|
||||
return l;
|
||||
}
|
||||
|
||||
// lines splits a textarea's value into non-empty trimmed lines.
|
||||
export function lines(text) {
|
||||
return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== "");
|
||||
}
|
||||
|
||||
// ---- tables
|
||||
|
||||
// table renders rows with columns [{label, render(row) => node|string, class}].
|
||||
export function table(columns, rows, opts = {}) {
|
||||
if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet.");
|
||||
const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label))));
|
||||
const tbody = h("tbody");
|
||||
rows.forEach((row, i) => {
|
||||
const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" });
|
||||
for (const c of columns) {
|
||||
const v = c.render(row, i);
|
||||
tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v));
|
||||
}
|
||||
tbody.append(tr);
|
||||
});
|
||||
return h("div", { class: "table-wrap" }, h("table", null, thead, tbody));
|
||||
}
|
||||
|
||||
// ---- problems and errors
|
||||
|
||||
export function problemsList(problems) {
|
||||
if (!problems || !problems.length) return null;
|
||||
return h("ul", { class: "problems" }, problems.map((p) =>
|
||||
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
|
||||
}
|
||||
|
||||
// errorBox shows an API error with its problems.
|
||||
export function errorBox(err) {
|
||||
if (!err) return null;
|
||||
const d = err.details || {};
|
||||
let problems = d.problems || [];
|
||||
// Don't repeat a single problem that is already the headline.
|
||||
if (problems.length === 1 && err.message.includes(problems[0].message)) problems = [];
|
||||
return h("div", { class: "msg err" }, h("div", null, err.message), problemsList(problems));
|
||||
}
|
||||
|
||||
export function copyBox(value) {
|
||||
const inp = input({ value, readOnly: true });
|
||||
const btn = h("button", { type: "button", class: "small", onclick: async () => {
|
||||
try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); }
|
||||
} }, "Copy");
|
||||
return h("div", { class: "copy-box" }, inp, btn);
|
||||
}
|
||||
|
||||
// debounce delays fn until calls stop for ms.
|
||||
export function debounce(fn, ms = 250) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
// backLink is the "← Zones" link above a page's heading, back to the
|
||||
// page it was opened from.
|
||||
export function backLink(label, href) {
|
||||
return h("nav", { class: "back-link", "aria-label": "Back" }, h("a", { href }, "← ", label));
|
||||
}
|
||||
|
||||
// safeBack returns a "back" address taken from the URL if it is one of
|
||||
// the UI's own (#/...), else null: it ends up in an href.
|
||||
export function safeBack(v) {
|
||||
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Widgets shared by several pages: usage bars, the options and pools
|
||||
// editors, the subnet form, durations.
|
||||
import { h, mount, input, select, field, checkbox } from "./ui.js";
|
||||
import { catalog, get } from "./api.js";
|
||||
|
||||
// ---- durations
|
||||
|
||||
// fmtSeconds writes seconds the short way: 43200 is "12h", 5400 "1h30m".
|
||||
export function fmtSeconds(n) {
|
||||
if (!n || n <= 0) return "";
|
||||
const parts = [];
|
||||
for (const [s, u] of [[86400, "d"], [3600, "h"], [60, "m"], [1, "s"]]) {
|
||||
if (n >= s) { parts.push(Math.floor(n / s) + u); n %= s; }
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// parseSeconds reads "12h", "30m", "1d12h" or plain seconds; null when
|
||||
// it can't.
|
||||
export function parseSeconds(s) {
|
||||
s = String(s || "").trim();
|
||||
if (s === "") return 0;
|
||||
if (/^\d+$/.test(s)) return parseInt(s, 10);
|
||||
const re = /(\d+)\s*([dhms])/g;
|
||||
let total = 0, m, used = "";
|
||||
while ((m = re.exec(s))) {
|
||||
total += parseInt(m[1], 10) * { d: 86400, h: 3600, m: 60, s: 1 }[m[2]];
|
||||
used += m[0];
|
||||
}
|
||||
return used.replace(/\s/g, "") === s.replace(/\s/g, "") && total > 0 ? total : null;
|
||||
}
|
||||
|
||||
// ---- usage
|
||||
|
||||
// usageBar shows how much of a subnet's pools is leased.
|
||||
export function usageBar(u) {
|
||||
if (!u || !u.pool_size) return h("span", { class: "muted small" }, u && u.reservations ? "reservations only" : "no pools");
|
||||
const pct = (n) => Math.min(100, (n * 100) / u.pool_size);
|
||||
const full = u.leased + u.declined >= u.pool_size;
|
||||
const leased = h("div", { class: "seg leased" }), declined = h("div", { class: "seg declined" });
|
||||
leased.style.width = pct(u.leased) + "%";
|
||||
declined.style.width = pct(u.declined) + "%";
|
||||
return h("div", { class: "usage", title: u.leased + " leased, " + u.declined + " held back, " + (u.pool_size - u.leased - u.declined) + " free of " + u.pool_size },
|
||||
h("div", { class: "usage-text" }, u.leased + " / " + u.pool_size + " (" + Math.round(pct(u.leased)) + "%)", u.declined ? " · " + u.declined + " held back" : ""),
|
||||
h("div", { class: "usage-bar" + (full ? " full" : "") }, leased, declined));
|
||||
}
|
||||
|
||||
// ---- options
|
||||
|
||||
// optionsEditor edits a list of options. inherited are the options this
|
||||
// level gets from above (shown, and overridden by an option of the same
|
||||
// code here). Its value() is the list for the API ({option, value, type}).
|
||||
export async function optionsEditor(options, inherited = []) {
|
||||
const cat = await catalog();
|
||||
const rows = h("div", { class: "opt-rows" });
|
||||
const box = h("div", null, rows);
|
||||
const byCode = new Map(cat.options.map((o) => [o.code, o]));
|
||||
const choices = [
|
||||
["", "choose an option…"],
|
||||
...cat.options.filter((o) => o.common).map((o) => [String(o.code), o.name + " (" + o.code + ")"]),
|
||||
...cat.options.filter((o) => !o.common).map((o) => [String(o.code), o.name + " (" + o.code + ")"]),
|
||||
["custom", "another option, by number…"],
|
||||
];
|
||||
const list = [];
|
||||
const addRow = (opt) => {
|
||||
const known = opt && byCode.has(opt.code);
|
||||
const sel = select(choices, opt ? (known ? String(opt.code) : "custom") : "");
|
||||
const code = input({ type: "number", min: 1, max: 254, placeholder: "code", value: opt && !known ? opt.code : "" });
|
||||
code.style.width = "6em";
|
||||
const typ = select(cat.types, (opt && opt.type) || "hex", { class: "type" });
|
||||
const val = input({ class: "value", value: opt ? opt.value : "", placeholder: "value" });
|
||||
const help = h("div", { class: "help" });
|
||||
const row = h("div", { class: "opt-row" });
|
||||
const sync = () => {
|
||||
const custom = sel.value === "custom";
|
||||
code.classList.toggle("hidden", !custom);
|
||||
typ.classList.toggle("hidden", !custom);
|
||||
const d = byCode.get(parseInt(sel.value, 10));
|
||||
mount(help, d ? d.type + ": " + d.help : custom ? "Steward doesn't know this option: give its number and how its value is written" : "");
|
||||
if (d) val.placeholder = examples[d.type] || "value";
|
||||
};
|
||||
sel.addEventListener("change", sync);
|
||||
const entry = {
|
||||
row,
|
||||
value: () => {
|
||||
if (sel.value === "") return null;
|
||||
if (sel.value === "custom") return { option: String(code.value), value: val.value, type: typ.value };
|
||||
return { option: sel.value, value: val.value };
|
||||
},
|
||||
};
|
||||
const del = h("button", { type: "button", class: "small", title: "Remove this option", onclick: () => { row.remove(); list.splice(list.indexOf(entry), 1); } }, "✕");
|
||||
mount(row, sel, code, typ, val, del, help);
|
||||
sync();
|
||||
list.push(entry);
|
||||
rows.append(row);
|
||||
};
|
||||
for (const o of options || []) addRow(o);
|
||||
box.append(h("button", { type: "button", class: "small", onclick: () => addRow(null) }, "+ Add option"));
|
||||
const inh = (inherited || []).filter((o) => !(options || []).some((x) => x.code === o.code));
|
||||
if (inh.length) {
|
||||
box.append(h("div", { class: "opt-inherited" }, "Also sent, from above: ",
|
||||
inh.map((o, i) => [i ? "; " : "", h("code", null, o.name + "=" + o.value)]),
|
||||
". An option set here wins."));
|
||||
}
|
||||
box.value = () => list.map((e) => e.value()).filter(Boolean);
|
||||
return box;
|
||||
}
|
||||
|
||||
const examples = {
|
||||
ip: "10.0.10.1", ips: "10.0.10.1, 10.0.10.2", string: "text", uint8: "0-255", uint16: "1500", uint32: "86400",
|
||||
int32: "-3600", bool: "true or false", domains: "lan, example.com", routes: "10.20.0.0/16 via 10.0.10.1", hex: "01:02:0a",
|
||||
};
|
||||
|
||||
// ---- pools
|
||||
|
||||
// poolsEditor edits a subnet's pools; value() is ["first-last", ...].
|
||||
export function poolsEditor(pools, placeholder) {
|
||||
const rows = h("div", { class: "pool-rows" });
|
||||
const box = h("div", null, rows);
|
||||
const list = [];
|
||||
const addRow = (v) => {
|
||||
const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" });
|
||||
const row = h("div", { class: "pool-row" }, inp,
|
||||
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕"));
|
||||
list.push(inp);
|
||||
rows.append(row);
|
||||
return inp;
|
||||
};
|
||||
for (const p of pools || []) addRow(p.start + "-" + p.end);
|
||||
box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
|
||||
box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== "");
|
||||
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; };
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---- the subnet form
|
||||
|
||||
// v4 helpers for suggestions.
|
||||
function ip2n(s) { const p = s.split(".").map(Number); return ((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]; }
|
||||
function n2ip(n) { return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."); }
|
||||
|
||||
// suggestPool proposes a pool for a prefix: in a /24, .100-.199; else the
|
||||
// second half, without the broadcast address.
|
||||
export function suggestPool(prefix) {
|
||||
const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(prefix || "");
|
||||
if (!m) return "";
|
||||
const bits = parseInt(m[2], 10);
|
||||
if (bits < 8 || bits > 29) return "";
|
||||
const size = 2 ** (32 - bits);
|
||||
const net = (ip2n(m[1]) & ~(size - 1)) >>> 0;
|
||||
if (bits === 24) return n2ip(net + 100) + "-" + n2ip(net + 199);
|
||||
return n2ip(net + size / 2) + "-" + n2ip(net + size - 2);
|
||||
}
|
||||
|
||||
// subnetForm builds the fields of a subnet; values() returns the API
|
||||
// input. sn is the subnet to edit (null for a new one).
|
||||
export async function subnetForm(sn, settings) {
|
||||
const ifaces = await get("/interfaces");
|
||||
const name = input({ value: sn ? sn.name : "", placeholder: "office, iot, guests…" });
|
||||
const ifaceNames = ifaces.map((i) => i.name);
|
||||
const ifaceSel = select([["", "choose…"], ...ifaces.map((i) => [i.name, i.name + (i.addresses.length ? " (" + i.addresses.join(", ") + ")" : " (no IPv4 address)")]), ["other", "another name…"]],
|
||||
sn ? (ifaceNames.includes(sn.interface) ? sn.interface : "other") : "");
|
||||
const ifaceOther = input({ value: sn && !ifaceNames.includes(sn.interface) ? sn.interface : "", placeholder: "eth0.30 (one that comes up later)" });
|
||||
const prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" });
|
||||
const pools = poolsEditor(sn ? sn.pools : []);
|
||||
const lease = input({ value: sn ? fmtSeconds(sn.lease_time) : "", placeholder: "the default: " + fmtSeconds(settings.lease_time || 43200) });
|
||||
const opts = await optionsEditor(sn ? sn.options : [], settings.options);
|
||||
const comment = input({ value: sn ? sn.comment : "" });
|
||||
const ifaceHint = h("div", { class: "help" });
|
||||
const ifaceValue = () => (ifaceSel.value === "other" ? ifaceOther.value.trim() : ifaceSel.value);
|
||||
const syncIface = () => {
|
||||
ifaceOther.classList.toggle("hidden", ifaceSel.value !== "other");
|
||||
const i = ifaces.find((x) => x.name === ifaceSel.value);
|
||||
mount(ifaceHint, "Only clients on this interface get the subnet's addresses.",
|
||||
i && i.subnets.length && (!sn || !i.subnets.includes(sn.id)) ? " It already serves " + i.subnets.length + (i.subnets.length === 1 ? " subnet" : " subnets") + "; clients get addresses from each in turn." : "");
|
||||
// A new subnet: suggest the interface's network and a pool.
|
||||
if (!sn && i && i.addresses.length && !prefix.value) {
|
||||
const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(i.addresses[0]);
|
||||
if (m) {
|
||||
const size = 2 ** (32 - parseInt(m[2], 10));
|
||||
prefix.value = n2ip((ip2n(m[1]) & ~(size - 1)) >>> 0) + "/" + m[2];
|
||||
pools.setFirst(suggestPool(prefix.value));
|
||||
}
|
||||
}
|
||||
};
|
||||
ifaceSel.addEventListener("change", syncIface);
|
||||
prefix.addEventListener("change", () => { if (!sn) pools.setFirst(suggestPool(prefix.value.trim())); });
|
||||
syncIface();
|
||||
const disabled = checkbox("Disabled: keep the subnet and its leases, but don't answer its clients", sn ? sn.disabled : false);
|
||||
const grid = h("div", { class: "form-grid" },
|
||||
field("Name", name),
|
||||
field("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint,
|
||||
field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."),
|
||||
field("Pools", pools, "Ranges handed out to any client: FIRST-LAST, or a prefix. Reserved addresses inside a pool are never handed out to others."),
|
||||
field("Lease time", lease, "12h, 1d, 30m; empty for the default."),
|
||||
field("Options", opts),
|
||||
field("Comment", comment),
|
||||
sn ? [h("label"), disabled] : null);
|
||||
grid.values = () => {
|
||||
const lt = parseSeconds(lease.value);
|
||||
if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m");
|
||||
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), lease_time: lt, options: opts.value(), comment: comment.value };
|
||||
if (sn) v.disabled = disabled.input.checked;
|
||||
return v;
|
||||
};
|
||||
grid.focusFirst = () => (sn ? name : ifaceSel).focus();
|
||||
return grid;
|
||||
}
|
||||
|
||||
// subnetLabel names a subnet: "office (10.0.10.0/24)".
|
||||
export function subnetLabel(s) {
|
||||
return s.name ? s.name + " (" + s.prefix + ")" : s.prefix;
|
||||
}
|
||||
|
||||
// stateBadge shows a lease's state.
|
||||
export function stateText(st) {
|
||||
return { active: "active", expired: "expired", released: "released", declined: "held back" }[st] || st;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Steward</title>
|
||||
<link rel="icon" href="/assets/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><p>Steward's web interface needs JavaScript. The REST API and the <code>steward</code> CLI work without it.</p></noscript>
|
||||
<div id="app"></div>
|
||||
<div id="toasts"></div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
// Package web embeds the web UI: a small single-page application that
|
||||
// uses Steward's REST API for everything it does.
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"mime"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Not in Go's built-in table, and the server sends nosniff.
|
||||
mime.AddExtensionType(".woff2", "font/woff2")
|
||||
}
|
||||
|
||||
//go:embed static
|
||||
var static embed.FS
|
||||
|
||||
// FS returns the web UI files (index.html and assets/).
|
||||
func FS() fs.FS {
|
||||
sub, err := fs.Sub(static, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return sub
|
||||
}
|
||||
Reference in new issue
Block a user