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>
251 lines
8.7 KiB
Go
251 lines
8.7 KiB
Go
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)
|
|
}
|