Files
abrown38andClaude Opus 5.5 34292eeebb 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>
2026-10-19 07:57:14 +01:00

485 lines
14 KiB
Go

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)
}