shadow.enabled in housecarl.yaml loads Housecarl's ruleset so that it blocks, rejects and translates nothing: drops and rejects count and accept (an accept only ends Housecarl's chain), policies accept and log what they would drop, NAT only counts, MSS clamping and the flowtable are left out, and raw rules that do more than accept, drop or reject are left out with a warning. Comments mark what would happen ([would drop]). Filter chains run after the other firewall's (+10) or before (-10); NAT chains always first. Every change is checked by nftables in both forms. No probation; takeovers and closing connections are refused. The boot fallback filters nothing. internal/tablewatch puts the table back after a flush ruleset. Mode in status, healthz, whoami, the web UI, the log and the tracer; hc ruleset --enforcing. docs/shadow.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
293 lines
9.8 KiB
Go
293 lines
9.8 KiB
Go
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(), Mode: s.mode()}
|
|
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
|
|
}
|