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:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
}
|
||||
Reference in new issue
Block a user