Build Housecarl 0.1.0: the nftables firewall manager

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

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

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

No files matched your search

+335
View File
@@ -0,0 +1,335 @@
// Package auth handles passwords, API tokens, sessions and permissions.
package auth
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base32"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
"housecarl/internal/model"
)
// Argon2id parameters (OWASP: m=19 MiB, t=2, p=1).
const (
argonTime = 2
argonMemory = 19 * 1024
argonThreads = 1
argonKeyLen = 32
saltLen = 16
)
// MinPasswordLength is the shortest accepted password.
const MinPasswordLength = 8
// HashPassword returns an argon2id hash in PHC string format.
func HashPassword(password string) (string, error) {
if len(password) < MinPasswordLength {
return "", fmt.Errorf("a password needs at least %d characters", MinPasswordLength)
}
salt := make([]byte, saltLen)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s", argon2.Version, argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt), base64.RawStdEncoding.EncodeToString(key)), nil
}
// dummyHash is checked when a user doesn't exist, so a login takes as
// long whether or not the username is right.
var dummyHash, _ = HashPassword("not-a-real-password")
// CheckPassword reports whether password matches hash. An empty hash never
// matches (in the same time).
func CheckPassword(hash, password string) bool {
if hash == "" {
hash = dummyHash
password = "\x00" + password
}
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
for _, kv := range strings.Split(parts[3], ",") {
k, v, _ := strings.Cut(kv, "=")
n, err := strconv.ParseUint(v, 10, 32)
if err != nil {
return false
}
switch k {
case "m":
m = uint32(n)
case "t":
t = uint32(n)
case "p":
p = uint8(n)
}
}
salt, err1 := base64.RawStdEncoding.DecodeString(parts[4])
want, err2 := base64.RawStdEncoding.DecodeString(parts[5])
if err1 != nil || err2 != nil || m == 0 || t == 0 || p == 0 {
return false
}
got := argon2.IDKey([]byte(password), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
var b32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
// RandomString returns a random string with nbytes of entropy.
func RandomString(nbytes int) string {
b := make([]byte, nbytes)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return b32.EncodeToString(b)
}
// TokenPrefix starts every API token, so leaked ones are easy to spot.
const TokenPrefix = "housecarl_"
// NewToken makes a token: the whole string to give out, its public ID
// (stored in clear) and the hash of its secret.
func NewToken() (token, publicID, secretHash string) {
publicID = RandomString(10) // 16 characters
secret := RandomString(20) // 32 characters
return TokenPrefix + publicID + "_" + secret, publicID, HashSecret(secret)
}
// RotateToken makes a new secret for a token's public ID.
func RotateToken(publicID string) (token, secretHash string) {
secret := RandomString(20)
return TokenPrefix + publicID + "_" + secret, HashSecret(secret)
}
// ParseToken splits a token into its public ID and secret.
func ParseToken(token string) (publicID, secret string, err error) {
rest, ok := strings.CutPrefix(strings.TrimSpace(token), TokenPrefix)
if !ok {
return "", "", errors.New("not a Housecarl token (they start with " + TokenPrefix + ")")
}
publicID, secret, ok = strings.Cut(rest, "_")
if !ok || len(publicID) != 16 || len(secret) != 32 {
return "", "", errors.New("malformed token")
}
return publicID, secret, nil
}
// HashSecret hashes a high-entropy secret (token secrets, session IDs).
func HashSecret(secret string) string {
h := sha256.Sum256([]byte(secret))
return hex.EncodeToString(h[:])
}
// CheckSecret compares a secret with a stored hash in constant time.
func CheckSecret(secret, hash string) bool {
return subtle.ConstantTimeCompare([]byte(HashSecret(secret)), []byte(hash)) == 1
}
// NewSessionID returns a session ID and its CSRF token.
func NewSessionID() (id, csrf string) {
return RandomString(32), RandomString(32)
}
// Resources grants apply to.
const (
ResConfig = "config" // the firewall configuration, its history and imports
ResSets = "sets" // dynamic sets' entries (a grant may name the sets)
ResLog = "log" // counters, the drop log and the status
ResAudit = "audit" // the audit log
ResAll = "*"
)
// Resources lists them in display order.
var Resources = []string{ResConfig, ResSets, ResLog, ResAudit}
// Access levels.
const (
Read = "read"
Write = "write"
)
// Roles are ready-made sets of grants.
var Roles = map[string][]model.Grant{
"viewer": {{Resource: ResConfig, Access: Read}, {Resource: ResSets, Access: Read}, {Resource: ResLog, Access: Read}},
"operator": {{Resource: ResConfig, Access: Write}, {Resource: ResSets, Access: Write}, {Resource: ResLog, Access: Read}, {Resource: ResAudit, Access: Read}},
"log": {{Resource: ResLog, Access: Read}},
}
// RoleNames lists the roles.
func RoleNames() []string {
var out []string
for n := range Roles {
out = append(out, n)
}
sort.Strings(out)
return out
}
// ParseGrant reads "resource:access" ("log:read", "*:write"), and for
// sets optionally the sets it is limited to ("sets:write:guest,portal").
func ParseGrant(s string) (model.Grant, error) {
parts := strings.SplitN(strings.TrimSpace(s), ":", 3)
g := model.Grant{Resource: strings.ToLower(parts[0])}
if len(parts) < 2 {
return g, fmt.Errorf("grant %q: write it as resource:access, e.g. log:read, or sets:write:SET1,SET2", s)
}
g.Access = strings.ToLower(parts[1])
if len(parts) == 3 {
for _, n := range strings.Split(parts[2], ",") {
if n = strings.TrimSpace(n); n != "" {
g.Sets = append(g.Sets, n)
}
}
}
return g, ValidateGrant(&g)
}
// ValidateGrant checks a grant.
func ValidateGrant(g *model.Grant) error {
g.Resource = strings.ToLower(strings.TrimSpace(g.Resource))
g.Access = strings.ToLower(strings.TrimSpace(g.Access))
ok := g.Resource == ResAll
for _, r := range Resources {
ok = ok || g.Resource == r
}
if !ok {
return fmt.Errorf("unknown resource %q (use %s or *)", g.Resource, strings.Join(Resources, ", "))
}
if g.Access != Read && g.Access != Write {
return fmt.Errorf("access must be read or write, not %q", g.Access)
}
if len(g.Sets) > 0 && g.Resource != ResSets {
return fmt.Errorf("only a sets grant can name sets (sets:write:%s)", strings.Join(g.Sets, ","))
}
return nil
}
// FormatGrant writes a grant as "resource:access" (and ":SET,…").
func FormatGrant(g model.Grant) string {
if len(g.Sets) > 0 {
return g.Resource + ":" + g.Access + ":" + strings.Join(g.Sets, ",")
}
return g.Resource + ":" + g.Access
}
// Principal is who makes a request.
type Principal struct {
User *model.User
Token *model.Token
Method string // session, token, socket
IP string
Agent string
}
// Name is the user's name.
func (p *Principal) Name() string {
if p == nil || p.User == nil {
return ""
}
return p.User.Username
}
// IsAdmin reports whether the principal has full rights: an
// administrator, not through a restricted token.
func (p *Principal) IsAdmin() bool {
return p.User != nil && p.User.Admin && (p.Token == nil || p.Token.Grants == nil)
}
// Can reports whether the principal may read (write false) or change
// (write true) a resource.
func (p *Principal) Can(resource string, write bool) bool {
if p == nil || p.User == nil {
return false
}
allowed := p.User.Admin || covers(p.User.Grants, resource, write)
if p.Token != nil && p.Token.Grants != nil {
allowed = allowed && covers(p.Token.Grants, resource, write)
}
return allowed
}
func covers(gs []model.Grant, resource string, write bool) bool {
for _, g := range gs {
if (g.Resource == ResAll || g.Resource == resource) && (!write || g.Access == Write) {
return true
}
}
return false
}
// CanSet reports whether the principal may read or change the entries of
// a dynamic set: a sets grant covers it if it names no sets, or names it.
func (p *Principal) CanSet(name string, write bool) bool {
if p == nil || p.User == nil {
return false
}
allowed := p.User.Admin || coversSet(p.User.Grants, name, write)
if p.Token != nil && p.Token.Grants != nil {
allowed = allowed && coversSet(p.Token.Grants, name, write)
}
return allowed
}
func coversSet(gs []model.Grant, name string, write bool) bool {
for _, g := range gs {
if (g.Resource != ResAll && g.Resource != ResSets) || (write && g.Access != Write) {
continue
}
if len(g.Sets) == 0 {
return true
}
for _, s := range g.Sets {
if strings.EqualFold(s, name) {
return true
}
}
}
return false
}
// Permissions lists what the principal may do, for display.
func (p *Principal) Permissions() []string {
var out []string
for _, r := range Resources {
switch {
case p.Can(r, true):
out = append(out, r+":write")
case p.Can(r, false):
out = append(out, r+":read")
}
}
if p.IsAdmin() {
out = append(out, "users:write")
}
return out
}
// Narrower reports whether every grant in a is covered by b.
func Narrower(a, b []model.Grant) bool {
for _, g := range a {
if !covers(b, g.Resource, g.Access == Write) {
if g.Resource != ResAll {
return false
}
for _, r := range Resources {
if !covers(b, r, g.Access == Write) {
return false
}
}
}
}
return true
}
+73
View File
@@ -0,0 +1,73 @@
package auth
import (
"testing"
"housecarl/internal/model"
)
func TestPasswords(t *testing.T) {
h, err := HashPassword("correct horse")
if err != nil {
t.Fatal(err)
}
if !CheckPassword(h, "correct horse") || CheckPassword(h, "wrong horse") || CheckPassword("", "") {
t.Error("password check")
}
if _, err := HashPassword("short"); err == nil {
t.Error("short password accepted")
}
}
func TestTokens(t *testing.T) {
tok, pid, hash := NewToken()
gotPID, secret, err := ParseToken(tok)
if err != nil || gotPID != pid || !CheckSecret(secret, hash) {
t.Fatalf("token %q: %v", tok, err)
}
if _, _, err := ParseToken("jarl_abc_def"); err == nil {
t.Error("a Jarl token was accepted")
}
}
func TestPermissions(t *testing.T) {
op := &model.User{Username: "op", Grants: Roles["operator"]}
p := &Principal{User: op}
if !p.Can(ResConfig, true) || !p.Can(ResAudit, false) || p.Can(ResAudit, true) || p.IsAdmin() {
t.Error("operator")
}
admin := &model.User{Username: "admin", Admin: true}
p = &Principal{User: admin, Token: &model.Token{Grants: []model.Grant{{Resource: ResLog, Access: Read}}}}
if !p.Can(ResLog, false) || p.Can(ResLog, true) || p.Can(ResConfig, false) || p.IsAdmin() {
t.Error("restricted admin token")
}
if !Narrower(Roles["log"], Roles["operator"]) || Narrower(Roles["operator"], Roles["viewer"]) {
t.Error("narrower")
}
if _, err := ParseGrant("log"); err == nil {
t.Error("grant without access accepted")
}
if g, err := ParseGrant("Config:Write"); err != nil || g.Resource != "config" {
t.Errorf("grant %v %v", g, err)
}
}
func TestSetGrants(t *testing.T) {
g, err := ParseGrant("sets:write:guest-signed-in, portal")
if err != nil || len(g.Sets) != 2 || FormatGrant(g) != "sets:write:guest-signed-in,portal" {
t.Fatalf("parse %+v %v", g, err)
}
if _, err := ParseGrant("config:write:guest"); err == nil {
t.Error("a config grant naming sets was accepted")
}
user := &model.User{Username: "portal", Grants: []model.Grant{g}}
p := &Principal{User: user}
if !p.CanSet("guest-signed-in", true) || !p.CanSet("PORTAL", false) || p.CanSet("trusted", false) || p.Can(ResConfig, false) {
t.Error("set-scoped user")
}
admin := &Principal{User: &model.User{Username: "admin", Admin: true},
Token: &model.Token{Grants: []model.Grant{{Resource: ResSets, Access: Write, Sets: []string{"guest-signed-in"}}}}}
if !admin.CanSet("guest-signed-in", true) || admin.CanSet("trusted", true) {
t.Error("an admin's set-scoped token")
}
}