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

+87
View File
@@ -0,0 +1,87 @@
package model
import (
"fmt"
"net/netip"
"strings"
)
// Address is a parsed address entry: one address, a prefix or a range.
type Address struct {
Prefix netip.Prefix // a single address is a /32 or /128
First, End netip.Addr // a range (First valid only for ranges)
}
// IsV6 reports whether the entry is IPv6.
func (a Address) IsV6() bool {
if a.First.IsValid() {
return a.First.Is6()
}
return a.Prefix.Addr().Is6()
}
// String writes the entry as nftables and Housecarl write it:
// "10.0.0.5", "10.0.0.0/24", "10.0.0.1-10.0.0.9".
func (a Address) String() string {
if a.First.IsValid() {
return a.First.String() + "-" + a.End.String()
}
if a.Prefix.IsSingleIP() {
return a.Prefix.Addr().String()
}
return a.Prefix.String()
}
// ParseAddress reads an address, a prefix (host bits must be zero) or a
// range, IPv4 or IPv6.
func ParseAddress(s string) (Address, error) {
s = strings.TrimSpace(s)
if a, b, ok := strings.Cut(s, "-"); ok {
first, err1 := netip.ParseAddr(strings.TrimSpace(a))
end, err2 := netip.ParseAddr(strings.TrimSpace(b))
if err1 != nil || err2 != nil || first.Is4() != end.Is4() {
return Address{}, fmt.Errorf("%q is not an address range (write it as 10.0.0.1-10.0.0.9)", s)
}
first, end = first.Unmap(), end.Unmap()
if end.Less(first) {
return Address{}, fmt.Errorf("the range %q ends before it starts", s)
}
if first == end {
return Address{Prefix: netip.PrefixFrom(first, first.BitLen())}, nil
}
return Address{First: first, End: end}, nil
}
if strings.Contains(s, "/") {
p, err := netip.ParsePrefix(s)
if err != nil {
return Address{}, fmt.Errorf("%q is not an address or prefix", s)
}
p = netip.PrefixFrom(p.Addr().Unmap(), p.Bits())
if p.Masked() != p {
return Address{}, fmt.Errorf("%q has host bits set; the prefix is %s", s, p.Masked())
}
return Address{Prefix: p}, nil
}
a, err := netip.ParseAddr(s)
if err != nil || a.Zone() != "" {
return Address{}, fmt.Errorf("%q is not an address, prefix, range or set name", s)
}
a = a.Unmap()
return Address{Prefix: netip.PrefixFrom(a, a.BitLen())}, nil
}
// IsLiteral reports whether a source/destination reference is an
// address, prefix or range rather than a set name.
func IsLiteral(ref string) bool {
_, err := ParseAddress(ref)
return err == nil
}
// NormalizeAddress rewrites an address entry in its canonical form.
func NormalizeAddress(s string) (string, error) {
a, err := ParseAddress(s)
if err != nil {
return "", err
}
return a.String(), nil
}
+133
View File
@@ -0,0 +1,133 @@
package model
import (
"encoding/json"
"time"
)
// Grant gives access to one resource (see internal/auth).
type Grant struct {
Resource string `json:"resource"` // config, sets, log, audit, *
Access string `json:"access"` // read or write (write implies read)
// Sets limits a "sets" grant to these dynamic sets (none: all).
Sets []string `json:"sets,omitempty"`
}
// User is a person or a service account.
type User struct {
ID int64 `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
HasPassword bool `json:"has_password"`
Admin bool `json:"admin"`
Grants []Grant `json:"grants"`
Disabled bool `json:"disabled"`
Comment string `json:"comment"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
}
// Token is an API token. The secret is shown once, when it is made; only
// its hash is stored.
type Token struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Username string `json:"username,omitempty"`
Name string `json:"name"`
PublicID string `json:"public_id"`
SecretHash string `json:"-"`
// Grants restrict the token to part of its user's permissions; nil
// means all of them.
Grants []Grant `json:"grants"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
// RotatedAt is when the token last got a new secret (nil: never).
RotatedAt *time.Time `json:"rotated_at,omitempty"`
// After a rotation with a grace period, the previous secret keeps
// working until PrevExpiresAt.
PrevSecretHash string `json:"-"`
PrevExpiresAt *time.Time `json:"previous_secret_valid_until,omitempty"`
}
// IssuedAt is when the token's current secret was made: its last
// rotation, or its creation.
func (t *Token) IssuedAt() time.Time {
if t.RotatedAt != nil {
return *t.RotatedAt
}
return t.CreatedAt
}
// Session is a web UI login.
type Session struct {
IDHash string `json:"-"`
UserID int64 `json:"user_id"`
CSRFToken string `json:"-"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
LastSeenAt time.Time `json:"last_seen_at"`
IP string `json:"ip"`
UserAgent string `json:"user_agent"`
}
// AuditEvent records an action: logins, users and tokens, changes,
// dynamic set entries.
type AuditEvent struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
UserID int64 `json:"user_id,omitempty"`
Username string `json:"username,omitempty"`
Auth string `json:"auth,omitempty"` // session, token, socket
TokenID int64 `json:"token_id,omitempty"`
IP string `json:"ip,omitempty"`
Action string `json:"action"`
Object string `json:"object,omitempty"`
Result string `json:"result"` // success, failure, denied
Detail string `json:"detail,omitempty"`
}
// Change is one object's change: Before is nil for a create, After is nil
// for a delete. Settings have ID 0.
type Change struct {
Kind string `json:"kind"`
ID int64 `json:"id"`
Label string `json:"label"`
Before json.RawMessage `json:"before,omitempty"`
After json.RawMessage `json:"after,omitempty"`
}
// Action describes a change in a word.
func (c Change) Action() string {
switch {
case c.Before == nil:
return "create"
case c.After == nil:
return "delete"
}
return "update"
}
// Inverse is the change that undoes c.
func (c Change) Inverse() Change {
c.Before, c.After = c.After, c.Before
return c
}
// HistoryEntry is a saved configuration change.
type HistoryEntry struct {
ID int64 `json:"id"`
Time time.Time `json:"time"`
UserID int64 `json:"user_id,omitempty"`
Username string `json:"username"`
Via string `json:"via"` // web, cli, api
Description string `json:"description"`
Changes []Change `json:"changes"`
// RevertOf is the entry this one undid; RevertedBy the entry that
// undid this one.
RevertOf int64 `json:"revert_of,omitempty"`
RevertedBy int64 `json:"reverted_by,omitempty"`
}
+250
View File
@@ -0,0 +1,250 @@
package model
import "fmt"
// Chains rules are placed in.
const (
// ChainEarly sees packets before connection tracking (prerouting,
// priority raw): cheap drops, antispoofing.
ChainEarly = "early"
// ChainInput is traffic to the firewall itself.
ChainInput = "input"
// ChainForward is traffic through the firewall.
ChainForward = "forward"
// ChainOutput is traffic from the firewall itself.
ChainOutput = "output"
)
// Chains lists the rule chains in display order.
var Chains = []string{ChainEarly, ChainInput, ChainForward, ChainOutput}
// ChainOrder is a chain's place in Chains.
func ChainOrder(c string) int {
for i, x := range Chains {
if x == c {
return i
}
}
return len(Chains)
}
// Rule actions.
const (
ActionAccept = "accept"
ActionDrop = "drop"
ActionReject = "reject"
// ActionLog only logs (and counts); the packet goes on to the next
// rule.
ActionLog = "log"
)
// Actions lists the rule actions.
var Actions = []string{ActionAccept, ActionDrop, ActionReject, ActionLog}
// Families a rule can be limited to; "" is both.
const (
FamilyIPv4 = "ipv4"
FamilyIPv6 = "ipv6"
)
// Zone is a group of interfaces (wildcards such as "incus*" allowed).
// External zones face the internet: built-ins such as antispoofing and
// MSS clamping apply to them.
type Zone struct {
ID int64 `json:"id"`
Name string `json:"name"`
Interfaces []string `json:"interfaces"`
External bool `json:"external,omitempty"`
Comment string `json:"comment,omitempty"`
}
func (z *Zone) GetID() int64 { return z.ID }
func (z *Zone) Label() string { return z.Name }
// AddressSet is a named list of IPv4 and IPv6 addresses, prefixes and
// ranges. A dynamic set also takes entries at run time, each with a
// timeout, through its own API (a captive portal's signed-in clients).
type AddressSet struct {
ID int64 `json:"id"`
Name string `json:"name"`
Entries []string `json:"entries"`
Dynamic bool `json:"dynamic,omitempty"`
// Timeout is a dynamic set's default entry lifetime in seconds (0:
// entries stay until removed).
Timeout int `json:"timeout,omitempty"`
Comment string `json:"comment,omitempty"`
}
func (s *AddressSet) GetID() int64 { return s.ID }
func (s *AddressSet) Label() string { return s.Name }
// Service is a named list of ports ("tcp/22", "udp/60000-61000") and ICMP
// types ("icmp/echo-request", "icmpv6/packet-too-big"). Rules name
// services, or give such entries directly.
type Service struct {
ID int64 `json:"id"`
Name string `json:"name"`
Entries []string `json:"entries"`
Comment string `json:"comment,omitempty"`
// Builtin marks the catalog's services (never stored).
Builtin bool `json:"builtin,omitempty"`
}
func (s *Service) GetID() int64 { return s.ID }
func (s *Service) Label() string { return s.Name }
// Match is what a rule, port forward, source NAT or redirect looks at.
// Lists are ORed; the fields together are ANDed.
type Match struct {
// InZones and OutZones are zone names; with NotIn/NotOut, every zone
// but those.
InZones []string `json:"in_zones,omitempty"`
NotIn bool `json:"not_in,omitempty"`
OutZones []string `json:"out_zones,omitempty"`
NotOut bool `json:"not_out,omitempty"`
// Sources and Destinations are set names or literal addresses,
// prefixes and ranges, IPv4 or IPv6.
Sources []string `json:"sources,omitempty"`
NotSources bool `json:"not_sources,omitempty"`
Destinations []string `json:"destinations,omitempty"`
NotDests bool `json:"not_destinations,omitempty"`
// Services are service names or entries ("tcp/22", "icmp/echo-request").
Services []string `json:"services,omitempty"`
// Family limits the rule to ipv4 or ipv6 ("": what its addresses
// and services allow).
Family string `json:"family,omitempty"`
}
// Rule is one line of a chain, checked in order.
type Rule struct {
ID int64 `json:"id"`
Chain string `json:"chain"`
Position float64 `json:"position"`
Match
// CtState: new, established, related, invalid, untracked.
CtState []string `json:"ct_state,omitempty"`
// CtStatus: dnat, snat (a connection that was NATed).
CtStatus []string `json:"ct_status,omitempty"`
// Days ("Monday", …) and hours ("08:00"-"17:30") the rule applies.
Days []string `json:"days,omitempty"`
HourFrom string `json:"hour_from,omitempty"`
HourTo string `json:"hour_to,omitempty"`
// Limit, if set, makes the rule match only packets within (or with
// LimitOver, beyond) a rate.
Limit *Limit `json:"limit,omitempty"`
// ConnLimit matches when a source already has more than this many
// connections (per source address).
ConnLimit int `json:"conn_limit,omitempty"`
Action string `json:"action"`
// RejectWith: "" (ICMP port/admin unreachable as fits) or tcp-reset.
RejectWith string `json:"reject_with,omitempty"`
Log bool `json:"log,omitempty"`
LogPrefix string `json:"log_prefix,omitempty"`
// Raw, when set, is an nftables rule written by hand, used instead of
// the fields above (the escape hatch for what Housecarl can't say).
Raw string `json:"raw,omitempty"`
Comment string `json:"comment,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
func (r *Rule) GetID() int64 { return r.ID }
func (r *Rule) Label() string {
if r.Comment != "" {
return fmt.Sprintf("%s #%d (%s)", r.Chain, r.ID, r.Comment)
}
return fmt.Sprintf("%s #%d", r.Chain, r.ID)
}
// Limit is a rate: Rate packets per Per (second, minute, hour, day), with
// a burst.
type Limit struct {
Rate int `json:"rate"`
Per string `json:"per"`
Burst int `json:"burst,omitempty"`
// Over matches packets beyond the rate instead of within it (to drop
// floods).
Over bool `json:"over,omitempty"`
// PerSource counts each source address on its own (a meter), instead
// of all traffic the rule sees together.
PerSource bool `json:"per_source,omitempty"`
}
// PortForward publishes a service on an inside host: incoming connections
// to a public address and port are sent on (DNAT) to To[:ToPort], let
// through the forward chain, and, with Hairpin, also work from inside.
// To6 also opens the port to an IPv6 host (a pinhole; no translation).
type PortForward struct {
ID int64 `json:"id"`
Position float64 `json:"position"`
Name string `json:"name,omitempty"`
// Protocols: tcp, udp (default tcp).
Protocols []string `json:"protocols"`
// Ports is the public port or range ("8096", "7777-7780").
Ports string `json:"ports"`
// PublicAddresses are the IPv4 addresses (or set names) to match;
// none means any address on the external zones' interfaces (and then
// no hairpin).
PublicAddresses []string `json:"public_addresses,omitempty"`
// Sources limits who may connect (set names or addresses).
Sources []string `json:"sources,omitempty"`
To string `json:"to,omitempty"`
// ToPort is the inside port ("" keeps the public one; for a range, the
// first port of an equally long range).
ToPort string `json:"to_port,omitempty"`
To6 string `json:"to6,omitempty"`
// Hairpin lets inside clients use the public address: their
// connections are masqueraded so replies come back through the
// firewall. HairpinSources limits that to some sources (none: all
// inside traffic).
Hairpin bool `json:"hairpin,omitempty"`
HairpinSources []string `json:"hairpin_sources,omitempty"`
Comment string `json:"comment,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
func (f *PortForward) GetID() int64 { return f.ID }
func (f *PortForward) Label() string {
if f.Name != "" {
return f.Name
}
return fmt.Sprintf("port forward #%d (%s)", f.ID, f.Ports)
}
// SourceNAT rewrites the source address of outgoing traffic: masquerade
// (To empty: the outgoing interface's address) or SNAT to To.
type SourceNAT struct {
ID int64 `json:"id"`
Position float64 `json:"position"`
Match
To string `json:"to,omitempty"`
Comment string `json:"comment,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
func (s *SourceNAT) GetID() int64 { return s.ID }
func (s *SourceNAT) Label() string {
if s.Comment != "" {
return fmt.Sprintf("source NAT #%d (%s)", s.ID, s.Comment)
}
return fmt.Sprintf("source NAT #%d", s.ID)
}
// Redirect sends matching traffic to a port on the firewall itself (to
// make every client use its DNS server, say).
type Redirect struct {
ID int64 `json:"id"`
Position float64 `json:"position"`
Match
// ToPort is the local port (the services' first port when empty).
ToPort string `json:"to_port,omitempty"`
Comment string `json:"comment,omitempty"`
Disabled bool `json:"disabled,omitempty"`
}
func (r *Redirect) GetID() int64 { return r.ID }
func (r *Redirect) Label() string {
if r.Comment != "" {
return fmt.Sprintf("redirect #%d (%s)", r.ID, r.Comment)
}
return fmt.Sprintf("redirect #%d", r.ID)
}
+95
View File
@@ -0,0 +1,95 @@
package model
// FamilyRefs are a reference list's part for one address family: names of
// sets that have entries of the family (dynamic sets always do), and
// literal entries.
type FamilyRefs struct {
Sets []string
Literals []string
}
// Empty reports whether nothing of the family was referenced.
func (f FamilyRefs) Empty() bool { return len(f.Sets) == 0 && len(f.Literals) == 0 }
// SplitRefs splits source or destination references by family. Unknown
// names are left out (validation reports them).
func (st *State) SplitRefs(refs []string) (v4, v6 FamilyRefs) {
for _, r := range refs {
if a, err := ParseAddress(r); err == nil {
if a.IsV6() {
v6.Literals = append(v6.Literals, a.String())
} else {
v4.Literals = append(v4.Literals, a.String())
}
continue
}
s := st.Set(r)
if s == nil {
continue
}
has4, has6 := s.Families()
if has4 {
v4.Sets = append(v4.Sets, s.Name)
}
if has6 {
v6.Sets = append(v6.Sets, s.Name)
}
}
return v4, v6
}
// Families reports which families a set holds: a dynamic set may get
// either at run time.
func (s *AddressSet) Families() (v4, v6 bool) {
if s.Dynamic {
return true, true
}
for _, e := range s.Entries {
if a, err := ParseAddress(e); err == nil {
if a.IsV6() {
v6 = true
} else {
v4 = true
}
}
}
return v4, v6
}
// FamilyEntries returns a set's static entries of one family.
func (s *AddressSet) FamilyEntries(v6 bool) []string {
var out []string
for _, e := range s.Entries {
if a, err := ParseAddress(e); err == nil && a.IsV6() == v6 {
out = append(out, a.String())
}
}
return out
}
// ServiceEntries resolves service references (names and entries) to
// entries, without duplicates. Unknown names are left out.
func (st *State) ServiceEntries(refs []string) []ServiceEntry {
var out []ServiceEntry
seen := map[string]bool{}
add := func(s string) {
e, err := ParseServiceEntry(s)
if err != nil || seen[e.String()] {
return
}
seen[e.String()] = true
out = append(out, e)
}
for _, r := range refs {
if IsServiceEntry(r) {
add(r)
continue
}
if svc := st.Service(r); svc != nil {
for _, e := range svc.Entries {
add(e)
}
}
}
return out
}
+146
View File
@@ -0,0 +1,146 @@
package model
import (
"fmt"
"sort"
"strconv"
"strings"
)
// ServiceEntry is a parsed service entry: ports of a transport protocol
// ("tcp/22", "udp/60000-61000") or an ICMP type ("icmp/echo-request",
// "icmpv6/packet-too-big").
type ServiceEntry struct {
Proto string // tcp, udp, sctp, icmp, icmpv6
// Ports: "22" or "1000-2000" (transport protocols).
Ports string
// Type: the ICMP type's name (icmp, icmpv6).
Type string
}
func (e ServiceEntry) String() string {
if e.Type != "" {
return e.Proto + "/" + e.Type
}
return e.Proto + "/" + e.Ports
}
// IsICMP reports whether the entry is an ICMP or ICMPv6 type.
func (e ServiceEntry) IsICMP() bool { return e.Proto == "icmp" || e.Proto == "icmpv6" }
// ICMP types nftables knows, by protocol.
var icmpTypes = map[string][]string{
"icmp": {"echo-reply", "destination-unreachable", "source-quench", "redirect", "echo-request",
"router-advertisement", "router-solicitation", "time-exceeded", "parameter-problem",
"timestamp-request", "timestamp-reply", "info-request", "info-reply", "address-mask-request",
"address-mask-reply"},
"icmpv6": {"destination-unreachable", "packet-too-big", "time-exceeded", "parameter-problem",
"echo-request", "echo-reply", "mld-listener-query", "mld-listener-report", "mld-listener-done",
"mld-listener-reduction", "nd-router-solicit", "nd-router-advert", "nd-neighbor-solicit",
"nd-neighbor-advert", "nd-redirect", "router-renumbering", "ind-neighbor-solicit",
"ind-neighbor-advert", "mld2-listener-report"},
}
// ParseServiceEntry reads "tcp/22", "udp/1000-2000", "tcp/ssh"-style
// entries are not accepted (use the service's name instead).
func ParseServiceEntry(s string) (ServiceEntry, error) {
s = strings.ToLower(strings.TrimSpace(s))
proto, rest, ok := strings.Cut(s, "/")
if !ok {
return ServiceEntry{}, fmt.Errorf("%q is not a service entry (tcp/22, udp/1000-2000, icmp/echo-request) or a service name", s)
}
switch proto {
case "tcp", "udp", "sctp":
ports, err := NormalizePorts(rest)
if err != nil {
return ServiceEntry{}, fmt.Errorf("%q: %v", s, err)
}
return ServiceEntry{Proto: proto, Ports: ports}, nil
case "icmp", "icmpv6":
for _, t := range icmpTypes[proto] {
if t == rest {
return ServiceEntry{Proto: proto, Type: rest}, nil
}
}
return ServiceEntry{}, fmt.Errorf("%q: unknown %s type (%s)", s, proto, strings.Join(icmpTypes[proto], ", "))
}
return ServiceEntry{}, fmt.Errorf("%q: the protocol is tcp, udp, sctp, icmp or icmpv6", s)
}
// IsServiceEntry reports whether a reference is an entry ("tcp/22")
// rather than a service name.
func IsServiceEntry(ref string) bool { return strings.Contains(ref, "/") }
// NormalizePorts checks a port or range ("22", "1000-2000").
func NormalizePorts(s string) (string, error) {
s = strings.TrimSpace(s)
a, b, isRange := strings.Cut(s, "-")
lo, err1 := strconv.Atoi(strings.TrimSpace(a))
hi := lo
var err2 error
if isRange {
hi, err2 = strconv.Atoi(strings.TrimSpace(b))
}
if err1 != nil || err2 != nil || lo < 1 || hi > 65535 || hi < lo {
return "", fmt.Errorf("%q is not a port (1-65535) or a range (1000-2000)", s)
}
if lo == hi {
return strconv.Itoa(lo), nil
}
return fmt.Sprintf("%d-%d", lo, hi), nil
}
// PortRange returns a port spec's bounds.
func PortRange(s string) (lo, hi int) {
a, b, isRange := strings.Cut(s, "-")
lo, _ = strconv.Atoi(a)
hi = lo
if isRange {
hi, _ = strconv.Atoi(b)
}
return lo, hi
}
// builtinServices are the services every configuration has.
var builtinServices = map[string][]string{
"ssh": {"tcp/22"},
"dns": {"udp/53", "tcp/53"},
"dot": {"tcp/853"},
"http": {"tcp/80"},
"https": {"tcp/443", "udp/443"},
"smtp": {"tcp/25"},
"submission": {"tcp/587"},
"smtps": {"tcp/465"},
"imaps": {"tcp/993"},
"ntp": {"udp/123"},
"dhcp": {"udp/67"},
"dhcpv6": {"udp/547"},
"wireguard": {"udp/51820"},
"openvpn": {"udp/1194"},
"ping": {"icmp/echo-request", "icmpv6/echo-request"},
"snmp": {"udp/161"},
"syslog": {"udp/514"},
"mdns": {"udp/5353"},
"rdp": {"tcp/3389"},
"ipsec": {"udp/500", "udp/4500"},
}
// BuiltinService returns a built-in service by name (nil if there is
// none).
func BuiltinService(name string) *Service {
e, ok := builtinServices[strings.ToLower(name)]
if !ok {
return nil
}
return &Service{Name: strings.ToLower(name), Entries: append([]string(nil), e...), Builtin: true}
}
// BuiltinServices lists the built-in services by name.
func BuiltinServices() []*Service {
var out []*Service
for n := range builtinServices {
out = append(out, BuiltinService(n))
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
+90
View File
@@ -0,0 +1,90 @@
package model
// Settings are the firewall-wide settings: default policies and the rules
// Housecarl adds by itself ("built-ins"), each of which can be turned off.
type Settings struct {
// Policies for packets no rule decided: accept or drop.
InputPolicy string `json:"input_policy"`
ForwardPolicy string `json:"forward_policy"`
OutputPolicy string `json:"output_policy"`
// LogPolicyDrops logs (rate-limited) what the input and forward
// policies drop.
LogPolicyDrops bool `json:"log_policy_drops,omitempty"`
Builtins Builtins `json:"builtins"`
// AntispoofV4 and AntispoofV6 are source addresses that can't come
// from the internet: dropped on external zones (Builtins.Antispoof),
// together with OwnPrefixes.
AntispoofV4 []string `json:"antispoof_v4"`
AntispoofV6 []string `json:"antispoof_v6"`
// OwnPrefixes are your own public prefixes (your delegated IPv6
// prefix, say): traffic claiming to come from them can't arrive from
// outside.
OwnPrefixes []string `json:"own_prefixes,omitempty"`
// Flowtable devices: forwarded TCP and UDP connections between them
// skip the rules once established (fast path). Empty: off. Names
// must be exact (no wildcards).
FlowtableDevices []string `json:"flowtable_devices,omitempty"`
// ProbationSeconds is how long a change made over the network has to
// be confirmed before it is rolled back (0: the default).
ProbationSeconds int `json:"probation_seconds,omitempty"`
}
// Builtins are rules Housecarl adds itself, shown as "added by Housecarl".
type Builtins struct {
// Loopback accepts everything on lo.
Loopback bool `json:"loopback"`
// Established accepts packets of established and related connections.
Established bool `json:"established"`
// DropInvalid drops packets conntrack finds invalid.
DropInvalid bool `json:"drop_invalid"`
// ICMPv6 accepts the ICMPv6 IPv6 needs (RFC 4890): errors, echo, and
// neighbour discovery and MLD on the local link.
ICMPv6 bool `json:"icmpv6"`
// PingInternal accepts ICMP echo to the firewall from internal zones.
PingInternal bool `json:"ping_internal"`
// DHCPv6Client accepts DHCPv6 replies on external zones (an ISP's
// prefix delegation).
DHCPv6Client bool `json:"dhcpv6_client"`
// Antispoof drops impossible source addresses arriving on external
// zones (AntispoofV4, AntispoofV6, OwnPrefixes).
Antispoof bool `json:"antispoof"`
// MSSClamp clamps TCP MSS to the route MTU on external zones (needed
// with PPPoE).
MSSClamp bool `json:"mss_clamp"`
}
// Policies.
const (
PolicyAccept = "accept"
PolicyDrop = "drop"
)
// DefaultProbation is how long a network change has to be confirmed.
const DefaultProbation = 120
// DefaultSettings are the settings of a new database: drop what isn't
// allowed in and through, allow out, every built-in on.
func DefaultSettings() Settings {
return Settings{
InputPolicy: PolicyDrop,
ForwardPolicy: PolicyDrop,
OutputPolicy: PolicyAccept,
Builtins: Builtins{Loopback: true, Established: true, DropInvalid: true, ICMPv6: true,
PingInternal: true, DHCPv6Client: true, Antispoof: true, MSSClamp: false},
AntispoofV4: []string{"0.0.0.0/8", "10.0.0.0/8", "127.0.0.0/8", "169.254.0.0/16", "172.16.0.0/12",
"192.0.2.0/24", "192.168.0.0/16", "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4"},
AntispoofV6: []string{"::1/128", "::/128", "fc00::/7", "2001:db8::/32", "ff00::/8"},
}
}
// Probation is the probation in seconds, with its default.
func (s Settings) Probation() int {
if s.ProbationSeconds > 0 {
return s.ProbationSeconds
}
return DefaultProbation
}
+328
View File
@@ -0,0 +1,328 @@
// Package model holds Housecarl's data: the firewall configuration
// (State), users and tokens, the history of changes and the audit log.
package model
import (
"encoding/json"
"sort"
"strings"
)
// State is the whole firewall configuration. It is small and changed as a
// whole (internal/engine); the ruleset is rendered from it
// (internal/render).
type State struct {
Settings Settings `json:"settings"`
Zones []*Zone `json:"zones"`
Sets []*AddressSet `json:"sets"`
Services []*Service `json:"services"`
Rules []*Rule `json:"rules"`
Forwards []*PortForward `json:"forwards"`
SNATs []*SourceNAT `json:"snats"`
Redirects []*Redirect `json:"redirects"`
}
// Object kinds, for changes and the history.
const (
KindSettings = "settings"
KindZone = "zone"
KindSet = "set"
KindService = "service"
KindRule = "rule"
KindForward = "forward"
KindSNAT = "snat"
KindRedirect = "redirect"
)
// Kinds lists the object kinds (not settings) in display order.
var Kinds = []string{KindZone, KindSet, KindService, KindRule, KindForward, KindSNAT, KindRedirect}
// Object is anything in the State with an ID.
type Object interface {
GetID() int64
Label() string
}
// NewState is an empty configuration with the default settings.
func NewState() *State {
st := &State{Settings: DefaultSettings()}
st.Fill()
return st
}
// Fill replaces nil lists with empty ones (for JSON and comparisons).
func (st *State) Fill() {
if st.Zones == nil {
st.Zones = []*Zone{}
}
if st.Sets == nil {
st.Sets = []*AddressSet{}
}
if st.Services == nil {
st.Services = []*Service{}
}
if st.Rules == nil {
st.Rules = []*Rule{}
}
if st.Forwards == nil {
st.Forwards = []*PortForward{}
}
if st.SNATs == nil {
st.SNATs = []*SourceNAT{}
}
if st.Redirects == nil {
st.Redirects = []*Redirect{}
}
}
// Objects returns the objects of a kind.
func (st *State) Objects(kind string) []Object {
var out []Object
switch kind {
case KindZone:
for _, o := range st.Zones {
out = append(out, o)
}
case KindSet:
for _, o := range st.Sets {
out = append(out, o)
}
case KindService:
for _, o := range st.Services {
out = append(out, o)
}
case KindRule:
for _, o := range st.Rules {
out = append(out, o)
}
case KindForward:
for _, o := range st.Forwards {
out = append(out, o)
}
case KindSNAT:
for _, o := range st.SNATs {
out = append(out, o)
}
case KindRedirect:
for _, o := range st.Redirects {
out = append(out, o)
}
}
return out
}
// Put replaces (or adds, or with data nil deletes) the object of a kind
// with an ID, from its JSON.
// Empty reports whether nothing has been configured: no objects of any
// kind. housecarld applies no ruleset for an empty configuration, so
// installing and starting it doesn't close the machine.
func (st *State) Empty() bool {
for _, k := range Kinds {
if len(st.Objects(k)) > 0 {
return false
}
}
return true
}
func (st *State) Put(kind string, id int64, data json.RawMessage) error {
switch kind {
case KindZone:
return put(&st.Zones, id, data)
case KindSet:
return put(&st.Sets, id, data)
case KindService:
return put(&st.Services, id, data)
case KindRule:
return put(&st.Rules, id, data)
case KindForward:
return put(&st.Forwards, id, data)
case KindSNAT:
return put(&st.SNATs, id, data)
case KindRedirect:
return put(&st.Redirects, id, data)
}
return &unknownKind{kind}
}
type unknownKind struct{ kind string }
func (e *unknownKind) Error() string { return "unknown kind " + e.kind }
func put[T Object](list *[]T, id int64, data json.RawMessage) error {
keep := (*list)[:0:0]
for _, o := range *list {
if o.GetID() != id {
keep = append(keep, o)
}
}
if data != nil {
var o T
if err := json.Unmarshal(data, &o); err != nil {
return err
}
keep = append(keep, o)
}
*list = keep
return nil
}
// Clone returns a deep copy.
func (st *State) Clone() *State {
b, err := json.Marshal(st)
if err != nil {
panic(err)
}
var out State
if err := json.Unmarshal(b, &out); err != nil {
panic(err)
}
out.Fill()
return &out
}
// Sort puts the objects in display order: named objects by name, rules by
// chain and position, NAT rules by position.
func (st *State) Sort() {
sort.SliceStable(st.Zones, func(i, j int) bool { return st.Zones[i].Name < st.Zones[j].Name })
sort.SliceStable(st.Sets, func(i, j int) bool { return st.Sets[i].Name < st.Sets[j].Name })
sort.SliceStable(st.Services, func(i, j int) bool { return st.Services[i].Name < st.Services[j].Name })
sort.SliceStable(st.Rules, func(i, j int) bool {
a, b := st.Rules[i], st.Rules[j]
if a.Chain != b.Chain {
return ChainOrder(a.Chain) < ChainOrder(b.Chain)
}
return a.Position < b.Position
})
sort.SliceStable(st.Forwards, func(i, j int) bool { return st.Forwards[i].Position < st.Forwards[j].Position })
sort.SliceStable(st.SNATs, func(i, j int) bool { return st.SNATs[i].Position < st.SNATs[j].Position })
sort.SliceStable(st.Redirects, func(i, j int) bool { return st.Redirects[i].Position < st.Redirects[j].Position })
}
// Renumber gives the rules of each chain, and the NAT rules of each kind,
// positions 1, 2, 3, … in their current order (after an insertion at a
// fractional position, or a deletion).
func (st *State) Renumber() {
st.Sort()
n := map[string]float64{}
for _, r := range st.Rules {
n[r.Chain]++
r.Position = n[r.Chain]
}
for i, f := range st.Forwards {
f.Position = float64(i + 1)
}
for i, s := range st.SNATs {
s.Position = float64(i + 1)
}
for i, r := range st.Redirects {
r.Position = float64(i + 1)
}
}
// Zone finds a zone by name.
func (st *State) Zone(name string) *Zone {
for _, z := range st.Zones {
if strings.EqualFold(z.Name, name) {
return z
}
}
return nil
}
// Set finds an address set by name.
func (st *State) Set(name string) *AddressSet {
for _, s := range st.Sets {
if strings.EqualFold(s.Name, name) {
return s
}
}
return nil
}
// Service finds a service by name: the configuration's, else the built-in
// catalog's.
func (st *State) Service(name string) *Service {
for _, s := range st.Services {
if strings.EqualFold(s.Name, name) {
return s
}
}
return BuiltinService(name)
}
// ByID finds an object of a kind by ID (nil if there is none).
func (st *State) ByID(kind string, id int64) Object {
for _, o := range st.Objects(kind) {
if o.GetID() == id {
return o
}
}
return nil
}
// Find finds an object of a kind by ID or name (zones, sets, services,
// port forwards) or by ID alone (rules, source NAT, redirects).
func (st *State) Find(kind, ref string) Object {
for _, o := range st.Objects(kind) {
if itoa(o.GetID()) == ref {
return o
}
}
switch kind {
case KindZone:
if z := st.Zone(ref); z != nil {
return z
}
case KindSet:
if s := st.Set(ref); s != nil {
return s
}
case KindService:
for _, s := range st.Services {
if strings.EqualFold(s.Name, ref) {
return s
}
}
case KindForward:
for _, f := range st.Forwards {
if f.Name != "" && strings.EqualFold(f.Name, ref) {
return f
}
}
}
return nil
}
func itoa(n int64) string {
b, _ := json.Marshal(n)
return string(b)
}
// ExternalInterfaces lists the interfaces of the external zones.
func (st *State) ExternalInterfaces() []string {
return st.zoneInterfaces(func(z *Zone) bool { return z.External })
}
// InternalInterfaces lists the interfaces of the internal zones.
func (st *State) InternalInterfaces() []string {
return st.zoneInterfaces(func(z *Zone) bool { return !z.External })
}
func (st *State) zoneInterfaces(keep func(*Zone) bool) []string {
seen := map[string]bool{}
var out []string
for _, z := range st.Zones {
if !keep(z) {
continue
}
for _, i := range z.Interfaces {
if !seen[i] {
seen[i] = true
out = append(out, i)
}
}
}
sort.Strings(out)
return out
}