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,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"}
|
||||
}
|
||||
Reference in new issue
Block a user