A set can take its addresses from Jarl: every A and AAAA record of its
names (NAME@VIEW), CNAMEs followed within Jarl's zones in the same view.
housecarld reads them every jarl.refresh and after each change, keeps
only the addresses within the zones and networks the set allows, and
keeps what it last read (saved in the database and the boot ruleset)
while Jarl can't be reached. A change of the addresses applies the
ruleset again without a history entry and is audited (set.jarl).
hc set add --jarl --within, hc set jarl; the kind "from Jarl" and
Addresses… on the Sets page; GET /sets/{name}/jarl and /jarl/refresh;
validation codes set_jarl_*, status warnings jarl_*; docs/jarl.md.
e2e phase with scripts/fakejarl.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
536 lines
18 KiB
Go
536 lines
18 KiB
Go
package model
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// 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"`
|
|
// Jarl makes the set's entries the addresses of names in Jarl, which
|
|
// housecarld reads and keeps up to date (docs/jarl.md).
|
|
Jarl *JarlSource `json:"jarl,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
}
|
|
|
|
func (s *AddressSet) GetID() int64 { return s.ID }
|
|
|
|
// Runtime reports whether the set's entries come at run time (a dynamic
|
|
// set's, a Jarl set's) rather than from the configuration.
|
|
func (s *AddressSet) Runtime() bool { return s.Dynamic || s.Jarl != nil }
|
|
|
|
// JarlSource is where a set's addresses come from in Jarl: the A and AAAA
|
|
// records of names (CNAMEs followed), each in a view, kept only where
|
|
// Within allows.
|
|
type JarlSource struct {
|
|
Names []JarlName `json:"names"`
|
|
// Within are zone names and addresses (prefixes, ranges): an address
|
|
// is used only if it lies in a zone's connected networks or in one of
|
|
// the addresses.
|
|
Within []string `json:"within"`
|
|
}
|
|
|
|
// JarlName is a name in Jarl, in a view ("": the only view that has it).
|
|
type JarlName struct {
|
|
Name string `json:"name"` // "nas.example.com", without the final dot
|
|
View string `json:"view,omitempty"`
|
|
}
|
|
|
|
// String is "name" or "name@view".
|
|
func (n JarlName) String() string {
|
|
if n.View == "" {
|
|
return n.Name
|
|
}
|
|
return n.Name + "@" + n.View
|
|
}
|
|
|
|
// ParseJarlName reads "name" or "name@view".
|
|
func ParseJarlName(s string) JarlName {
|
|
name, view, _ := strings.Cut(strings.TrimSpace(s), "@")
|
|
return JarlName{Name: strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), "."), View: strings.TrimSpace(view)}
|
|
}
|
|
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
|
|
}
|
|
|
|
// What becomes of what a rule's quota used when the rule changes
|
|
// (QuotaCarry).
|
|
const (
|
|
QuotaKept = "" // counted as before: carried as it is
|
|
QuotaSummed = "summed" // per address to a total: the addresses' sum
|
|
QuotaRegrouped = "regrouped" // per address in wider groups: added up per group
|
|
QuotaRestarts = "restarts" // can't be carried: starts again
|
|
)
|
|
|
|
// QuotaCarry says what becomes of what old's quota used when the rule
|
|
// becomes new (both the same rule, with quotas), and for QuotaRestarts
|
|
// why. The amount, over and the resets don't matter: only how it counts.
|
|
func QuotaCarry(old, new *Rule) (carry, why string) {
|
|
if old == nil || new == nil || old.Quota == nil || new.Quota == nil {
|
|
return QuotaKept, ""
|
|
}
|
|
was, is := old.Quota.PerAddress(), new.Quota.PerAddress()
|
|
words := map[string]string{"saddr": "per source", "daddr": "per destination", "": "in total"}
|
|
switch {
|
|
case was == "" && is == "":
|
|
return QuotaKept, ""
|
|
case is == "":
|
|
return QuotaSummed, ""
|
|
case was == "":
|
|
return QuotaRestarts, "a total can't be split by address, so each address starts at nothing"
|
|
case was != is:
|
|
return QuotaRestarts, "what was counted " + words[was] + " can't be counted " + words[is]
|
|
}
|
|
o4, o6 := old.Groups()
|
|
n4, n6 := new.Groups()
|
|
switch {
|
|
case o4 == n4 && o6 == n6:
|
|
return QuotaKept, ""
|
|
case n4 > o4 || n6 > o6:
|
|
return QuotaRestarts, "addresses are grouped more finely now, and a group's use can't be split"
|
|
}
|
|
return QuotaRegrouped, ""
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
// Ban list scopes: what a list's bans keep the banned addresses from.
|
|
const (
|
|
// BanScopeFirewall: the firewall itself (the input chain).
|
|
BanScopeFirewall = "firewall"
|
|
// BanScopeHosts: the list's hosts, behind the firewall (the forward
|
|
// chain, where port forwards' addresses are already translated).
|
|
BanScopeHosts = "hosts"
|
|
// BanScopeEverything: the firewall and everything behind it.
|
|
BanScopeEverything = "everything"
|
|
)
|
|
|
|
// BanScopes lists the scopes.
|
|
var BanScopes = []string{BanScopeFirewall, BanScopeHosts, BanScopeEverything}
|
|
|
|
// Ban lists' defaults.
|
|
const (
|
|
DefaultBanTime = 3600
|
|
DefaultBanMaxTime = 7 * 24 * 3600
|
|
DefaultBanEntries = 10000
|
|
DefaultBanGroupV6 = 64
|
|
)
|
|
|
|
// BanList is where a program such as Guard, or fail2ban, bans addresses
|
|
// (docs/bans.md). The program names only the address, and perhaps a
|
|
// protocol and ports; the list says what the address is kept from. The
|
|
// bans themselves are kept apart from the configuration, like a dynamic
|
|
// set's entries.
|
|
type BanList struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Scope string `json:"scope"`
|
|
// Hosts are set names or addresses: what scope hosts protects.
|
|
Hosts []string `json:"hosts,omitempty"`
|
|
// Never are set names or addresses the list never bans.
|
|
Never []string `json:"never,omitempty"`
|
|
// DefaultTime is a ban's length in seconds when none is asked for
|
|
// (0: an hour); MaxTime the longest (0: a week; -1: no limit, so
|
|
// bans may be permanent).
|
|
DefaultTime int `json:"default_time,omitempty"`
|
|
MaxTime int `json:"max_time,omitempty"`
|
|
// MaxEntries limits the bans the list holds (0: 10000).
|
|
MaxEntries int `json:"max_entries,omitempty"`
|
|
// GroupV6 is the IPv6 prefix length banned for an address (0: /64,
|
|
// what one customer usually gets); IPv4 addresses are banned alone.
|
|
GroupV6 int `json:"group_v6,omitempty"`
|
|
Comment string `json:"comment,omitempty"`
|
|
Disabled bool `json:"disabled,omitempty"`
|
|
}
|
|
|
|
func (b *BanList) GetID() int64 { return b.ID }
|
|
func (b *BanList) Label() string { return b.Name }
|
|
|
|
// BanTime is the default ban length in seconds.
|
|
func (b *BanList) BanTime() int {
|
|
if b.DefaultTime > 0 {
|
|
return b.DefaultTime
|
|
}
|
|
return DefaultBanTime
|
|
}
|
|
|
|
// BanMaxTime is the longest ban in seconds, 0 for no limit.
|
|
func (b *BanList) BanMaxTime() int {
|
|
switch {
|
|
case b.MaxTime < 0:
|
|
return 0
|
|
case b.MaxTime > 0:
|
|
return b.MaxTime
|
|
}
|
|
return DefaultBanMaxTime
|
|
}
|
|
|
|
// Entries is the most bans the list holds.
|
|
func (b *BanList) Entries() int {
|
|
if b.MaxEntries > 0 {
|
|
return b.MaxEntries
|
|
}
|
|
return DefaultBanEntries
|
|
}
|
|
|
|
// V6Group is the IPv6 prefix length an address is banned with.
|
|
func (b *BanList) V6Group() int {
|
|
if b.GroupV6 > 0 {
|
|
return b.GroupV6
|
|
}
|
|
return DefaultBanGroupV6
|
|
}
|
|
|
|
// Covers reports whether the list's bans apply to a chain.
|
|
func (b *BanList) Covers(chain string) bool {
|
|
switch b.Scope {
|
|
case BanScopeFirewall:
|
|
return chain == ChainInput
|
|
case BanScopeHosts:
|
|
return chain == ChainForward
|
|
case BanScopeEverything:
|
|
return chain == ChainInput || chain == ChainForward
|
|
}
|
|
return false
|
|
}
|