Files
housecarl/internal/model/objects.go
T
abrown38andClaude Opus 5.5 a45c893830 Connections, "every packet" rules, and a packet tracer
- Connections: the kernel's tracked connections, read and closed over
  ctnetlink (internal/conntrack, raw netlink like the drop log), with
  their NAT and TCP state; closing one makes the rules check its next
  packet again (GET /connections, POST /connections/close; hc
  connections; Traffic → Connections).
- Rules can check every packet (every_packet, --every-packet): before
  established connections are accepted, so a drop or reject also stops
  connections already open. They can't accept.
- A packet tracer (internal/trace): a table of its own, inet
  housecarl-trace, marks the packets a filter picks, `nft monitor trace`
  reports every rule and policy they meet in every table, and the tracer
  names what accepted or dropped them, Housecarl's rules by name (POST,
  GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time,
  at most 200 packets and 20 a second; the table goes when it ends.

e2e: 157 checks, among them an upload a normal block doesn't stop and an
every-packet one does, and a trace naming the blocking rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:22:34 +01:00

356 lines
12 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, grouped as GroupV4/GroupV6 say).
ConnLimit int `json:"conn_limit,omitempty"`
// Quota, if set, is an allowance of bytes: the rule matches once it
// is used up (Over), or while it lasts.
Quota *Quota `json:"quota,omitempty"`
// GroupV4 and GroupV6 group addresses for the per-address limits,
// connection limit and quota: by prefix length (0: the default, each
// IPv4 address alone, IPv6 by /64, since one device has several).
GroupV4 int `json:"group_v4,omitempty"`
GroupV6 int `json:"group_v6,omitempty"`
// EveryPacket checks the rule against every packet, before established
// connections are accepted, so a drop or reject also stops connections
// already open, not just new ones.
EveryPacket bool `json:"every_packet,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 (or bytes, with Unit) per Per (second,
// minute, hour, day), with a burst.
type Limit struct {
Rate int `json:"rate"`
Per string `json:"per"`
// Burst is in the rate's unit.
Burst int `json:"burst,omitempty"`
// Unit: "" for packets, or bytes, kbytes or mbytes for a byte rate
// (bandwidth).
Unit string `json:"unit,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; PerDestination each
// destination.
PerSource bool `json:"per_source,omitempty"`
PerDestination bool `json:"per_destination,omitempty"`
}
// Byte units, as nftables writes them.
var ByteUnits = map[string]int64{"bytes": 1, "kbytes": 1 << 10, "mbytes": 1 << 20, "gbytes": 1 << 30}
// Quota is an allowance of bytes. nftables counts it; Housecarl keeps the
// count across changes and restarts, and resets it each period.
type Quota struct {
Bytes int64 `json:"bytes"`
// Over matches once the allowance is used up (to drop); otherwise the
// rule matches while it lasts (to accept).
Over bool `json:"over,omitempty"`
// PerSource gives each source address (grouped as the rule's
// GroupV4/GroupV6 say) its own allowance; PerDestination each
// destination.
PerSource bool `json:"per_source,omitempty"`
PerDestination bool `json:"per_destination,omitempty"`
// Reset: "" (only by hand), daily, weekly (on Mondays) or monthly (on
// Day, 1-31: a short month's last day when it has no such day), at
// midnight.
Reset string `json:"reset,omitempty"`
Day int `json:"day,omitempty"`
}
// Quota resets.
var QuotaResets = []string{"daily", "weekly", "monthly"}
// PerAddress reports whether a quota (or limit) counts each address on its
// own, and which: "saddr", "daddr" or "".
func (q *Quota) PerAddress() string {
switch {
case q == nil:
return ""
case q.PerSource:
return "saddr"
case q.PerDestination:
return "daddr"
}
return ""
}
// PerAddress: as Quota's.
func (l *Limit) PerAddress() string {
switch {
case l == nil:
return ""
case l.PerSource:
return "saddr"
case l.PerDestination:
return "daddr"
}
return ""
}
// CountsTraffic reports whether a rule measures traffic (a quota or a byte
// rate): it must see every packet of a connection, so it is checked before
// the built-in that accepts established connections.
func (r *Rule) CountsTraffic() bool {
if r.Raw != "" {
return false
}
if r.Quota != nil {
return true
}
if r.Limit == nil {
return false
}
_, bytes := ByteUnits[r.Limit.Unit]
return bytes
}
// ChecksEveryPacket reports whether a rule is checked before the built-in
// that accepts established connections: it counts traffic, or asks to.
func (r *Rule) ChecksEveryPacket() bool {
return r.Raw == "" && (r.EveryPacket || r.CountsTraffic())
}
// Groups returns a rule's address grouping, defaults applied.
func (r *Rule) Groups() (v4, v6 int) {
v4, v6 = r.GroupV4, r.GroupV6
if v4 == 0 {
v4 = 32
}
if v6 == 0 {
v6 = 64
}
return v4, v6
}
// 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)
}