Files
housecarl/internal/importer/importer.go
T
abrown38andClaude Opus 5.5 34292eeebb 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>
2026-10-19 07:57:14 +01:00

1263 lines
37 KiB
Go

// Package importer turns an existing nftables ruleset (nft -j list
// ruleset) into Housecarl's configuration: zones, sets, services, rules,
// port forwards, source NAT and redirects, with the built-ins it
// recognises. What it can't express becomes a raw rule with the original
// text (from nft -a list ruleset), and every guess is explained in a note.
//
// The mapping keeps what the ruleset does: rules stay in order; a rule
// that looks like a built-in becomes the built-in only when it sits where
// the built-in goes (before every other rule of its chains), and stays a
// rule otherwise.
package importer
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"housecarl/internal/model"
)
// Input is what to import.
type Input struct {
// JSON is `nft -j list ruleset`.
JSON []byte
// Text is `nft -a list ruleset` (optional): the source of raw rules.
Text string
// Defines is the ruleset file (optional): its define lines name sets,
// services and zones.
Defines string
// Table is the table to import ("inet main"); empty: the only table
// that isn't Housecarl's.
Table string
}
// Result is the imported configuration and what to know about it.
type Result struct {
State *model.State
Notes []string
// Raw lists the rules that became raw rules.
Raw []string
}
// ---- nftables JSON
type jsonDoc struct {
Nftables []map[string]json.RawMessage `json:"nftables"`
}
type nftTable struct {
Family string `json:"family"`
Name string `json:"name"`
}
type nftChain struct {
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Type string `json:"type"`
Hook string `json:"hook"`
Prio int `json:"prio"`
Policy string `json:"policy"`
}
type nftRule struct {
Family string `json:"family"`
Table string `json:"table"`
Chain string `json:"chain"`
Handle int `json:"handle"`
Comment string `json:"comment"`
Expr []json.RawMessage `json:"expr"`
}
type nftSet struct {
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Type json.RawMessage `json:"type"`
Elem []json.RawMessage `json:"elem"`
}
type nftFlowtable struct {
Table string `json:"table"`
Name string `json:"name"`
Dev []string `json:"dev"`
}
type importer struct {
in Input
table nftTable
chains map[string]nftChain
rules map[string][]nftRule // by chain, in order
sets map[string]nftSet
flow []nftFlowtable
text map[int]string // rule text by handle
st *model.State
notes []string
raw []string
id int64
defines map[string]string // name → value text
addrNames map[string]string // canonical entry list → define name
portNames map[string]string // canonical port list → define name
ifNames map[string]string // interface → define name
used map[string]bool // sets created
noted map[string]bool
positions map[string]float64
}
func (im *importer) note(format string, args ...any) {
im.notes = append(im.notes, fmt.Sprintf(format, args...))
}
func (im *importer) newID() int64 { im.id++; return im.id }
// Import converts a ruleset.
func Import(in Input) (*Result, error) {
var doc jsonDoc
if err := json.Unmarshal(in.JSON, &doc); err != nil {
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
}
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{},
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}}
var tables []nftTable
for _, item := range doc.Nftables {
if raw, ok := item["table"]; ok {
var t nftTable
json.Unmarshal(raw, &t)
if !(t.Family == "inet" && t.Name == "housecarl") {
tables = append(tables, t)
}
}
}
switch {
case in.Table != "":
fam, name, _ := strings.Cut(strings.TrimSpace(in.Table), " ")
found := false
for _, t := range tables {
if t.Family == fam && t.Name == name {
im.table, found = t, true
}
}
if !found {
return nil, fmt.Errorf("there is no table %q in the ruleset (%s)", in.Table, tableList(tables))
}
case len(tables) == 0:
return nil, fmt.Errorf("the ruleset has no table to import")
case len(tables) > 1:
return nil, fmt.Errorf("the ruleset has several tables (%s): say which to import (--table %q)", tableList(tables), tables[0].Family+" "+tables[0].Name)
default:
im.table = tables[0]
}
ours := func(fam, table string) bool { return fam == im.table.Family && table == im.table.Name }
for _, item := range doc.Nftables {
switch {
case item["chain"] != nil:
var c nftChain
json.Unmarshal(item["chain"], &c)
if ours(c.Family, c.Table) {
im.chains[c.Name] = c
}
case item["rule"] != nil:
var r nftRule
json.Unmarshal(item["rule"], &r)
if ours(r.Family, r.Table) {
im.rules[r.Chain] = append(im.rules[r.Chain], r)
}
case item["set"] != nil:
var s nftSet
json.Unmarshal(item["set"], &s)
if ours(s.Family, s.Table) {
im.sets[s.Name] = s
}
case item["flowtable"] != nil:
var f nftFlowtable
json.Unmarshal(item["flowtable"], &f)
if f.Table == im.table.Name {
im.flow = append(im.flow, f)
}
}
}
im.readText()
im.readDefines()
im.importSets()
im.run()
im.nameForwards()
im.st.Renumber()
return &Result{State: im.st, Notes: im.notes, Raw: im.raw}, nil
}
func tableList(ts []nftTable) string {
var out []string
for _, t := range ts {
out = append(out, t.Family+" "+t.Name)
}
return strings.Join(out, ", ")
}
// readText maps rule handles to their text, from nft -a list ruleset.
var handleRe = regexp.MustCompile(`^\s*(.*?)\s*# handle (\d+)$`)
var counterRe = regexp.MustCompile(`counter packets \d+ bytes \d+`)
func (im *importer) readText() {
inTable := false
for _, l := range strings.Split(im.in.Text, "\n") {
if strings.HasPrefix(l, "table ") {
inTable = strings.HasPrefix(l, "table "+im.table.Family+" "+im.table.Name+" ")
continue
}
if !inTable {
continue
}
m := handleRe.FindStringSubmatch(l)
if m == nil || strings.HasSuffix(m[1], "{") {
continue
}
h, _ := strconv.Atoi(m[2])
im.text[h] = strings.TrimSpace(counterRe.ReplaceAllString(m[1], "counter"))
}
}
// ---- defines
var defineRe = regexp.MustCompile(`(?m)^\s*define\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`)
func (im *importer) readDefines() {
im.defines, im.addrNames, im.portNames, im.ifNames = map[string]string{}, map[string]string{}, map[string]string{}, map[string]string{}
for _, m := range defineRe.FindAllStringSubmatch(im.in.Defines, -1) {
im.defines[m[1]] = m[2]
}
expand := func(v string) []string {
var out []string
for _, part := range strings.Split(strings.Trim(strings.TrimSpace(v), "{}"), ",") {
part = strings.Trim(strings.TrimSpace(part), `"`)
if strings.HasPrefix(part, "$") {
if d, ok := im.defines[part[1:]]; ok {
for _, x := range strings.Split(strings.Trim(strings.TrimSpace(d), "{}"), ",") {
out = append(out, strings.Trim(strings.TrimSpace(x), `"`))
}
continue
}
}
if part != "" {
out = append(out, part)
}
}
return out
}
names := make([]string, 0, len(im.defines))
for n := range im.defines {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
vals := expand(im.defines[n])
if len(vals) == 0 {
continue
}
var addrs, ports []string
for _, v := range vals {
if a, err := model.NormalizeAddress(v); err == nil {
addrs = append(addrs, a)
} else if p, err := model.NormalizePorts(v); err == nil {
ports = append(ports, p)
}
}
switch {
case len(addrs) == len(vals):
// A set for a list, or for one prefix; one address stays as it is.
if len(addrs) > 1 || strings.Contains(addrs[0], "/") {
if _, taken := im.addrNames[key(addrs)]; !taken {
im.addrNames[key(addrs)] = n
}
}
case len(ports) == len(vals) && len(ports) > 1:
im.portNames[key(ports)] = n
case len(vals) == 1 && validIface(vals[0]):
im.ifNames[vals[0]] = n
}
}
}
func key(vals []string) string {
s := append([]string(nil), vals...)
sort.Strings(s)
return strings.Join(s, ",")
}
var ifaceRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.@-]{0,14}\*?$`)
func validIface(s string) bool {
return ifaceRe.MatchString(s) && !strings.ContainsAny(s, ".:") || s == "lo"
}
// ---- sets
// importSets turns the table's named address sets into sets.
func (im *importer) importSets() {
names := make([]string, 0, len(im.sets))
for n := range im.sets {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
s := im.sets[n]
var typ string
json.Unmarshal(s.Type, &typ)
if typ != "ipv4_addr" && typ != "ipv6_addr" {
im.note("set %s (%s) isn't an address set: not imported; rules using it become raw rules", n, string(s.Type))
continue
}
var entries []string
for _, e := range s.Elem {
entries = append(entries, values(e)...)
}
if existing := im.st.Set(n); existing != nil {
existing.Entries = append(existing.Entries, entries...)
continue
}
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: setName(n), Entries: entries})
im.used[n] = true
}
}
var nameRe = regexp.MustCompile(`[^A-Za-z0-9_-]+`)
func setName(s string) string {
s = nameRe.ReplaceAllString(s, "_")
if s == "" || !(s[0] >= 'A' && s[0] <= 'Z' || s[0] >= 'a' && s[0] <= 'z') {
s = "s_" + s
}
if len(s) > 48 {
s = s[:48]
}
return s
}
// namedSet returns a set name for a list of address entries: a define's
// name if one matches, else none ("" keeps them literal).
func (im *importer) namedSet(entries []string) string {
n, ok := im.addrNames[key(entries)]
if !ok {
return ""
}
name := setName(n)
if !im.used[name] {
im.used[name] = true
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Entries: append([]string(nil), entries...)})
}
return name
}
// zone returns the zone of an interface, making it on first use.
func (im *importer) zone(iface string) string {
for _, z := range im.st.Zones {
for _, i := range z.Interfaces {
if i == iface {
return z.Name
}
}
}
name := iface
if d, ok := im.ifNames[iface]; ok {
name = d
for _, suf := range []string{"_interface", "_iface", "_int", "_if", "_dev"} {
name = strings.TrimSuffix(name, suf)
}
}
name = setName(strings.TrimSuffix(name, "*"))
if im.st.Zone(name) != nil {
name = setName(iface + "_zone")
}
im.st.Zones = append(im.st.Zones, &model.Zone{ID: im.newID(), Name: name, Interfaces: []string{iface}})
return name
}
func (im *importer) external(iface string) {
if z := im.st.Zone(im.zone(iface)); z != nil && !z.External {
z.External = true
}
}
// ---- expressions
// values reads a right-hand value: an address, prefix, range, number,
// string, or an anonymous set of them, as entries.
func values(raw json.RawMessage) []string {
var s string
if json.Unmarshal(raw, &s) == nil {
return []string{s}
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
return []string{n.String()}
}
var list []json.RawMessage
if json.Unmarshal(raw, &list) == nil {
var out []string
for _, x := range list {
out = append(out, values(x)...)
}
return out
}
var obj map[string]json.RawMessage
if json.Unmarshal(raw, &obj) != nil {
return nil
}
if set, ok := obj["set"]; ok {
return values(set)
}
if p, ok := obj["prefix"]; ok {
var x struct {
Addr string `json:"addr"`
Len int `json:"len"`
}
json.Unmarshal(p, &x)
return []string{fmt.Sprintf("%s/%d", x.Addr, x.Len)}
}
if r, ok := obj["range"]; ok {
var x []json.RawMessage
json.Unmarshal(r, &x)
if len(x) == 2 {
a, b := values(x[0]), values(x[1])
if len(a) == 1 && len(b) == 1 {
return []string{a[0] + "-" + b[0]}
}
}
}
if e, ok := obj["elem"]; ok {
var x struct {
Val json.RawMessage `json:"val"`
}
json.Unmarshal(e, &x)
return values(x.Val)
}
return nil
}
// parsed is one rule, read.
type parsed struct {
inIf, outIf []string
notIn, notOut bool
src, dst []string // entries, or "@set"
notSrc, notDst bool
family string
proto string
dports []string
icmp []string
icmpProto string
ctState, ctStatus []string
hop255 bool
tcpSyn bool
limit *model.Limit
log bool
logPrefix string
verdict string // accept, drop, reject, jump, goto, return
rejectWith string
target string
dnatAddr, dnatPort string
snatAddr string
masq bool
redirect string
mss, flowAdd bool
unsupported []string
comment string
handle int
}
type match struct {
Op string `json:"op"`
Left json.RawMessage `json:"left"`
Right json.RawMessage `json:"right"`
}
func parse(r nftRule) *parsed {
p := &parsed{comment: r.Comment, handle: r.Handle}
for _, raw := range r.Expr {
var e map[string]json.RawMessage
json.Unmarshal(raw, &e)
for k, v := range e {
p.expr(k, v)
}
}
return p
}
func (p *parsed) bad(what string) { p.unsupported = append(p.unsupported, what) }
func (p *parsed) expr(k string, v json.RawMessage) {
switch k {
case "match":
var m match
json.Unmarshal(v, &m)
p.match(m)
case "counter":
case "accept", "drop", "return":
p.verdict = k
case "jump", "goto":
var t struct{ Target string }
json.Unmarshal(v, &t)
p.verdict, p.target = k, t.Target
case "reject":
p.verdict = "reject"
var x struct {
Type string `json:"type"`
}
json.Unmarshal(v, &x)
if x.Type == "tcp reset" {
p.rejectWith = "tcp-reset"
}
case "log":
var x struct {
Prefix string `json:"prefix"`
}
json.Unmarshal(v, &x)
p.log, p.logPrefix = true, x.Prefix
case "limit":
var x struct {
Rate int `json:"rate"`
Per string `json:"per"`
Burst int `json:"burst"`
Inv bool `json:"inv"`
Unit string `json:"rate_unit"`
}
json.Unmarshal(v, &x)
if x.Unit != "" && x.Unit != "packets" {
p.bad("a byte rate limit")
return
}
p.limit = &model.Limit{Rate: x.Rate, Per: x.Per, Burst: x.Burst, Over: x.Inv}
case "dnat", "snat":
var x struct {
Family string `json:"family"`
Addr json.RawMessage `json:"addr"`
Port json.RawMessage `json:"port"`
}
json.Unmarshal(v, &x)
addr, port := values(x.Addr), values(x.Port)
if len(addr) != 1 || len(port) > 1 {
p.bad(k + " to a range or map")
return
}
if k == "dnat" {
p.dnatAddr = addr[0]
if len(port) == 1 {
p.dnatPort = port[0]
}
} else {
p.snatAddr = addr[0]
}
case "masquerade":
if string(v) != "null" {
p.bad("masquerade with options")
}
p.masq = true
case "redirect":
var x struct {
Port json.RawMessage `json:"port"`
}
json.Unmarshal(v, &x)
if port := values(x.Port); len(port) == 1 {
p.redirect = port[0]
} else {
p.redirect = "?"
}
case "mangle":
if strings.Contains(string(v), `"maxseg"`) && strings.Contains(string(v), `"mtu"`) {
p.mss = true
} else {
p.bad("mangle")
}
case "flow":
p.flowAdd = true
default:
p.bad(k)
}
}
func (p *parsed) match(m match) {
var left map[string]json.RawMessage
json.Unmarshal(m.Left, &left)
not := m.Op == "!="
if m.Op != "==" && m.Op != "!=" && m.Op != "in" {
p.bad("the operator " + m.Op)
return
}
right := values(m.Right)
if meta, ok := left["meta"]; ok {
var x struct{ Key string }
json.Unmarshal(meta, &x)
switch x.Key {
case "iifname":
p.inIf, p.notIn = right, not
case "oifname":
p.outIf, p.notOut = right, not
case "nfproto":
if len(right) == 1 && !not {
p.family = right[0]
} else {
p.bad("meta nfproto")
}
case "l4proto":
if len(right) == 1 && !not {
p.proto = right[0]
} else {
p.bad("meta l4proto with several protocols")
}
default:
p.bad("meta " + x.Key)
}
return
}
if pl, ok := left["payload"]; ok {
var x struct{ Protocol, Field string }
json.Unmarshal(pl, &x)
switch {
case (x.Protocol == "ip" || x.Protocol == "ip6") && (x.Field == "saddr" || x.Field == "daddr"):
if p.family == "" {
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
}
if x.Field == "saddr" {
p.src, p.notSrc = right, not
} else {
p.dst, p.notDst = right, not
}
case (x.Protocol == "tcp" || x.Protocol == "udp" || x.Protocol == "sctp") && x.Field == "dport" && !not:
p.proto, p.dports = x.Protocol, right
case x.Protocol == "tcp" && x.Field == "flags" && len(right) == 1 && right[0] == "syn":
p.tcpSyn = true
case (x.Protocol == "icmp" || x.Protocol == "icmpv6") && x.Field == "type" && !not:
p.icmpProto, p.icmp = x.Protocol, right
case x.Protocol == "ip6" && x.Field == "hoplimit" && len(right) == 1 && right[0] == "255":
p.hop255 = true
default:
p.bad(x.Protocol + " " + x.Field)
}
return
}
if ct, ok := left["ct"]; ok {
var x struct{ Key string }
json.Unmarshal(ct, &x)
switch {
case x.Key == "state" && !not:
p.ctState = right
case x.Key == "status" && !not:
p.ctStatus = right
default:
p.bad("ct " + x.Key)
}
return
}
for k := range left {
p.bad(k)
}
}
// ---- converting
func (im *importer) run() {
type base struct {
name, kind string
}
var bases []base
for name, c := range im.chains {
switch {
case c.Type == "filter" && c.Hook == "prerouting":
bases = append(bases, base{name, model.ChainEarly})
case c.Type == "filter" && (c.Hook == "input" || c.Hook == "forward" || c.Hook == "output"):
bases = append(bases, base{name, c.Hook})
case c.Type == "nat" && c.Hook == "prerouting":
bases = append(bases, base{name, "dstnat"})
case c.Type == "nat" && c.Hook == "postrouting":
bases = append(bases, base{name, "srcnat"})
case c.Hook != "":
im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook)
}
set := &im.st.Settings
switch {
case c.Hook == "input" && c.Type == "filter":
set.InputPolicy = c.Policy
case c.Hook == "forward" && c.Type == "filter":
set.ForwardPolicy = c.Policy
case c.Hook == "output" && c.Type == "filter":
set.OutputPolicy = c.Policy
}
}
// NAT first: the filter chains' port-forward accepts are folded into
// the port forwards.
order := map[string]int{"dstnat": 0, "srcnat": 1, model.ChainEarly: 2, model.ChainInput: 3, model.ChainForward: 4, model.ChainOutput: 5}
sort.SliceStable(bases, func(i, j int) bool { return order[bases[i].kind] < order[bases[j].kind] })
im.st.Settings.Builtins = model.Builtins{}
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later"
var deferred []func()
for _, b := range bases {
rules := im.flatten(b.name, nil, 0)
switch b.kind {
case "dstnat":
for _, p := range rules {
im.dstnat(p)
}
case "srcnat":
for _, p := range rules {
im.srcnat(p)
}
default:
prefix := true
for _, p := range rules {
p := p
if bi := im.builtinOf(b.kind, p); bi != "" {
if found[bi] == nil {
found[bi] = map[string]string{}
}
where := "later"
if prefix {
where = "prefix"
}
found[bi][b.kind] = where
if prefix {
continue
}
}
prefix = false
kind := b.kind
deferred = append(deferred, func() { im.rule(kind, p) })
}
}
}
im.settleBuiltins(found)
for _, f := range deferred {
f()
}
if len(im.flow) > 0 {
im.st.Settings.FlowtableDevices = im.flow[0].Dev
}
im.st.Sort()
}
// flatten reads a chain's rules, inlining jumps: each rule of the
// target chain gets the jump's own matches in front.
func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
var out []*parsed
for _, r := range im.rules[chain] {
p := parse(r)
if prefix != nil {
p = im.merge(prefix, p)
}
switch p.verdict {
case "jump", "goto":
if depth > 4 || im.chains[p.target].Hook != "" {
p.bad("a jump too deep")
out = append(out, p)
continue
}
jumpMatch := *p
jumpMatch.verdict, jumpMatch.target = "", ""
out = append(out, im.flatten(p.target, &jumpMatch, depth+1)...)
if p.verdict == "goto" {
im.note("the goto to %s became the same rules followed by the rest of %s: check the order", p.target, chain)
}
continue
case "return":
if prefix != nil && len(p.unsupported) == 0 && !hasMatch(p, prefix) {
return out // an unconditional return ends the jumped-to chain
}
p.bad("a conditional return")
}
out = append(out, p)
}
return out
}
// hasMatch reports whether p matches more than the jump's own matches.
func hasMatch(p, prefix *parsed) bool {
return len(p.dports) != len(prefix.dports) || len(p.src) != len(prefix.src) || len(p.dst) != len(prefix.dst) ||
len(p.inIf) != len(prefix.inIf) || len(p.outIf) != len(prefix.outIf) || len(p.ctState) != len(prefix.ctState) || len(p.icmp) != len(prefix.icmp)
}
// merge puts a jump's matches in front of a jumped-to rule's. When both
// match the same field, the rule's own (narrower, usually) is kept and
// noted.
func (im *importer) merge(prefix, p *parsed) *parsed {
q := *p
q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...)
fill := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
if len(*dst) == 0 && len(src) > 0 {
*dst, *not = src, srcNot
} else if len(*dst) > 0 && len(src) > 0 && key(*dst) != key(src) {
im.note("rule handle %d: both the jump to it and the rule match %s; the rule's own (%s) are kept, the jump's (%s) dropped: check it",
p.handle, what, strings.Join(*dst, ", "), strings.Join(src, ", "))
}
}
fill("interfaces in", &q.inIf, &q.notIn, prefix.inIf, prefix.notIn)
fill("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut)
fill("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc)
fill("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst)
if q.family == "" {
q.family = prefix.family
}
if len(q.ctState) == 0 {
q.ctState = prefix.ctState
}
if q.proto == "" {
q.proto, q.dports = prefix.proto, prefix.dports
}
return &q
}
// ---- built-ins
var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-big": true, "time-exceeded": true, "parameter-problem": true, "echo-request": true, "echo-reply": true}
var nd6 = map[string]bool{"nd-router-solicit": true, "nd-router-advert": true, "nd-neighbor-solicit": true, "nd-neighbor-advert": true}
func plain(p *parsed) bool {
return len(p.unsupported) == 0 && p.limit == nil && !p.log && len(p.src) == 0 && len(p.dst) == 0 && len(p.outIf) == 0
}
// builtinOf names the built-in a rule is, or "".
func (im *importer) builtinOf(chain string, p *parsed) string {
only := func(items []string, allowed map[string]bool) bool {
for _, i := range items {
if !allowed[i] {
return false
}
}
return len(items) > 0
}
switch {
case chain == model.ChainInput && p.verdict == "accept" && len(p.inIf) == 1 && p.inIf[0] == "lo" && !p.notIn && plain(p) && p.proto == "":
return "loopback"
case p.verdict == "accept" && key(p.ctState) == "established,related" && plain(p) && len(p.inIf) == 0 && p.proto == "":
return "established"
case p.verdict == "drop" && key(p.ctState) == "invalid" && plain(p) && len(p.inIf) == 0 && p.proto == "":
return "invalid"
case chain == model.ChainInput && p.verdict == "accept" && p.icmpProto == "icmpv6" && only(p.icmp, essentials6) && plain(p) && len(p.inIf) == 0 && !p.hop255:
return "icmpv6"
case chain == model.ChainInput && p.verdict == "accept" && p.icmpProto == "icmpv6" && only(p.icmp, nd6) && p.hop255 && plain(p) && len(p.inIf) == 0:
return "icmpv6-nd"
case chain == model.ChainInput && p.verdict == "accept" && p.icmpProto == "icmp" && only(p.icmp, map[string]bool{"echo-request": true, "echo-reply": true}) &&
plain(p) && p.notIn && len(p.inIf) > 0:
for _, i := range p.inIf {
im.external(i)
}
return "ping"
case chain == model.ChainInput && p.verdict == "accept" && p.proto == "udp" && key(p.dports) == "546" && len(p.inIf) > 0 && !p.notIn &&
p.limit == nil && !p.log && len(p.dst) == 0 && key(p.src) == "fe80::/10":
for _, i := range p.inIf {
im.external(i)
}
return "dhcpv6-client"
case chain == model.ChainForward && p.mss && p.tcpSyn && len(p.outIf) > 0 && !p.notOut:
for _, i := range p.outIf {
im.external(i)
}
return "mss-clamp"
case chain == model.ChainForward && p.flowAdd:
return "flowtable"
case chain == model.ChainEarly && p.verdict == "drop" && len(p.inIf) > 0 && !p.notIn && len(p.src) > 0 && !p.notSrc &&
len(p.dst) == 0 && p.proto == "" && len(p.unsupported) == 0:
for _, i := range p.inIf {
im.external(i)
}
for _, s := range p.src {
if strings.HasPrefix(s, "@") {
return ""
}
}
set := &im.st.Settings
if p.family == "ipv6" {
set.OwnPrefixes = append(set.OwnPrefixes, p.src...)
im.st.Settings.AntispoofV6 = nil
} else {
set.AntispoofV4 = p.src
}
return "antispoof"
}
return ""
}
// settleBuiltins turns on the built-ins found where they go, in every
// chain they apply to; the others stay off, and the rules that looked
// like them are kept as rules.
func (im *importer) settleBuiltins(found map[string]map[string]string) {
b := &im.st.Settings.Builtins
on := func(name string, chains ...string) bool {
for _, c := range chains {
if found[name][c] != "prefix" {
return false
}
}
return true
}
b.Loopback = on("loopback", model.ChainInput)
b.Established = on("established", model.ChainInput, model.ChainForward)
b.DropInvalid = on("invalid", model.ChainInput, model.ChainForward)
b.ICMPv6 = on("icmpv6", model.ChainInput) && on("icmpv6-nd", model.ChainInput)
b.PingInternal = on("ping", model.ChainInput)
b.DHCPv6Client = on("dhcpv6-client", model.ChainInput)
b.MSSClamp = on("mss-clamp", model.ChainForward)
b.Antispoof = on("antispoof", model.ChainEarly)
if !b.Antispoof {
im.st.Settings.AntispoofV4, im.st.Settings.AntispoofV6, im.st.Settings.OwnPrefixes = nil, nil, nil
} else if im.st.Settings.AntispoofV6 == nil {
im.st.Settings.AntispoofV6 = []string{}
}
if b.ICMPv6 {
im.note("the ICMPv6 built-in also accepts MLD from link-local addresses, and lets ICMPv6 errors through the forward chain (RFC 4890)")
}
// The ones found in one chain's head but not another's: explicit rules
// in the chains where they were found.
for name, chains := range found {
for chain, where := range chains {
if where != "prefix" {
continue
}
kept := map[string]bool{"loopback": b.Loopback, "established": b.Established, "invalid": b.DropInvalid, "icmpv6": b.ICMPv6, "icmpv6-nd": b.ICMPv6,
"ping": b.PingInternal, "dhcpv6-client": b.DHCPv6Client, "mss-clamp": b.MSSClamp, "antispoof": b.Antispoof, "flowtable": true}[name]
if !kept {
im.note("%s in the %s chain stays a rule (it isn't in every chain the built-in covers, or comes after other rules)", name, chain)
im.builtinRule(name, chain)
}
}
}
}
// builtinRule writes a built-in that stays off as a rule of its chain.
func (im *importer) builtinRule(name, chain string) {
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain) - 1000}
switch name {
case "established":
r.CtState, r.Action = []string{"established", "related"}, model.ActionAccept
case "invalid":
r.CtState, r.Action = []string{"invalid"}, model.ActionDrop
case "loopback":
r.Raw, r.Action = `iifname "lo" accept`, model.ActionAccept
default:
im.note("built-in %s of the %s chain couldn't be kept; add it by hand", name, chain)
return
}
im.st.Rules = append(im.st.Rules, r)
}
func (im *importer) nextPos(chain string) float64 {
im.positions[chain]++
return im.positions[chain]
}
// ---- rules
// addrRefs turns address entries into references: "@set" → the set, a
// list matching a define → its set, else the literal entries.
func (im *importer) addrRefs(entries []string) []string {
var out, lit []string
for _, e := range entries {
if strings.HasPrefix(e, "@") {
out = append(out, setName(e[1:]))
continue
}
if n, err := model.NormalizeAddress(e); err == nil {
lit = append(lit, n)
} else {
lit = append(lit, e)
}
}
if len(lit) > 0 {
if name := im.namedSet(lit); name != "" {
out = append(out, name)
} else {
out = append(out, lit...)
}
}
return out
}
func (im *importer) services(p *parsed) ([]string, bool) {
var out []string
if p.icmpProto != "" {
for _, t := range p.icmp {
out = append(out, p.icmpProto+"/"+t)
}
}
if p.proto != "" {
if len(p.dports) == 0 {
if p.proto != "tcp" && p.proto != "udp" && p.proto != "sctp" {
return nil, false
}
return append(out, p.proto+"/1-65535"), true
}
var ports []string
for _, d := range p.dports {
if strings.HasPrefix(d, "@") {
return nil, false
}
n, err := model.NormalizePorts(d)
if err != nil {
return nil, false
}
ports = append(ports, n)
}
if name, ok := im.portNames[key(ports)]; ok {
svc := setName(name)
if im.st.Service(svc) == nil || im.st.Service(svc).Builtin {
var entries []string
for _, x := range ports {
entries = append(entries, p.proto+"/"+x)
}
im.st.Services = append(im.st.Services, &model.Service{ID: im.newID(), Name: svc, Entries: entries})
}
return append(out, svc), true
}
for _, x := range ports {
out = append(out, p.proto+"/"+x)
}
}
return out, true
}
func (im *importer) zones(ifaces []string) []string {
var out []string
for _, i := range ifaces {
out = append(out, im.zone(i))
}
return out
}
// toMatch builds a Match; ok is false when something can't be said.
func (im *importer) toMatch(p *parsed) (model.Match, bool) {
svcs, ok := im.services(p)
if !ok || len(p.unsupported) > 0 {
return model.Match{}, false
}
m := model.Match{InZones: im.zones(p.inIf), NotIn: p.notIn, OutZones: im.zones(p.outIf), NotOut: p.notOut,
Sources: im.addrRefs(p.src), NotSources: p.notSrc, Destinations: im.addrRefs(p.dst), NotDests: p.notDst, Services: svcs}
if len(p.src) == 0 && len(p.dst) == 0 && p.icmpProto == "" && p.family != "" {
m.Family = p.family
}
return m, true
}
// rule converts a filter rule (or keeps it raw).
func (im *importer) rule(chain string, p *parsed) {
if chain == model.ChainForward && im.absorbForward(p) {
return
}
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain), Comment: p.comment, CtState: p.ctState, CtStatus: p.ctStatus,
Limit: p.limit, Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
switch p.verdict {
case "accept", "drop", "reject":
r.Action = p.verdict
case "":
if !p.log {
im.note("a %s rule with no verdict (handle %d) was left out", chain, p.handle)
return
}
r.Action = model.ActionLog
default:
p.bad(p.verdict)
}
m, ok := im.toMatch(p)
if ok && !p.hop255 && !p.mss && !p.tcpSyn && !p.flowAdd && p.dnatAddr == "" && !p.masq {
r.Match = m
im.st.Rules = append(im.st.Rules, r)
return
}
im.rawRule(r, p)
}
func (im *importer) rawRule(r *model.Rule, p *parsed) {
text, ok := im.text[p.handle]
if !ok {
im.note("rule handle %d of chain %s couldn't be expressed (%s), and there is no text to keep it raw: add it by hand (import with the nft -a list ruleset text)", p.handle, r.Chain, strings.Join(p.unsupported, ", "))
return
}
// The original's comment goes into Housecarl's.
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
text = strings.TrimSpace(text[:i])
}
if r.Action == "" {
r.Action = model.ActionAccept
}
r.Match, r.Raw = model.Match{}, text
im.st.Rules = append(im.st.Rules, r)
why := strings.Join(p.unsupported, ", ")
if why == "" {
why = "a match Housecarl has no field for"
}
im.raw = append(im.raw, fmt.Sprintf("%s: %s (%s)", r.Chain, text, why))
if strings.Contains(text, "@") {
im.note("raw rule %q names a set of the old table: Housecarl's sets are NAME_v4 and NAME_v6", text)
}
}
// ---- NAT
func (im *importer) dstnat(p *parsed) {
switch {
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) == 1 && !p.notDst && !p.notSrc:
ports, err := model.NormalizePorts(p.dports[0])
if err != nil {
break
}
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
if f.ToPort == ports {
f.ToPort = ""
}
f.PublicAddresses = im.addrRefs(p.dst)
f.Sources = im.addrRefs(p.src)
for _, i := range p.inIf {
im.external(i)
}
// The same forward for another protocol: one forward, both.
for _, o := range im.st.Forwards {
if o.Ports == f.Ports && o.To == f.To && o.ToPort == f.ToPort && key(o.PublicAddresses) == key(f.PublicAddresses) && key(o.Sources) == key(f.Sources) {
o.Protocols = append(o.Protocols, p.proto)
return
}
}
f.ID, f.Position = im.newID(), float64(len(im.st.Forwards)+1)
im.st.Forwards = append(im.st.Forwards, f)
return
case p.redirect != "" && p.redirect != "?" && len(p.unsupported) == 0:
m, ok := im.toMatch(p)
if ok {
im.st.Redirects = append(im.st.Redirects, &model.Redirect{ID: im.newID(), Position: float64(len(im.st.Redirects) + 1), Match: m, ToPort: p.redirect, Comment: p.comment})
return
}
}
im.note("the dstnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
}
var forwardWords = regexp.MustCompile(`(?i)^\s*port\s*forward(ing)?\s*[:-]?\s*`)
func forwardName(comment string) string {
n := strings.TrimSpace(forwardWords.ReplaceAllString(comment, ""))
return strings.Trim(strings.ToLower(nameRe.ReplaceAllString(n, "-")), "-")
}
// nameForwards makes the forwards' names (from their comments) valid and
// unique: "8096 to jellyfin" becomes "fwd-8096-to-jellyfin", a second
// "web" becomes "web-2".
func (im *importer) nameForwards() {
seen := map[string]bool{}
for _, f := range im.st.Forwards {
n := f.Name
if n == "" {
continue
}
if !(n[0] >= 'a' && n[0] <= 'z') {
n = "fwd-" + n
}
if len(n) > 44 {
n = strings.TrimRight(n[:44], "-")
}
base := n
for i := 2; seen[n]; i++ {
n = fmt.Sprintf("%s-%d", base, i)
}
seen[n] = true
f.Name = n
}
}
func (im *importer) srcnat(p *parsed) {
if len(p.unsupported) > 0 {
im.note("the srcnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
return
}
// Hairpin: masquerading connections that were port forwarded, to the
// forwards' hosts.
if p.masq && key(p.ctStatus) == "dnat" && len(p.dst) > 0 && !p.notDst {
matched := 0
for _, f := range im.st.Forwards {
for _, d := range p.dst {
if d == f.To {
f.Hairpin, f.HairpinSources = true, im.addrRefs(p.src)
if p.notSrc {
f.HairpinSources = nil
im.note("hairpin for %s: the original excluded sources; Housecarl's hairpin covers all inside sources", f.Label())
}
matched++
}
}
}
if matched > 0 {
return
}
}
m, ok := im.toMatch(p)
if !ok || !p.masq && p.snatAddr == "" {
im.note("the srcnat rule handle %d couldn't be imported: %s", p.handle, im.text[p.handle])
return
}
for _, i := range p.outIf {
if !p.notOut && p.masq {
im.external(i)
}
}
if p.family == "ipv6" && len(p.src) == 0 {
m.Family = model.FamilyIPv6
}
for _, s := range p.src {
if a, err := model.ParseAddress(s); err == nil && a.IsV6() {
m.Family = model.FamilyIPv6
}
}
im.st.SNATs = append(im.st.SNATs, &model.SourceNAT{ID: im.newID(), Position: float64(len(im.st.SNATs) + 1), Match: m, To: p.snatAddr, Comment: p.comment})
}
// absorbForward folds a port forward's own forward-chain accept (and an
// IPv6 pinhole on its port) into the port forward.
func (im *importer) absorbForward(p *parsed) bool {
if p.verdict != "accept" || len(p.unsupported) > 0 || len(p.dst) != 1 || p.notDst || len(p.dports) != 1 || p.proto == "" || len(p.src) > 0 || len(p.outIf) > 0 {
return false
}
port, err := model.NormalizePorts(p.dports[0])
if err != nil {
return false
}
dst := p.dst[0]
if p.family == "ipv4" || !strings.Contains(dst, ":") {
for _, f := range im.st.Forwards {
inside := f.ToPort
if inside == "" {
inside = f.Ports
}
if f.To == dst && inside == port && contains(f.Protocols, p.proto) {
if len(p.inIf) > 0 && !im.noted["forward-iif"] {
im.noted["forward-iif"] = true
im.note("port forwards' own accepts aren't limited to arriving on %s any more: they only let port-forwarded connections through (ct status dnat), which hairpin needs from inside too", strings.Join(p.inIf, ", "))
}
return true
}
}
return false
}
// IPv6: a pinhole for the one forward on that port.
var cands []*model.PortForward
for _, f := range im.st.Forwards {
inside := f.ToPort
if inside == "" {
inside = f.Ports
}
if inside == port && contains(f.Protocols, p.proto) && f.To6 == "" {
cands = append(cands, f)
}
}
if len(cands) == 1 && len(p.ctStatus) == 0 {
cands[0].To6 = dst
im.note("the IPv6 rule to %s port %s became %s's IPv6 pinhole", dst, port, cands[0].Label())
return true
}
return false
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}