Build Housecarl 0.1.0: the nftables firewall manager

Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and
services, ordered rules in the early, input, forward and output chains,
port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects,
and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS
clamping, DHCPv6 client, flowtable). Every change is validated, checked
with nft -c and applied in one transaction to Housecarl's own table,
inet housecarl; changes made over the network roll back unless
confirmed, and the confirmed ruleset is loaded at boot by
housecarl-ruleset.service. An empty configuration applies nothing.

Also: hit counters and the drop log (nflog), 'hc import' of an existing
nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web
UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's
certificate through Jarl, Debian packages, docs, unit tests with real
nft 1.1.3, and an e2e test with real traffic (71 checks).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 07:57:14 +01:00
1 parent 54beb0bba3
commit 34292eeebb
118 files changed
+22165 -114

No files matched your search

+292
View File
@@ -0,0 +1,292 @@
package server
import (
"context"
"crypto/subtle"
"errors"
"net/http"
"strings"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/store"
"housecarl/internal/version"
)
const (
sessionCookie = "housecarl_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 housecarl_...'"}
// 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
}
now := time.Now()
if !auth.CheckSecret(secret, t.SecretHash) {
// After a rotation with a grace period the old secret works until
// the grace period ends.
if t.PrevSecretHash == "" || t.PrevExpiresAt == nil || !auth.CheckSecret(secret, t.PrevSecretHash) {
return nil, errors.New("wrong token secret")
}
if now.After(*t.PrevExpiresAt) {
return nil, errors.New("this is the token's old secret, from before it was rotated; its grace period has ended")
}
s.log.Warn("token used with its old secret; give this client the new one before the grace period ends", "token_id", t.ID, "token", t.Username+"/"+t.Name, "ip", ip, "valid_until", t.PrevExpiresAt.Format(time.RFC3339))
}
if t.RevokedAt != nil {
return nil, errors.New("the token was revoked")
}
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
}
if p.Token != nil {
out.TokenID = p.Token.ID
}
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
}
+94
View File
@@ -0,0 +1,94 @@
package server
import (
"context"
"fmt"
"net/http"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/certs"
"housecarl/internal/validate"
)
func (s *Server) certificate(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
out := api.CertificateResponse{Source: "none"}
switch {
case s.cfg.ACME != nil:
st := s.cfg.ACME.Status()
out.Configured, out.Source, out.Status = true, "acme", &st
case s.cfg.TLSCertFile != "":
out.Source = "file"
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) acmeManager() (*certs.Manager, error) {
if s.cfg.ACME == nil {
return nil, conflict("housecarld doesn't obtain its certificate: set tls.acme and jarl in housecarl.yaml (docs/certificates.md)")
}
return s.cfg.ACME, nil
}
// certificateCheck: POST /certificate/check runs the preflight check now
// (it takes up to a few minutes when the record is slow to appear).
func (s *Server) certificateCheck(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
m, err := s.acmeManager()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Minute)
defer cancel()
c := m.Preflight(ctx)
s.eng.Audit(r.Context(), p, "certificate.check", m.Status().Domain, map[bool]string{true: "success", false: "failure"}[c.OK], "")
writeJSON(w, http.StatusOK, c)
return nil
}
// certificateRenew: POST /certificate/renew gets a new certificate now, in
// the background (GET /certificate follows it).
func (s *Server) certificateRenew(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
m, err := s.acmeManager()
if err != nil {
return err
}
m.Kick()
s.eng.Audit(r.Context(), p, "certificate.renew", m.Status().Domain, "success", "")
st := m.Status()
writeJSON(w, http.StatusAccepted, api.CertificateResponse{Configured: true, Source: "acme", Status: &st})
return nil
}
// certificateProblems are the certificate's problems for /status.
func (s *Server) certificateProblems() []validate.Problem {
if s.cfg.ACME == nil {
return nil
}
st := s.cfg.ACME.Status()
obj := "certificate " + st.Domain
var out []validate.Problem
if st.LastError != "" {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_failed", Object: obj,
Message: "getting the web UI's certificate failed: " + st.LastError})
}
if st.NotAfter != nil && time.Until(*st.NotAfter) < 14*24*time.Hour {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_expiring", Object: obj,
Message: fmt.Sprintf("the certificate ends %s and hasn't been renewed", st.NotAfter.Local().Format("2006-01-02 15:04"))})
}
if st.State == "ok" && st.Staging {
out = append(out, validate.Problem{Severity: validate.Warning, Code: "certificate_staging", Object: obj,
Message: "the certificate is from Let's Encrypt's staging service, which browsers don't trust: once it works, set tls.acme.directory: letsencrypt and restart housecarld"})
}
return out
}
+588
View File
@@ -0,0 +1,588 @@
package server
import (
"encoding/json"
"errors"
"net/http"
"sort"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/store"
)
// change applies an edit through the engine and answers it; object picks
// what to return from the new state. A network change starts (or joins)
// the probation.
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.afterChange(r.Context(), p, res)
// POST to a collection creates; POST to an object (move) changes it.
s.respondChange(w, r, res, object, r.Method == http.MethodPost && r.PathValue("ref") == "")
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 pr := s.probationStatus(); res.Applied && (len(pr.Pending) > 0 || pr.Takeovers > 0) {
out.ConfirmBy = &pr.Deadline
}
if object != nil && (res.Applied || res.DryRun) {
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)
}
// kindOf maps the API's plural path names to kinds.
var kindOf = map[string]string{
"zones": model.KindZone, "sets": model.KindSet, "services": model.KindService, "rules": model.KindRule,
"forwards": model.KindForward, "snats": model.KindSNAT, "redirects": model.KindRedirect,
}
func pathKind(r *http.Request) (string, error) {
k, ok := kindOf[r.PathValue("kind")]
if !ok {
return "", &httpError{status: http.StatusNotFound, code: "not_found", msg: "no such API endpoint: " + r.URL.Path}
}
return k, nil
}
func findObject(st *model.State, kind, ref string) (model.Object, error) {
if o := st.Find(kind, ref); o != nil {
return o, nil
}
return nil, notFound("no %s %s", kind, ref)
}
// ---- objects
func (s *Server) listObjects(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
kind, err := pathKind(r)
if err != nil {
return err
}
st := s.eng.State()
out := []any{}
q := r.URL.Query()
for _, o := range st.Objects(kind) {
if c := q.Get("chain"); c != "" {
if rule, ok := o.(*model.Rule); ok && rule.Chain != c {
continue
}
}
out = append(out, o)
}
// Services: the built-in ones too, unless overridden.
if kind == model.KindService && !queryBool(r, "own") {
for _, b := range model.BuiltinServices() {
if mine := st.Find(kind, b.Name); mine == nil {
out = append(out, b)
}
}
sort.SliceStable(out, func(i, j int) bool {
return out[i].(*model.Service).Name < out[j].(*model.Service).Name
})
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) getObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
kind, err := pathKind(r)
if err != nil {
return err
}
st := s.eng.State()
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil && kind == model.KindService {
if b := model.BuiltinService(r.PathValue("ref")); b != nil {
writeJSON(w, http.StatusOK, b)
return nil
}
}
if err != nil {
return err
}
writeJSON(w, http.StatusOK, o)
return nil
}
// decodeObject reads a kind's object from JSON (unknown fields refused).
func decodeObject(kind string, data []byte) (model.Object, error) {
var o model.Object
switch kind {
case model.KindZone:
o = &model.Zone{}
case model.KindSet:
o = &model.AddressSet{}
case model.KindService:
o = &model.Service{}
case model.KindRule:
o = &model.Rule{}
case model.KindForward:
o = &model.PortForward{}
case model.KindSNAT:
o = &model.SourceNAT{}
case model.KindRedirect:
o = &model.Redirect{}
}
dec := json.NewDecoder(strings.NewReader(string(data)))
dec.DisallowUnknownFields()
if err := dec.Decode(o); err != nil {
return nil, badRequest("%s: %v", kind, err)
}
return o, nil
}
// lastPosition is after the last rule of the chain (or NAT rule of the
// kind).
func lastPosition(st *model.State, kind, chain string) float64 {
max := 0.0
for _, o := range st.Objects(kind) {
var pos float64
switch x := o.(type) {
case *model.Rule:
if x.Chain != chain {
continue
}
pos = x.Position
case *model.PortForward:
pos = x.Position
case *model.SourceNAT:
pos = x.Position
case *model.Redirect:
pos = x.Position
}
if pos > max {
max = pos
}
}
return max + 1
}
func setID(o model.Object, id int64) {
switch x := o.(type) {
case *model.Zone:
x.ID = id
case *model.AddressSet:
x.ID = id
case *model.Service:
x.ID = id
case *model.Rule:
x.ID = id
case *model.PortForward:
x.ID = id
case *model.SourceNAT:
x.ID = id
case *model.Redirect:
x.ID = id
}
}
func position(o model.Object) *float64 {
switch x := o.(type) {
case *model.Rule:
return &x.Position
case *model.PortForward:
return &x.Position
case *model.SourceNAT:
return &x.Position
case *model.Redirect:
return &x.Position
}
return nil
}
func chainOf(o model.Object) string {
if r, ok := o.(*model.Rule); ok {
return r.Chain
}
return ""
}
func (s *Server) createObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
if err != nil {
return err
}
var raw json.RawMessage
if err := decodeJSON(r, &raw); err != nil {
return err
}
o, err := decodeObject(kind, raw)
if err != nil {
return err
}
if svc, ok := o.(*model.Service); ok {
svc.Builtin = false
}
if rule, ok := o.(*model.Rule); ok && rule.Action == "" {
rule.Action = model.ActionAccept
}
if f, ok := o.(*model.PortForward); ok && len(f.Protocols) == 0 {
f.Protocols = []string{"tcp"}
}
var id int64
return s.change(w, r, p, "", func(st *model.State, newID func() int64) error {
id = newID()
setID(o, id)
if pos := position(o); pos != nil && *pos == 0 {
*pos = lastPosition(st, kind, chainOf(o))
}
data, _ := json.Marshal(o)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
}
func (s *Server) updateObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
if err != nil {
return err
}
var patch map[string]json.RawMessage
if err := decodeJSON(r, &patch); err != nil {
return err
}
delete(patch, "id")
var id int64
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
id = o.GetID()
// The patch's fields over the object's.
var cur map[string]json.RawMessage
b, _ := json.Marshal(o)
json.Unmarshal(b, &cur)
for k, v := range patch {
if string(v) == "null" {
delete(cur, k)
} else {
cur[k] = v
}
}
merged, _ := json.Marshal(cur)
next, err := decodeObject(kind, merged)
if err != nil {
return err
}
setID(next, id)
data, _ := json.Marshal(next)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
}
func (s *Server) deleteObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
if err != nil {
return err
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
if used := usedBy(st, kind, o); used != "" {
return conflict("%s %s is used by %s; change that first", kind, o.Label(), used)
}
return st.Put(kind, o.GetID(), nil)
}, nil)
}
// usedBy names an object that refers to o (zones, sets and services are
// referred to by name), or "".
func usedBy(st *model.State, kind string, o model.Object) string {
name := o.Label()
has := func(list []string) bool {
for _, x := range list {
if strings.EqualFold(x, name) {
return true
}
}
return false
}
uses := func(m model.Match) bool {
switch kind {
case model.KindZone:
return has(m.InZones) || has(m.OutZones)
case model.KindSet:
return has(m.Sources) || has(m.Destinations)
case model.KindService:
return has(m.Services)
}
return false
}
for _, x := range st.Rules {
if uses(x.Match) {
return x.Label()
}
}
for _, x := range st.SNATs {
if uses(x.Match) {
return x.Label()
}
}
for _, x := range st.Redirects {
if uses(x.Match) {
return x.Label()
}
}
if kind == model.KindSet {
for _, f := range st.Forwards {
if has(f.Sources) || has(f.PublicAddresses) || has(f.HairpinSources) {
return f.Label()
}
}
}
return ""
}
// moveObject reorders a rule or NAT rule: before or after another, or to
// the start or end; a rule can change chain at the same time.
func (s *Server) moveObject(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
kind, err := pathKind(r)
if err != nil {
return err
}
var in api.MoveInput
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 {
o, err := findObject(st, kind, r.PathValue("ref"))
if err != nil {
return err
}
id = o.GetID()
pos := position(o)
if pos == nil {
return badRequest("only rules, port forwards, source NAT rules and redirects have an order")
}
if rule, ok := o.(*model.Rule); ok && in.Chain != "" {
rule.Chain = in.Chain
}
chain := chainOf(o)
// The others in order.
var others []model.Object
for _, x := range st.Objects(kind) {
if x.GetID() != id && chainOf(x) == chain {
others = append(others, x)
}
}
sort.SliceStable(others, func(i, j int) bool { return *position(others[i]) < *position(others[j]) })
place := func(i int) { // before others[i] (i == len: at the end)
switch {
case len(others) == 0:
*pos = 1
case i == 0:
*pos = *position(others[0]) - 1
case i == len(others):
*pos = *position(others[len(others)-1]) + 1
default:
*pos = (*position(others[i-1]) + *position(others[i])) / 2
}
}
index := func(ref int64) (int, error) {
for i, x := range others {
if x.GetID() == ref {
return i, nil
}
}
return 0, badRequest("%d isn't another %s of the same chain", ref, kind)
}
switch {
case in.Before != 0:
i, err := index(in.Before)
if err != nil {
return err
}
place(i)
case in.After != 0:
i, err := index(in.After)
if err != nil {
return err
}
place(i + 1)
case in.To == "start":
place(0)
case in.To == "end" || in.Chain != "":
place(len(others))
default:
return badRequest("say where: before, after, or to start or end")
}
data, _ := json.Marshal(o)
return st.Put(kind, id, data)
}, func(st *model.State) any { return st.ByID(kind, id) })
}
// ---- 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 patch map[string]json.RawMessage
if err := decodeJSON(r, &patch); err != nil {
return err
}
return s.change(w, r, p, "", func(st *model.State, _ func() int64) error {
var cur map[string]json.RawMessage
b, _ := json.Marshal(st.Settings)
json.Unmarshal(b, &cur)
for k, v := range patch {
// Built-ins merge one by one.
if k == "builtins" {
var cb, pb map[string]json.RawMessage
json.Unmarshal(cur[k], &cb)
if err := json.Unmarshal(v, &pb); err != nil {
return badRequest("builtins: %v", err)
}
for bk, bv := range pb {
cb[bk] = bv
}
v, _ = json.Marshal(cb)
}
cur[k] = v
}
merged, _ := json.Marshal(cur)
var next model.Settings
dec := json.NewDecoder(strings.NewReader(string(merged)))
dec.DisallowUnknownFields()
if err := dec.Decode(&next); err != nil {
return badRequest("settings: %v", err)
}
st.Settings = next
return nil
}, func(st *model.State) any { return st.Settings })
}
// ---- the ruleset
func (s *Server) ruleset(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, false); err != nil {
return err
}
rs := s.fw.Render(s.eng.State())
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Write([]byte(rs.Text))
return 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.afterChange(r.Context(), p, res)
s.respondChange(w, r, res, nil, false)
return nil
}
// ---- probation
func (s *Server) getProbation(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.probationStatus())
return nil
}
func (s *Server) confirmChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
n, err := s.confirm(r.Context(), p)
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]int{"confirmed": n})
return nil
}
func (s *Server) rollbackChanges(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
ps := s.probationStatus()
n := len(ps.Pending) + ps.Takeovers
if n > 0 {
s.rollback("rolled back by " + p.Name())
}
writeJSON(w, http.StatusOK, map[string]int{"rolled_back": n})
return nil
}
+147
View File
@@ -0,0 +1,147 @@
package server
import (
"fmt"
"net/http"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/importer"
"housecarl/internal/model"
)
// importNft makes an nftables ruleset Housecarl's configuration
// (internal/importer); ?dry_run=1 shows the result without applying it.
func (s *Server) importNft(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResConfig, true); err != nil {
return err
}
var in api.ImportInput
if err := decodeJSON(r, &in); err != nil {
return err
}
res, err := importer.Import(importer.Input{JSON: in.JSON, Text: in.Text, Defines: in.Defines, Table: in.Table})
if err != nil {
return badRequest("%v", err)
}
cur := s.eng.State()
if !in.Replace && hasConfig(cur) {
return conflict("Housecarl already has a configuration; import with replace (--replace) to replace it")
}
// The importer numbers objects from 1; give them IDs of their own.
if err := s.renumber(r, res.State); err != nil {
return err
}
opts := engine.Options{DryRun: queryBool(r, "dry_run"), Via: via(r, p)}
er, err := s.eng.Replace(r.Context(), p, "import from nftables ("+tableName(in.Table)+")", opts, res.State)
out := api.ImportResult{State: res.State, Notes: res.Notes, Raw: res.Raw}
if out.Notes == nil {
out.Notes = []string{}
}
if out.Raw == nil {
out.Raw = []string{}
}
if err != nil {
s.writeErr(w, r, err, out)
return nil
}
s.afterChange(r.Context(), p, er)
out.MutationResponse = api.MutationResponse{Applied: er.Applied, DryRun: er.DryRun, HistoryID: er.HistoryID, Changes: er.Changes, Warnings: er.Warnings}
if out.Changes == nil {
out.Changes = []model.Change{}
}
if pr := s.probationStatus(); er.Applied && len(pr.Pending) > 0 {
out.ConfirmBy = &pr.Deadline
}
writeJSON(w, http.StatusOK, out)
return nil
}
func tableName(t string) string {
if t == "" {
return "the ruleset"
}
return t
}
func hasConfig(st *model.State) bool {
for _, k := range model.Kinds {
if len(st.Objects(k)) > 0 {
return true
}
}
return false
}
// renumber gives every object of an imported state a fresh ID.
func (s *Server) renumber(r *http.Request, st *model.State) error {
n := 0
for _, k := range model.Kinds {
n += len(st.Objects(k))
}
if n == 0 {
return nil
}
ids, err := s.store.AllocateIDs(r.Context(), n)
if err != nil {
return err
}
i := 0
for _, k := range model.Kinds {
for _, o := range st.Objects(k) {
setID(o, ids[i])
i++
}
}
return nil
}
// takeover deletes the tables Housecarl replaces (the ruleset imported).
// Over the network it is on probation: unless confirmed, the tables come
// back.
func (s *Server) takeover(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireAdmin(p); err != nil {
return err
}
var in api.TakeoverInput
if err := decodeJSON(r, &in); err != nil {
return err
}
if len(in.Tables) == 0 {
return badRequest("name the tables to delete (\"inet main\")")
}
var done []string
for _, t := range in.Tables {
fam, name, ok := strings.Cut(strings.TrimSpace(t), " ")
if !ok || name == "" {
return badRequest("%q: write a table as FAMILY NAME (\"inet main\")", t)
}
if fam == "inet" && name == "housecarl" {
return badRequest("that is Housecarl's own table")
}
script, err := s.fw.TableText(r.Context(), fam, name)
if err != nil {
return notFound("there is no table %s %s", fam, name)
}
if queryBool(r, "dry_run") {
done = append(done, t)
continue
}
if err := s.fw.DeleteTable(r.Context(), fam, name); err != nil {
return fmt.Errorf("deleting table %s: %w", t, err)
}
s.startProbation(r.Context(), p, 0, script)
s.eng.Audit(r.Context(), p, "takeover", t, "success", "")
done = append(done, t)
}
msg := "deleted " + strings.Join(done, ", ")
if queryBool(r, "dry_run") {
msg = "would delete " + strings.Join(done, ", ")
} else if pr := s.probationStatus(); pr.Takeovers > 0 {
msg += fmt.Sprintf("; confirm with 'hc confirm' within %ds, or they come back", pr.SecondsLeft)
}
writeJSON(w, http.StatusOK, map[string]any{"deleted": done, "message": msg})
return nil
}
+282
View File
@@ -0,0 +1,282 @@
package server
import (
"net/http"
"sort"
"strings"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/model"
"housecarl/internal/render"
"housecarl/internal/store"
"housecarl/internal/validate"
"housecarl/internal/version"
)
// ---- status
func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if !p.Can(auth.ResConfig, false) && !p.Can(auth.ResLog, false) {
return forbidden("permission denied: this needs config:read or log:read")
}
st := s.eng.State()
out := api.Status{Build: version.Info(), Started: s.started, Problems: s.eng.Problems(), Probation: s.probationStatus(),
Interfaces: s.hostInterfaces(st), Counts: map[string]int{}, OtherTables: []string{}}
if out.Problems == nil {
out.Problems = []validate.Problem{}
}
if p.IsAdmin() {
out.Database = s.store.Path()
}
for _, k := range model.Kinds {
out.Counts[k] = len(st.Objects(k))
}
if _, err := s.fw.Table(r.Context()); err != nil && st.Empty() {
out.Unconfigured = true
} else if err != nil {
out.ApplyError = err.Error()
} else {
out.Applied = true
}
if data, err := s.fw.ListRuleset(r.Context()); err == nil {
out.OtherTables = otherTables(data)
}
writeJSON(w, http.StatusOK, out)
return nil
}
// hostInterfaces lists the machine's interfaces with their zones.
func (s *Server) hostInterfaces(st *model.State) []api.HostInterface {
out := []api.HostInterface{}
if s.ifaces == nil {
return out
}
all, err := s.ifaces.All()
if err != nil {
return out
}
for name, ps := range all {
hi := api.HostInterface{Name: name, Addresses: []string{}, Up: s.ifaces.Up(name), Zone: zoneOf(st, name)}
for _, a := range ps {
hi.Addresses = append(hi.Addresses, a.String())
}
out = append(out, hi)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// zoneOf names the zone an interface is in: an exact name, else the
// longest matching wildcard.
func zoneOf(st *model.State, name string) string {
best, bestLen := "", -1
for _, z := range st.Zones {
for _, i := range z.Interfaces {
if i == name {
return z.Name
}
if p, ok := strings.CutSuffix(i, "*"); ok && strings.HasPrefix(name, p) && len(p) > bestLen {
best, bestLen = z.Name, len(p)
}
}
}
return best
}
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
}
writeJSON(w, http.StatusOK, s.hostInterfaces(s.eng.State()))
return nil
}
func (s *Server) counters(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
t, err := s.fw.Table(r.Context())
if err != nil && s.eng.State().Empty() {
// Nothing applied yet: nothing counted.
writeJSON(w, http.StatusOK, api.Counters{Since: s.started, Counters: map[string]api.Counter{}})
return nil
}
if err != nil {
return err
}
out := api.Counters{Since: t.Since, Counters: map[string]api.Counter{}}
for k, c := range t.Counters {
out.Counters[k] = api.Counter{Packets: c.Packets, Bytes: c.Bytes}
}
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
}
func (s *Server) catalog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
roles := map[string][]string{}
for n, gs := range auth.Roles {
roles[n] = grantStrings(gs)
}
writeJSON(w, http.StatusOK, map[string]any{
"services": model.BuiltinServices(), "chains": model.Chains, "actions": model.Actions,
"roles": roles, "log_group": render.LogGroup,
})
return nil
}
// ---- dynamic sets' entries
func (s *Server) dynamicSet(r *http.Request, p *auth.Principal, write bool) (*model.AddressSet, error) {
name := r.PathValue("ref")
if !p.CanSet(name, write) {
access := "read"
if write {
access = "write"
}
return nil, forbidden("permission denied: this needs sets:%s for %s", access, name)
}
set := s.eng.State().Set(name)
if set == nil {
return nil, notFound("no set %s", name)
}
if !set.Dynamic {
return nil, badRequest("set %s isn't dynamic: change its entries with PATCH /sets/%s", set.Name, set.Name)
}
return set, nil
}
func (s *Server) listElements(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
set, err := s.dynamicSet(r, p, false)
if err != nil {
return err
}
t, err := s.fw.Table(r.Context())
if err != nil {
return err
}
out := []api.Element{}
for _, e := range t.Elements[set.Name] {
out = append(out, api.Element{Address: e.Addr, Timeout: e.Timeout})
}
writeJSON(w, http.StatusOK, out)
return nil
}
func (s *Server) addElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
set, err := s.dynamicSet(r, p, true)
if err != nil {
return err
}
var in api.Element
if err := decodeJSON(r, &in); err != nil {
return err
}
a, err := model.ParseAddress(in.Address)
if err != nil || !a.Prefix.IsSingleIP() {
return badRequest("a dynamic set takes single addresses (%q)", in.Address)
}
if in.Timeout < 0 || in.Timeout > 366*24*3600 {
return badRequest("the timeout is in seconds, up to a year")
}
if err := s.fw.AddElement(r.Context(), set.Name, a.String(), in.Timeout); err != nil {
return err
}
s.eng.Audit(r.Context(), p, "set.add", set.Name+" "+a.String(), "success", "")
writeJSON(w, http.StatusCreated, api.Element{Address: a.String(), Timeout: in.Timeout})
return nil
}
func (s *Server) deleteElement(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
set, err := s.dynamicSet(r, p, true)
if err != nil {
return err
}
a, err := model.ParseAddress(r.PathValue("addr"))
if err != nil {
return badRequest("%v", err)
}
if err := s.fw.DeleteElement(r.Context(), set.Name, a.String()); err != nil {
return notFound("%s isn't in set %s", a, set.Name)
}
s.eng.Audit(r.Context(), p, "set.delete", set.Name+" "+a.String(), "success", "")
w.WriteHeader(http.StatusNoContent)
return nil
}
// ---- the log
// logRing keeps the latest logged packets.
type logRing struct {
mu sync.Mutex
max int
list []api.LogEntry
}
func newLogRing(max int) *logRing { return &logRing{max: max} }
// Add records a logged packet.
func (l *logRing) Add(e api.LogEntry) {
l.mu.Lock()
defer l.mu.Unlock()
l.list = append(l.list, e)
if len(l.list) > l.max {
l.list = append([]api.LogEntry(nil), l.list[len(l.list)-l.max:]...)
}
}
// Latest returns up to n entries, newest first, after a time.
func (l *logRing) Latest(n int, after time.Time, keep func(api.LogEntry) bool) []api.LogEntry {
l.mu.Lock()
defer l.mu.Unlock()
out := []api.LogEntry{}
for i := len(l.list) - 1; i >= 0 && (n <= 0 || len(out) < n); i-- {
e := l.list[i]
if !e.Time.After(after) {
break
}
if keep == nil || keep(e) {
out = append(out, e)
}
}
return out
}
// LogPacket records a packet the firewall logged (internal/nflog calls
// it).
func (s *Server) LogPacket(e api.LogEntry) { s.logs.Add(e) }
func (s *Server) listLog(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := require(p, auth.ResLog, false); err != nil {
return err
}
q := r.URL.Query()
var after time.Time
if v := q.Get("after"); v != "" {
after, _ = time.Parse(time.RFC3339Nano, v)
}
search := strings.ToLower(q.Get("q"))
writeJSON(w, http.StatusOK, s.logs.Latest(queryInt(r, "limit", 200), after, func(e api.LogEntry) bool {
if search == "" {
return true
}
return strings.Contains(strings.ToLower(e.Prefix+" "+e.Source+" "+e.Dest+" "+e.In+" "+e.Out+" "+e.Protocol), search)
}))
return nil
}
+484
View File
@@ -0,0 +1,484 @@
package server
import (
"errors"
"net/http"
"strings"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/config"
"housecarl/internal/model"
"housecarl/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 Housecarl user; use 'hc 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 Housecarl user and has no tokens; make the token for a user (hc 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
}
// maxRotationGrace is the longest an old secret may keep working.
const maxRotationGrace = 7 * 24 * time.Hour
// rotateToken: POST /tokens/{id}/rotate gives a token a new secret. It
// keeps its ID, name, user and grants, so only the programs that use it
// need the new secret. The old secret stops at once, or after the grace
// period. A token that expires gets its lifetime again, from now.
func (s *Server) rotateToken(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
id, err := pathID(r, "id")
if err != nil {
return err
}
var in api.TokenRotation
if err := decodeOptional(r, &in); 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)
}
// A restricted token may rotate itself and tokens no wider than
// itself, but not a wider one: it would learn that token's secret.
if !p.IsAdmin() && p.Token != nil && p.Token.Grants != nil && p.Token.ID != t.ID && (t.Grants == nil || !auth.Narrower(t.Grants, p.Token.Grants)) {
return forbidden("a restricted token can only rotate itself and tokens with a subset of its own grants")
}
if t.RevokedAt != nil {
return conflict("token %d is revoked; make a new one", id)
}
now := time.Now()
var exp *time.Time
switch {
case in.ExpiresIn != "":
if in.NoExpiry {
return badRequest("no_expiry can't be combined with expires_in")
}
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)
}
e := now.Add(d)
exp = &e
case in.NoExpiry:
case t.ExpiresAt != nil:
if life := t.ExpiresAt.Sub(t.IssuedAt()); life > 0 {
e := now.Add(life)
exp = &e
}
}
var prevUntil *time.Time
if in.Grace != "" {
g, err := config.ParseDuration(in.Grace)
if err != nil || g < 0 || g > maxRotationGrace {
return badRequest("invalid grace %q: use a duration up to 7d, e.g. 30m, 24h or 2d", in.Grace)
}
until := now.Add(g)
if t.ExpiresAt != nil && t.ExpiresAt.Before(until) {
until = *t.ExpiresAt // the old secret never outlives its token
}
if g > 0 && until.After(now) {
prevUntil = &until
}
}
secret, hash := auth.RotateToken(t.PublicID)
if err := s.store.RotateToken(r.Context(), t.ID, hash, exp, prevUntil); err != nil {
return err
}
if t, err = s.store.GetToken(r.Context(), id); err != nil {
return err
}
detail := "the old secret stops now"
if prevUntil != nil {
detail = "the old secret works until " + prevUntil.UTC().Format(time.RFC3339)
}
s.eng.Audit(r.Context(), p, "token.rotate", "token "+t.Username+"/"+t.Name, "success", detail)
out := presentToken(t)
out.Secret = secret
writeJSON(w, http.StatusOK, out)
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)
}
+178
View File
@@ -0,0 +1,178 @@
package server
import (
"context"
"encoding/json"
"fmt"
"sync"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
)
// A change made over the network is applied on probation: unless it is
// confirmed in time (POST /confirm, hc confirm, the web UI's banner), it is
// undone, so a change that cuts the user off from the firewall undoes
// itself. Changes through the local socket (root on the firewall) need no
// confirmation, but join a probation already running, so a rollback undoes
// everything since the last confirmed state. The pending entries are kept
// in the database: housecarld starting with any rolls them back.
// Housecarl adds no rule of its own to keep the API reachable.
const probationKey = "probation"
type probation struct {
mu sync.Mutex
pending []int64
deadline time.Time
timer *time.Timer
// restores are nft scripts that bring back tables a takeover deleted.
restores []string
}
// system is the principal of rollbacks.
var system = &auth.Principal{User: &model.User{Username: "housecarl"}, Method: "system"}
type savedProbation struct {
Pending []int64 `json:"pending"`
Deadline time.Time `json:"deadline"`
Restores []string `json:"restores,omitempty"`
}
func (s *Server) saveProbation(ctx context.Context) {
b, _ := json.Marshal(savedProbation{Pending: s.probation.pending, Deadline: s.probation.deadline, Restores: s.probation.restores})
if err := s.store.SetMeta(ctx, probationKey, string(b)); err != nil {
s.log.Error("saving the probation failed", "err", err)
}
}
// afterChange is called after every applied change.
func (s *Server) afterChange(ctx context.Context, p *auth.Principal, res *engine.Result) {
if res == nil || !res.Applied || res.HistoryID == 0 {
return
}
s.startProbation(ctx, p, res.HistoryID, "")
}
// startProbation puts a change (a history entry, or a takeover's restore
// script) on probation, or, through the socket with nothing pending,
// makes it permanent at once.
func (s *Server) startProbation(ctx context.Context, p *auth.Principal, historyID int64, restore string) {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
if p.Method == "socket" && len(pr.pending) == 0 && len(pr.restores) == 0 {
s.saveBoot()
return
}
if historyID != 0 {
pr.pending = append(pr.pending, historyID)
}
if restore != "" {
pr.restores = append(pr.restores, restore)
}
if p.Method != "socket" || pr.deadline.IsZero() {
d := time.Duration(s.eng.State().Settings.Probation()) * time.Second
if s.probationFor > 0 {
d = s.probationFor
}
pr.deadline = s.nowFn().Add(d)
if pr.timer != nil {
pr.timer.Stop()
}
pr.timer = time.AfterFunc(d, func() { s.rollback("not confirmed in time") })
}
s.saveProbation(ctx)
}
func (s *Server) saveBoot() {
if err := s.fw.SaveBoot(s.eng.State()); err != nil {
s.log.Error("saving the ruleset for the next boot failed", "err", err)
}
}
// probationStatus describes what is pending.
func (s *Server) probationStatus() api.Probation {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
out := api.Probation{Pending: append([]int64{}, pr.pending...), Takeovers: len(pr.restores)}
if len(pr.pending) > 0 || len(pr.restores) > 0 {
out.Deadline = pr.deadline
out.SecondsLeft = int(pr.deadline.Sub(s.nowFn()).Seconds() + 0.5)
if out.SecondsLeft < 0 {
out.SecondsLeft = 0
}
}
return out
}
// confirm makes the pending changes permanent.
func (s *Server) confirm(ctx context.Context, p *auth.Principal) (int, error) {
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
n := len(pr.pending) + len(pr.restores)
if n == 0 {
return 0, nil
}
if pr.timer != nil {
pr.timer.Stop()
}
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
s.saveProbation(ctx)
s.saveBoot()
s.eng.Audit(ctx, p, "config.confirm", "", "success", fmt.Sprintf("%d changes confirmed", n))
return n, nil
}
// rollback undoes the pending changes, newest first.
func (s *Server) rollback(why string) {
ctx := context.Background()
pr := &s.probation
pr.mu.Lock()
defer pr.mu.Unlock()
pending, restores := pr.pending, pr.restores
pr.pending, pr.restores, pr.deadline, pr.timer = nil, nil, time.Time{}, nil
for i := len(restores) - 1; i >= 0; i-- {
if err := s.fw.ApplyScript(ctx, restores[i]); err != nil {
s.log.Error("bringing back a table a takeover deleted failed", "err", err)
s.eng.Audit(ctx, system, "takeover.rollback", "", "failure", err.Error())
} else {
s.eng.Audit(ctx, system, "takeover.rollback", "", "success", why)
s.log.Warn("brought back a table an unconfirmed takeover deleted", "why", why)
}
}
for i := len(pending) - 1; i >= 0; i-- {
id := pending[i]
if _, err := s.eng.Revert(ctx, system, id, engine.Options{Via: "rollback"}); err != nil {
s.log.Error("rolling back an unconfirmed change failed", "history", id, "err", err)
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "failure", err.Error())
continue
}
s.eng.Audit(ctx, system, "config.rollback", fmt.Sprintf("history %d", id), "success", why)
s.log.Warn("rolled back an unconfirmed change", "history", id, "why", why)
}
s.saveProbation(ctx)
}
// ResumeProbation rolls back what was pending when housecarld stopped. It
// returns whether anything was rolled back.
func (s *Server) ResumeProbation(ctx context.Context) bool {
v, err := s.store.GetMeta(ctx, probationKey)
if err != nil || v == "" {
return false
}
var saved savedProbation
if json.Unmarshal([]byte(v), &saved) != nil || len(saved.Pending) == 0 && len(saved.Restores) == 0 {
return false
}
s.probation.mu.Lock()
s.probation.pending, s.probation.restores = saved.Pending, saved.Restores
s.probation.mu.Unlock()
s.rollback("housecarld restarted before they were confirmed")
return true
}
+192
View File
@@ -0,0 +1,192 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/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, engine.KindFirewall:
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(), "housecarl-cli"):
return "cli"
case p.Method == "session":
return "web"
}
return "api"
}
+123
View File
@@ -0,0 +1,123 @@
package server
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"io/fs"
"net/http"
"strings"
"time"
"housecarl/internal/api"
"housecarl/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("POST "+p+"/tokens/{id}/rotate", s.authed(s.rotateToken))
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+"/sets/{ref}/elements", s.authed(s.listElements))
m.HandleFunc("POST "+p+"/sets/{ref}/elements", s.authed(s.addElement))
m.HandleFunc("DELETE "+p+"/sets/{ref}/elements/{addr}", s.authed(s.deleteElement))
m.HandleFunc("GET "+p+"/{kind}", s.authed(s.listObjects))
m.HandleFunc("POST "+p+"/{kind}", s.authed(s.createObject))
m.HandleFunc("GET "+p+"/{kind}/{ref}", s.authed(s.getObject))
m.HandleFunc("PATCH "+p+"/{kind}/{ref}", s.authed(s.updateObject))
m.HandleFunc("DELETE "+p+"/{kind}/{ref}", s.authed(s.deleteObject))
m.HandleFunc("POST "+p+"/{kind}/{ref}/move", s.authed(s.moveObject))
m.HandleFunc("GET "+p+"/ruleset", s.authed(s.ruleset))
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("GET "+p+"/probation", s.authed(s.getProbation))
m.HandleFunc("POST "+p+"/confirm", s.authed(s.confirmChanges))
m.HandleFunc("POST "+p+"/rollback", s.authed(s.rollbackChanges))
m.HandleFunc("POST "+p+"/import/nft", s.authed(s.importNft))
m.HandleFunc("POST "+p+"/takeover", s.authed(s.takeover))
m.HandleFunc("GET "+p+"/catalog", s.authed(s.catalog))
m.HandleFunc("GET "+p+"/interfaces", s.authed(s.interfaces))
// Status, counters, the log and audit
m.HandleFunc("GET "+p+"/status", s.authed(s.status))
m.HandleFunc("GET "+p+"/counters", s.authed(s.counters))
m.HandleFunc("GET "+p+"/log", s.authed(s.listLog))
m.HandleFunc("GET "+p+"/audit", s.authed(s.listAudit))
m.HandleFunc("GET "+p+"/certificate", s.authed(s.certificate))
m.HandleFunc("POST "+p+"/certificate/check", s.authed(s.certificateCheck))
m.HandleFunc("POST "+p+"/certificate/renew", s.authed(s.certificateRenew))
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
}
+26
View File
@@ -0,0 +1,26 @@
package server
import "encoding/json"
// otherTables lists the tables in an `nft -j list ruleset` other than
// Housecarl's ("inet main").
func otherTables(data []byte) []string {
var doc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
out := []string{}
if json.Unmarshal(data, &doc) != nil {
return out
}
for _, item := range doc.Nftables {
raw, ok := item["table"]
if !ok {
continue
}
var t struct{ Family, Name string }
if json.Unmarshal(raw, &t) == nil && !(t.Family == "inet" && t.Name == "housecarl") {
out = append(out, t.Family+" "+t.Name)
}
}
return out
}
+470
View File
@@ -0,0 +1,470 @@
// Package server is Housecarl'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"
"housecarl/internal/auth"
"housecarl/internal/certs"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/nft"
"housecarl/internal/render"
"housecarl/internal/store"
"housecarl/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
// ACME keeps the certificate housecarld obtains itself (tls.acme; nil
// when off).
ACME *certs.Manager
}
// Interfaces lists the host's interfaces with their addresses, and
// whether each is up.
type Interfaces interface {
All() (map[string][]netip.Prefix, error)
Up(name string) bool
}
// Firewall is what the server needs from the running firewall beyond the
// engine (internal/nft.Firewall).
type Firewall interface {
Render(st *model.State) *render.Ruleset
Table(ctx context.Context) (*nft.Table, error)
AddElement(ctx context.Context, set, addr string, timeout int) error
DeleteElement(ctx context.Context, set, addr string) error
ListRuleset(ctx context.Context) ([]byte, error)
DeleteTable(ctx context.Context, family, name string) error
TableText(ctx context.Context, family, name string) (string, error)
ApplyScript(ctx context.Context, script string) error
SaveBoot(st *model.State) error
}
// Server is the HTTP server.
type Server struct {
cfg Config
eng *engine.Engine
fw Firewall
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
probation probation
logs *logRing
nowFn func() time.Time
// probationFor overrides the settings' probation (tests).
probationFor time.Duration
}
// New makes a server. web holds the web UI (nil: API only).
func New(cfg Config, eng *engine.Engine, fw Firewall, 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, fw: fw, ifaces: ifaces, store: eng.Store(), log: log, mux: http.NewServeMux(), limiter: newRateLimiter(),
web: web, started: time.Now(), logs: newLogRing(2000), nowFn: 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")
}
// With a certificate (tls.cert_file, or one housecarld obtains, which
// may turn up later), each listener serves HTTPS and plain HTTP side
// by side (sniff.go); plain HTTP is redirected once there is one.
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
}
if s.certs != nil || s.cfg.ACME != nil {
tlsCfg = &tls.Config{MinVersion: tls.VersionTLS12, GetCertificate: s.webCertificate, 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 housecarl.service grants)", addr, err)
}
return fmt.Errorf("listening on %s: %w", addr, err)
}
listeners = append(listeners, ln)
}
for i, ln := range listeners {
handler := s.Handler()
if tlsCfg != nil {
handler = s.requireHTTPS(handler)
}
srv := &http.Server{
Handler: 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 {
if s.httpsAvailable() {
scheme = "https"
}
ln = newSniffListener(ln, tlsCfg)
}
if !s.httpsAvailable() && s.cfg.ACME == nil {
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", "Housecarl/"+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 Housecarl over HTTPS: TLS to
// housecarld, 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")
}
+540
View File
@@ -0,0 +1,540 @@
package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/cookiejar"
"net/http/httptest"
"net/netip"
"os"
"strings"
"sync"
"testing"
"time"
"housecarl/internal/api"
"housecarl/internal/auth"
"housecarl/internal/engine"
"housecarl/internal/model"
"housecarl/internal/nft"
"housecarl/internal/render"
"housecarl/internal/store"
)
// fakeFW stands in for nftables: it records the applied states and keeps
// dynamic sets' entries.
type fakeFW struct {
mu sync.Mutex
applied []*model.State
boot *model.State
elements map[string][]render.Element
applyErr error
deleted []string
restored []string
}
func (f *fakeFW) Check(_ context.Context, st *model.State) error { return nil }
func (f *fakeFW) Apply(_ context.Context, st *model.State) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.applyErr != nil {
return f.applyErr
}
f.applied = append(f.applied, st.Clone())
return nil
}
func (f *fakeFW) last() *model.State {
f.mu.Lock()
defer f.mu.Unlock()
return f.applied[len(f.applied)-1]
}
func (f *fakeFW) Render(st *model.State) *render.Ruleset { return render.Render(st, render.Options{}) }
func (f *fakeFW) Table(context.Context) (*nft.Table, error) {
f.mu.Lock()
defer f.mu.Unlock()
if len(f.applied) == 0 {
return nil, errors.New("No such file or directory: list table inet housecarl")
}
return &nft.Table{Counters: map[string]nft.Counter{"r1": {Packets: 3, Bytes: 180}}, Elements: f.elements}, nil
}
func (f *fakeFW) AddElement(_ context.Context, set, addr string, timeout int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.elements[set] = append(f.elements[set], render.Element{Addr: addr, Timeout: timeout})
return nil
}
func (f *fakeFW) DeleteElement(_ context.Context, set, addr string) error {
f.mu.Lock()
defer f.mu.Unlock()
keep := f.elements[set][:0]
for _, e := range f.elements[set] {
if e.Addr != addr {
keep = append(keep, e)
}
}
f.elements[set] = keep
return nil
}
func (f *fakeFW) ListRuleset(context.Context) ([]byte, error) {
return []byte(`{"nftables":[{"table":{"family":"inet","name":"main"}},{"table":{"family":"inet","name":"housecarl"}}]}`), nil
}
func (f *fakeFW) DeleteTable(_ context.Context, fam, name string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.deleted = append(f.deleted, fam+" "+name)
return nil
}
func (f *fakeFW) TableText(_ context.Context, fam, name string) (string, error) {
return "table " + fam + " " + name + " {}", nil
}
func (f *fakeFW) ApplyScript(_ context.Context, script string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.restored = append(f.restored, script)
return nil
}
func (f *fakeFW) SaveBoot(st *model.State) error {
f.mu.Lock()
defer f.mu.Unlock()
f.boot = st.Clone()
return nil
}
type hostIfaces map[string][]netip.Prefix
func (h hostIfaces) All() (map[string][]netip.Prefix, error) { return h, nil }
func (h hostIfaces) Up(string) bool { return true }
type env struct {
t *testing.T
ts *httptest.Server
srv *Server
eng *engine.Engine
fw *fakeFW
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)
}
fw := &fakeFW{elements: map[string][]render.Element{}}
eng.Firewall = fw
ifs := hostIfaces{"wan": {netip.MustParsePrefix("217.155.8.194/32")}, "eth1": {netip.MustParsePrefix("10.0.10.1/24")}}
srv := New(Config{}, eng, fw, ifs, nil, nil)
ts := httptest.NewServer(srv.Handler())
t.Cleanup(ts.Close)
return &env{t: t, ts: ts, srv: srv, eng: eng, fw: fw, 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 itoa(n int64) string { b, _ := json.Marshal(n); return string(b) }
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 := `{"name":"lan","interfaces":["eth1"]}`
if r := post("/zones", body, ""); r.StatusCode != 403 {
t.Errorf("change without CSRF token: %d", r.StatusCode)
}
if r := post("/zones", 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" || h[0].Description != "create zone lan" {
t.Errorf("history %+v", h)
}
}
func TestObjectsFlow(t *testing.T) {
e := newEnv(t)
var stat api.Status
if e.call(e.token, "GET", "/status", nil, &stat); !stat.Unconfigured || stat.Applied || stat.ApplyError != "" {
t.Errorf("empty configuration: %+v", stat)
}
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr); code != 201 || !mr.Applied {
t.Fatalf("zone: %d %+v", code, mr)
}
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
if code := e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24", "2a02:8013:7f3d:10::0/64"}}, &mr); code != 201 {
t.Fatalf("set: %d %+v", code, mr)
}
var set model.AddressSet
json.Unmarshal(mr.Object, &set)
if set.Entries[1] != "2a02:8013:7f3d:10::/64" {
t.Errorf("set entries not normalized: %v", set.Entries)
}
// Rules go to the end of their chain.
var ids []int64
for _, svc := range []string{"ssh", "dns", "https"} {
var r model.Rule
e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Sources: []string{"trusted"}, Services: []string{svc}}}, &mr)
json.Unmarshal(mr.Object, &r)
if r.Action != "accept" {
t.Errorf("default action %q", r.Action)
}
ids = append(ids, r.ID)
}
// Move https first; PATCH changes only the given fields.
if code := e.call(e.token, "POST", "/rules/"+itoa(ids[2])+"/move", api.MoveInput{To: "start"}, nil); code != 200 {
t.Errorf("move: %d", code)
}
if code := e.call(e.token, "PATCH", "/rules/"+itoa(ids[0]), map[string]any{"comment": "admins", "log": true}, &mr); code != 200 {
t.Errorf("patch: %d %+v", code, mr)
}
var rules []model.Rule
e.call(e.token, "GET", "/rules?chain=input", nil, &rules)
if len(rules) != 3 || rules[0].ID != ids[2] || rules[1].Comment != "admins" || !rules[1].Log || rules[1].Sources[0] != "trusted" {
t.Errorf("rules after move and patch: %+v", rules)
}
// Validation: an unknown service, refused with its code.
var eb api.ErrorBody
if code := e.call(e.token, "POST", "/rules", model.Rule{Chain: "input", Match: model.Match{Services: []string{"gopher"}}}, &eb); code != 422 || eb.Error.Problems[0].Code != "unknown_service" {
t.Errorf("unknown service: %d %+v", code, eb)
}
// Unknown fields are refused.
if code := e.call(e.token, "POST", "/zones", map[string]any{"name": "x", "interfaces": []string{"x"}, "colour": "red"}, &eb); code != 400 {
t.Errorf("unknown field: %d", code)
}
// A set in use can't be deleted.
if code := e.call(e.token, "DELETE", "/sets/trusted", nil, &eb); code != 409 || !strings.Contains(eb.Error.Message, "used by input") {
t.Errorf("deleting a used set: %d %+v", code, eb)
}
// Services list includes the built-in ones.
var svcs []model.Service
e.call(e.token, "GET", "/services", nil, &svcs)
found := false
for _, s := range svcs {
found = found || s.Name == "ssh" && s.Builtin
}
if !found {
t.Errorf("no built-in ssh in %+v", svcs)
}
// Settings merge, built-ins one by one.
if code := e.call(e.token, "PATCH", "/settings", map[string]any{"builtins": map[string]bool{"mss_clamp": true}, "own_prefixes": []string{"2a02:8013:7f3d::/48"}}, &mr); code != 200 {
t.Fatalf("settings: %d %+v", code, mr)
}
s := e.eng.State().Settings
if !s.Builtins.MSSClamp || !s.Builtins.Established || s.OwnPrefixes[0] != "2a02:8013:7f3d::/48" {
t.Errorf("settings %+v", s)
}
// The firewall got the latest state; the ruleset is there to see.
if len(e.fw.last().Rules) != 3 {
t.Errorf("applied %+v", e.fw.last().Rules)
}
req, _ := http.NewRequest("GET", e.ts.URL+api.Prefix+"/ruleset", nil)
req.Header.Set("Authorization", "Bearer "+e.token)
resp, _ := http.DefaultClient.Do(req)
b, _ := io.ReadAll(resp.Body)
if !strings.Contains(string(b), "ip6 saddr @trusted_v6 udp dport 53 counter accept") {
t.Errorf("ruleset:\n%s", b)
}
var st api.Status
if code := e.call(e.token, "GET", "/status", nil, &st); code != 200 || st.Counts["rule"] != 3 || len(st.OtherTables) != 1 || st.OtherTables[0] != "inet main" {
t.Errorf("status: %d %+v", code, st)
}
var ifs []api.HostInterface
e.call(e.token, "GET", "/interfaces", nil, &ifs)
if len(ifs) != 2 || ifs[0].Name != "eth1" || ifs[0].Zone != "lan" {
t.Errorf("interfaces %+v", ifs)
}
}
func TestProbation(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 300 * time.Millisecond
ctx := context.Background()
// A network change: pending, then rolled back by itself.
var mr api.MutationResponse
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, &mr)
var pr api.Probation
e.call(e.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 1 || pr.Pending[0] != mr.HistoryID || pr.SecondsLeft > 1 {
t.Fatalf("probation %+v", pr)
}
if v, _ := e.srv.store.GetMeta(ctx, probationKey); !strings.Contains(v, itoa(mr.HistoryID)) {
t.Errorf("probation not saved: %q", v)
}
time.Sleep(700 * time.Millisecond)
if len(e.eng.State().Zones) != 0 || len(e.fw.last().Zones) != 0 {
t.Errorf("not rolled back: state %+v applied %+v", e.eng.State().Zones, e.fw.last().Zones)
}
e.call(e.token, "GET", "/probation", nil, &pr)
if len(pr.Pending) != 0 {
t.Errorf("still pending: %+v", pr)
}
// Confirmed in time: kept, and saved for the boot.
e.srv.probationFor = 5 * time.Second
e.call(e.token, "POST", "/zones", model.Zone{Name: "wan", Interfaces: []string{"wan"}, External: true}, nil)
e.call(e.token, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, nil)
var conf map[string]int
if code := e.call(e.token, "POST", "/confirm", nil, &conf); code != 200 || conf["confirmed"] != 2 {
t.Errorf("confirm: %d %+v", code, conf)
}
if len(e.eng.State().Zones) != 2 || e.fw.boot == nil || len(e.fw.boot.Zones) != 2 {
t.Errorf("after confirm: state %+v boot %+v", e.eng.State().Zones, e.fw.boot)
}
// Through the socket: no probation, saved for the boot at once.
sock := &auth.Principal{User: &model.User{Username: "root", Admin: true}, Method: "socket"}
res, err := e.eng.Apply(ctx, sock, "", engine.Options{Via: "cli"}, func(st *model.State, newID func() int64) error {
st.Settings.ForwardPolicy = model.PolicyAccept
return nil
})
if err != nil {
t.Fatal(err)
}
e.srv.afterChange(ctx, sock, res)
if len(e.srv.probationStatus().Pending) != 0 || e.fw.boot.Settings.ForwardPolicy != model.PolicyAccept {
t.Errorf("socket change: %+v boot %s", e.srv.probationStatus(), e.fw.boot.Settings.ForwardPolicy)
}
// Pending when housecarld stops: rolled back when it starts.
e.srv.probationFor = time.Hour
e.call(e.token, "PATCH", "/zones/lan", map[string]any{"interfaces": []string{"eth1", "eth2"}}, nil)
srv2 := New(Config{}, e.eng, e.fw, nil, nil, nil)
if !srv2.ResumeProbation(ctx) || len(e.eng.State().Zone("lan").Interfaces) != 1 {
t.Errorf("restart rollback: %+v", e.eng.State().Zone("lan"))
}
h, _ := e.srv.store.ListHistory(ctx, 1, 0)
if h[0].Via != "rollback" || h[0].Username != "housecarl" {
t.Errorf("rollback history %+v", h[0])
}
}
func TestDynamicSetElements(t *testing.T) {
e := newEnv(t)
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "guests", Dynamic: true, Timeout: 3600}, nil)
e.call(e.token, "POST", "/sets", model.AddressSet{Name: "trusted", Entries: []string{"10.0.10.0/24"}}, nil)
// A token that may only change the guests set (a captive portal).
var tok api.Token
if code := e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "portal", Grants: []string{"sets:write:guests"}}, &tok); code != 201 {
t.Fatalf("token: %d", code)
}
if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.5", Timeout: 1800}, nil); code != 201 {
t.Errorf("add: %d", code)
}
if code := e.call(tok.Secret, "POST", "/sets/guests/elements", api.Element{Address: "10.0.30.0/24"}, nil); code != 400 {
t.Errorf("a prefix: %d", code)
}
var els []api.Element
e.call(tok.Secret, "GET", "/sets/guests/elements", nil, &els)
if len(els) != 1 || els[0].Address != "10.0.30.5" || els[0].Timeout != 1800 {
t.Errorf("elements %+v", els)
}
if code := e.call(tok.Secret, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 403 {
t.Errorf("another set: %d", code)
}
if code := e.call(e.token, "POST", "/sets/trusted/elements", api.Element{Address: "10.0.10.9"}, nil); code != 400 {
t.Errorf("a static set: %d", code)
}
if code := e.call(tok.Secret, "PATCH", "/sets/guests", map[string]any{"timeout": 60}, nil); code != 403 {
t.Errorf("changing the set itself: %d", code)
}
if code := e.call(tok.Secret, "DELETE", "/sets/guests/elements/10.0.30.5", nil, nil); code != 204 {
t.Errorf("delete: %d", code)
}
evs, _ := e.srv.store.ListAudit(context.Background(), store.AuditFilter{Action: "set."})
if len(evs) != 2 || evs[0].Action != "set.delete" {
t.Errorf("audit %+v", evs)
}
}
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: "log"}, &tok); code != 201 || tok.Secret == "" {
t.Fatalf("token: %d %+v", code, tok)
}
if code := e.call(tok.Secret, "GET", "/counters", nil, nil); code != 200 {
t.Errorf("counters with a log:read token: %d", code)
}
if code := e.call(tok.Secret, "GET", "/rules", nil, nil); code != 403 {
t.Errorf("rules with a log:read token: %d", code)
}
var eb api.ErrorBody
if code := e.call(tok.Secret, "POST", "/zones", model.Zone{Name: "lan", Interfaces: []string{"eth1"}}, &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("housecarl_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 TestTokenRotation(t *testing.T) {
e := newEnv(t)
var tok api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "script", ExpiresIn: "30d"}, &tok)
var rot api.Token
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "1h"}, &rot); code != 200 || rot.Secret == "" || rot.Secret == tok.Secret || rot.PrevExpiresAt == nil {
t.Fatalf("rotate: %d %+v", code, rot)
}
if rot.ID != tok.ID || rot.Name != "script" || rot.ExpiresAt == nil || rot.ExpiresAt.Sub(*tok.ExpiresAt) < 0 {
t.Errorf("rotated token %+v", rot)
}
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("new secret: %d", code)
}
if code := e.call(tok.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("old secret in its grace period: %d", code)
}
var me api.Whoami
e.call(rot.Secret, "GET", "/auth/whoami", nil, &me)
if me.TokenID != tok.ID {
t.Errorf("whoami token id %d, want %d", me.TokenID, tok.ID)
}
// Without a grace period the old secret stops at once.
var rot2 api.Token
e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, &rot2)
if code := e.call(rot.Secret, "GET", "/auth/whoami", nil, nil); code != 401 {
t.Errorf("old secret without grace: %d", code)
}
if code := e.call(rot2.Secret, "GET", "/auth/whoami", nil, nil); code != 200 {
t.Errorf("newest secret: %d", code)
}
if code := e.call(e.token, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", api.TokenRotation{Grace: "30d"}, nil); code != 400 {
t.Errorf("a grace over 7 days: %d", code)
}
// A restricted token can't rotate a wider one.
var narrow api.Token
e.call(e.token, "POST", "/me/tokens", api.TokenInput{Name: "narrow", Role: "log"}, &narrow)
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(tok.ID)+"/rotate", nil, nil); code != 403 {
t.Errorf("narrow token rotating a wider one: %d", code)
}
if code := e.call(narrow.Secret, "POST", "/tokens/"+itoa(narrow.ID)+"/rotate", nil, nil); code != 200 {
t.Errorf("a token rotating itself: %d", code)
}
}
func TestImportAndTakeover(t *testing.T) {
e := newEnv(t)
e.srv.probationFor = 300 * time.Millisecond
js, _ := os.ReadFile("../importer/testdata/router.json")
txt, _ := os.ReadFile("../importer/testdata/router.txt")
defs, _ := os.ReadFile("../importer/testdata/router.nft")
in := api.ImportInput{JSON: js, Text: string(txt), Defines: string(defs)}
var res api.ImportResult
if code := e.call(e.token, "POST", "/import/nft?dry_run=1", in, &res); code != 200 || !res.DryRun || len(res.State.Forwards) != 5 || len(res.Notes) == 0 {
t.Fatalf("dry run: %d %+v", code, res.MutationResponse)
}
if len(e.eng.State().Rules) != 0 {
t.Error("a dry run changed the configuration")
}
if code := e.call(e.token, "POST", "/import/nft", in, &res); code != 200 || !res.Applied || res.ConfirmBy == nil {
t.Fatalf("import: %d %+v", code, res.MutationResponse)
}
st := e.eng.State()
if len(st.Forwards) != 5 || st.Zone("wan") == nil || st.Forwards[0].ID < 2 {
t.Errorf("imported: %+v", st.Forwards)
}
// IDs are the store's: a new object doesn't collide.
var mr api.MutationResponse
if code := e.call(e.token, "POST", "/zones", model.Zone{Name: "extra", Interfaces: []string{"eth9"}}, &mr); code != 201 {
t.Errorf("a zone after the import: %d %+v", code, mr)
}
// A second import needs --replace.
if code := e.call(e.token, "POST", "/import/nft", in, nil); code != 409 {
t.Errorf("import over a configuration: %d", code)
}
e.call(e.token, "POST", "/confirm", nil, nil)
// Takeover: the old table goes, and comes back unless confirmed.
var out map[string]any
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet main"}}, &out); code != 200 || len(e.fw.deleted) != 1 {
t.Fatalf("takeover: %d %+v", code, out)
}
if !strings.Contains(out["message"].(string), "come back") {
t.Errorf("takeover message %q", out["message"])
}
time.Sleep(700 * time.Millisecond)
if len(e.fw.restored) != 1 || e.fw.restored[0] != "table inet main {}" {
t.Errorf("not restored: %+v", e.fw.restored)
}
if code := e.call(e.token, "POST", "/takeover", api.TakeoverInput{Tables: []string{"inet housecarl"}}, nil); code != 400 {
t.Errorf("taking over Housecarl's own table: %d", code)
}
}
+147
View File
@@ -0,0 +1,147 @@
package server
import (
"bufio"
"crypto/tls"
"errors"
"net"
"net/http"
"net/netip"
"strings"
"sync"
"time"
)
// sniffListener serves TLS and plain HTTP on the same port: it looks at a
// connection's first byte and hands the server a *tls.Conn when it is a
// TLS handshake (0x16), the plain connection otherwise. That way housecarld
// can start serving HTTPS when a certificate turns up (one it obtains,
// docs/certificates.md) without a restart, and a plain-HTTP bookmark gets
// a redirect instead of an error.
type sniffListener struct {
net.Listener
cfg *tls.Config
ch chan net.Conn
errc chan error
done chan struct{}
once sync.Once
}
func newSniffListener(ln net.Listener, cfg *tls.Config) *sniffListener {
l := &sniffListener{Listener: ln, cfg: cfg, ch: make(chan net.Conn), errc: make(chan error, 1), done: make(chan struct{})}
go l.loop()
return l
}
func (l *sniffListener) loop() {
for {
c, err := l.Listener.Accept()
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
time.Sleep(50 * time.Millisecond)
continue
}
select {
case l.errc <- err:
default:
}
return
}
go l.classify(c)
}
}
func (l *sniffListener) classify(c net.Conn) {
c.SetReadDeadline(time.Now().Add(10 * time.Second))
br := bufio.NewReader(c)
b, err := br.Peek(1)
c.SetReadDeadline(time.Time{})
if err != nil {
c.Close()
return
}
var out net.Conn = &peekedConn{Conn: c, r: br}
if b[0] == 0x16 {
out = tls.Server(out, l.cfg)
}
select {
case l.ch <- out:
case <-l.done:
c.Close()
}
}
func (l *sniffListener) Accept() (net.Conn, error) {
select {
case c := <-l.ch:
return c, nil
case err := <-l.errc:
return nil, err
case <-l.done:
return nil, net.ErrClosed
}
}
func (l *sniffListener) Close() error {
l.once.Do(func() { close(l.done) })
return l.Listener.Close()
}
// peekedConn reads through the buffer that holds the peeked byte.
type peekedConn struct {
net.Conn
r *bufio.Reader
}
func (c *peekedConn) Read(p []byte) (int, error) { return c.r.Read(p) }
// webCertificate is the certificate housecarld serves: tls.cert_file's, or
// the one it obtains itself (tls.acme).
func (s *Server) webCertificate(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
if s.certs != nil {
return s.certs.get(hello)
}
if s.cfg.ACME != nil {
if c := s.cfg.ACME.Certificate(); c != nil {
return c, nil
}
}
return nil, errNoCertificate
}
var errNoCertificate = errors.New("no certificate for HTTPS yet")
// httpsAvailable reports whether housecarld has a certificate to serve.
func (s *Server) httpsAvailable() bool {
return s.certs != nil || s.cfg.ACME != nil && s.cfg.ACME.Certificate() != nil
}
// requireHTTPS sends plain-HTTP requests to HTTPS once there is a
// certificate: browsers (GET and HEAD) get a redirect, anything else an
// error, so tokens and passwords aren't sent in the clear. Requests from
// loopback (tools on this server) and from trusted proxies (which
// terminate TLS themselves) are served as they are.
func (s *Server) requireHTTPS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.TLS != nil || !s.httpsAvailable() {
next.ServeHTTP(w, r)
return
}
host, _, _ := net.SplitHostPort(r.RemoteAddr)
if a, err := netip.ParseAddr(host); err == nil && (a.Unmap().IsLoopback() || s.trusted(a.Unmap())) {
next.ServeHTTP(w, r)
return
}
target := "https://" + r.Host + r.URL.RequestURI()
if r.Method == http.MethodGet || r.Method == http.MethodHead {
http.Redirect(w, r, target, http.StatusPermanentRedirect)
return
}
msg := "housecarld serves HTTPS on this port; use " + strings.TrimSuffix(target, r.URL.RequestURI())
if r.Header.Get("Authorization") != "" {
msg += ". This request's token crossed the network unencrypted; if that network isn't trusted, rotate the token"
}
writeError(w, http.StatusBadRequest, "https_required", msg)
})
}
+58
View File
@@ -0,0 +1,58 @@
package server
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"io"
"math/big"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// TestHTTPSAndPlainOnOnePort serves a certificate file: the same port
// answers HTTPS, and plain HTTP from loopback.
func TestHTTPSAndPlainOnOnePort(t *testing.T) {
dir := t.TempDir()
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
tmpl := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "dhcp.test"}, DNSNames: []string{"dhcp.test"},
NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour)}
der, _ := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
kd, _ := x509.MarshalPKCS8PrivateKey(key)
os.WriteFile(filepath.Join(dir, "c.pem"), pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600)
os.WriteFile(filepath.Join(dir, "k.pem"), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}), 0o600)
ln, _ := net.Listen("tcp", "127.0.0.1:0")
addr := ln.Addr().String()
ln.Close()
e := newEnv(t)
e.srv.cfg.Listen, e.srv.cfg.TLSCertFile, e.srv.cfg.TLSKeyFile = []string{addr}, filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
ready := make(chan struct{})
go e.srv.Serve(ctx, func() { close(ready) })
<-ready
pool := x509.NewCertPool()
pool.AddCert(func() *x509.Certificate { c, _ := x509.ParseCertificate(der); return c }())
hc := &http.Client{Timeout: 5 * time.Second, Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, ServerName: "dhcp.test"}}}
for _, url := range []string{"https://" + addr + "/healthz", "http://" + addr + "/healthz"} {
resp, err := hc.Get(url)
if err != nil {
t.Fatalf("%s: %v", url, err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(string(b), `"ok":true`) {
t.Errorf("%s: %d %s", url, resp.StatusCode, b)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
package server
// The local unix socket (socket in housecarl.yaml, /run/housecarl/housecarl.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
// housecarld's own user get administrator rights without a token. Both can
// already do everything (read the database, run 'housecarld user', stop the
// service), so this adds convenience, not power: 'sudo hc 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 housecarl 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"
"housecarl/internal/auth"
"housecarl/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 housecarld 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 Housecarl 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=housecarl; 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 housecarld", 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 housecarld's own user, not to " + who + "; send an API token (--token), or run the command as root"}
}