Files
housecarl/internal/importer/importer.go
T
abrown38andClaude Opus 5.5 9d766b9823 Boot fallback, flowtable devices that come and go, healthz, renames
The boot unit fails closed: if ruleset.nft doesn't load, it loads
ruleset-fallback.nft (render.Fallback), saved beside it from the same
configuration: nothing new in from external zones, established, the
ICMPv6 IPv6 needs and DHCPv6 replies let in, the inside left alone and
masqueraded, and the unit marked failed with a critical message.
housecarld replaces it when it applies; status, the dashboard and
/healthz say while it is in force. no_external_zone now also says the
fallback closes everything without an external zone.

The boot ruleset has no flowtable: nftables on Debian 13's kernel
refuses one naming a device that doesn't exist yet (ppp0 before PPPoE),
and with it the whole file. internal/linkwatch listens to rtnetlink link
messages; housecarld applies again when a flowtable device appears or
comes back as a new interface (by ifindex), under the engine's lock.
docs/flowtable.md: what it does, when to use it and when not, how.

GET /healthz reports firewall (applied, unconfigured, fallback,
missing), changes on probation and the machine's warnings, with 503
unless in force, read at most every five seconds.

Renaming a zone, set or service changes what names it in the same
change (one history entry, one undo); dynamic sets keep their names.
The importer keeps a chain's head in order: once a would-be built-in
stays a rule, the ones after it do too, so the maintainer's ICMPv6
accepts stay behind the invalid drop as written. hc settings set
--flowtable none empties the list.

make test (nft 1.1.3) and make e2e (242 checks, with VLAN
sub-interfaces, the fallback and the flowtable) pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 23:16:46 +01:00

1672 lines
49 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"
"net/netip"
"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
// Table is the table imported ("inet main").
Table string
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"`
}
// nftMap is a named map: its elements are [key, value] pairs.
type nftMap struct {
Family string `json:"family"`
Table string `json:"table"`
Name string `json:"name"`
Type json.RawMessage `json:"type"`
Map json.RawMessage `json:"map"`
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
maps map[string]nftMap
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
meters map[string]bool // sets used as meters
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{}, maps: map[string]nftMap{},
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["map"] != nil:
var m nftMap
json.Unmarshal(item["map"], &m)
if ours(m.Family, m.Table) {
im.maps[m.Name] = m
}
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.findMeters()
im.importSets()
im.run()
im.nameForwards()
im.st.Renumber()
return &Result{State: im.st, Table: im.table.Family + " " + im.table.Name, 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.
// findMeters notes the sets rules use as meters (set statements: "update
// @m { ip saddr limit ... }"): they hold per-address state, not
// addresses, and become the rules' per-address limits and quotas.
func (im *importer) findMeters() {
im.meters = map[string]bool{}
for _, rules := range im.rules {
for _, r := range rules {
for _, e := range r.Expr {
var x struct {
Set *struct {
Set string `json:"set"`
} `json:"set"`
}
if json.Unmarshal(e, &x) == nil && x.Set != nil {
im.meters[strings.TrimPrefix(x.Set.Set, "@")] = true
}
}
}
}
}
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 {
if im.meters[n] {
continue
}
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
quota *model.Quota
connLimit int
// group4, group6: a meter's key grouped by a prefix (ip saddr and
// 255.255.255.0); 0 for none.
group4, group6 int
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
// mapUse: a dnat to a map, or a verdict map, still to be expanded
// into one rule per element (expandMaps).
mapUse *mapUse
// name: a port forward's name, when the rule's comment won't do
// (one of a map's elements).
name string
}
// mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or
// "tcp dport vmap { 22 : accept }".
type mapUse struct {
kind string // dnat or vmap
key json.RawMessage // what is looked up: {"payload": ...}, {"meta": ...}
data json.RawMessage // "@name", or {"set": [[key, value], ...]}
}
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":
l, ok := limitJSON(v)
if !ok {
p.bad("a limit Housecarl can't read")
return
}
p.limit = l
case "quota":
q, ok := quotaJSON(v)
if !ok {
p.bad("a quota Housecarl can't read")
return
}
p.quota = q
case "set":
p.meter(v)
case "dnat", "snat":
var x struct {
Family string `json:"family"`
Addr json.RawMessage `json:"addr"`
Port json.RawMessage `json:"port"`
}
json.Unmarshal(v, &x)
var lookup struct {
Map *struct {
Key json.RawMessage `json:"key"`
Data json.RawMessage `json:"data"`
} `json:"map"`
}
if k == "dnat" && json.Unmarshal(x.Addr, &lookup) == nil && lookup.Map != nil && len(x.Port) == 0 {
p.mapUse = &mapUse{kind: "dnat", key: lookup.Map.Key, data: lookup.Map.Data}
return
}
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
case "vmap":
var x struct {
Key json.RawMessage `json:"key"`
Data json.RawMessage `json:"data"`
}
json.Unmarshal(v, &x)
p.mapUse = &mapUse{kind: "vmap", key: x.Key, data: x.Data}
default:
p.bad(k)
}
}
// limitJSON reads a limit: packets, or bytes (rate_unit, burst_unit).
func limitJSON(v json.RawMessage) (*model.Limit, bool) {
var x struct {
Rate int64 `json:"rate"`
Per string `json:"per"`
Burst int64 `json:"burst"`
Inv bool `json:"inv"`
Unit string `json:"rate_unit"`
BurstUnit string `json:"burst_unit"`
}
if json.Unmarshal(v, &x) != nil {
return nil, false
}
l := &model.Limit{Rate: int(x.Rate), Per: x.Per, Burst: int(x.Burst), Over: x.Inv}
if x.Unit == "" || x.Unit == "packets" {
return l, true
}
// Bytes: both in the largest unit that holds them exactly.
rm, ok1 := model.ByteUnits[x.Unit]
bu := x.BurstUnit
if bu == "" {
bu = "bytes"
}
bm, ok2 := model.ByteUnits[bu]
if !ok1 || !ok2 {
return nil, false
}
rate, burst := x.Rate*rm, x.Burst*bm
for _, u := range []string{"mbytes", "kbytes", "bytes"} {
m := model.ByteUnits[u]
if rate%m == 0 && burst%m == 0 {
l.Rate, l.Burst, l.Unit = int(rate/m), int(burst/m), u
break
}
}
return l, true
}
// quotaJSON reads a quota: {"val": 100, "val_unit": "mbytes", "inv": true}.
func quotaJSON(v json.RawMessage) (*model.Quota, bool) {
var x struct {
Val int64 `json:"val"`
Unit string `json:"val_unit"`
Inv bool `json:"inv"`
}
if json.Unmarshal(v, &x) != nil {
return nil, false
}
m, ok := model.ByteUnits[x.Unit]
if x.Unit == "" {
m, ok = 1, true
}
if !ok || x.Val < 1 {
return nil, false
}
return &model.Quota{Bytes: x.Val * m, Over: x.Inv}, true
}
// meter reads a set statement that keeps a limit, connection count or
// quota per address (a meter: "update @m { ip saddr limit rate over
// 3/minute }"), keyed by source or destination, perhaps grouped by a
// prefix.
func (p *parsed) meter(v json.RawMessage) {
var x struct {
Op string `json:"op"`
Elem json.RawMessage `json:"elem"`
Stmt []json.RawMessage `json:"stmt"`
}
if json.Unmarshal(v, &x) != nil || (x.Op != "add" && x.Op != "update") || len(x.Stmt) != 1 {
p.bad("a set statement")
return
}
// The key: ip saddr, or ip saddr and MASK.
var key map[string]json.RawMessage
json.Unmarshal(x.Elem, &key)
bits := 0
if and, ok := key["&"]; ok {
var pair []json.RawMessage
var mask string
if json.Unmarshal(and, &pair) != nil || len(pair) != 2 || json.Unmarshal(pair[1], &mask) != nil {
p.bad("a meter key")
return
}
a, err := netip.ParseAddr(mask)
if err != nil {
p.bad("a meter key")
return
}
pfx, ok := maskBits(a)
if !ok {
p.bad("a meter key with a mask that isn't a prefix")
return
}
bits = pfx
key = nil
json.Unmarshal(pair[0], &key)
}
var pl struct{ Protocol, Field string }
if raw, ok := key["payload"]; !ok || json.Unmarshal(raw, &pl) != nil || (pl.Protocol != "ip" && pl.Protocol != "ip6") || (pl.Field != "saddr" && pl.Field != "daddr") {
p.bad("a meter keyed by something other than an address")
return
}
if bits > 0 {
if pl.Protocol == "ip" {
p.group4 = bits
} else {
p.group6 = bits
}
}
if p.family == "" {
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[pl.Protocol]
}
var stmt map[string]json.RawMessage
json.Unmarshal(x.Stmt[0], &stmt)
switch {
case stmt["limit"] != nil:
l, ok := limitJSON(stmt["limit"])
if !ok {
p.bad("a meter's limit")
return
}
l.PerSource, l.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
p.limit = l
case stmt["ct count"] != nil:
var c struct {
Val int `json:"val"`
Inv bool `json:"inv"`
}
if json.Unmarshal(stmt["ct count"], &c) != nil || !c.Inv || pl.Field != "saddr" {
p.bad("a connection count other than over N per source")
return
}
p.connLimit = c.Val
case stmt["quota"] != nil:
q, ok := quotaJSON(stmt["quota"])
if !ok {
p.bad("a meter's quota")
return
}
q.PerSource, q.PerDestination = pl.Field == "saddr", pl.Field == "daddr"
p.quota = q
default:
p.bad("a set statement")
}
}
// maskBits is a mask's prefix length (255.255.255.0: 24), if it is one:
// ones, then only zeros.
func maskBits(a netip.Addr) (int, bool) {
n, zero := 0, false
for _, x := range a.AsSlice() {
for bit := 7; bit >= 0; bit-- {
one := x&(1<<bit) != 0
switch {
case one && zero:
return 0, false
case one:
n++
default:
zero = true
}
}
}
return n, true
}
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", "later" or "demoted"
heads := map[string][]headRule{} // chain → the built-ins its rules start with, in order
var chains []string
rest := map[string][]*parsed{} // chain → the rules after its head
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:
chains = append(chains, b.kind)
prefix := true
for _, p := range rules {
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 {
heads[b.kind] = append(heads[b.kind], headRule{bi, p})
continue
}
}
prefix = false
rest[b.kind] = append(rest[b.kind], p)
}
}
}
im.settleBuiltins(found, heads)
for _, chain := range chains {
for _, h := range heads[chain] {
if found[h.builtin][chain] == "demoted" {
im.rule(chain, h.p)
}
}
for _, p := range rest[chain] {
im.rule(chain, p)
}
}
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
var rules []*parsed
for _, r := range im.rules[chain] {
rules = append(rules, im.expandMaps(parse(r))...)
}
for _, p := range rules {
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
}
// expandMaps turns a rule that looks something up in a map into one rule
// per element, each matching the element's key and doing what its value
// says: "tcp dport vmap { 22 : accept, 23 : drop }" becomes "tcp dport 22
// accept" and "tcp dport 23 drop"; "dnat ip to tcp dport map @forwards"
// one dnat (later a port forward) per element. A lookup finds at most one
// element, and their keys don't overlap, so the rules do the same in
// order. A map that can't be expanded leaves the rule unsupported.
func (im *importer) expandMaps(p *parsed) []*parsed {
u := p.mapUse
if u == nil {
return []*parsed{p}
}
p.mapUse = nil
fail := func(why string) []*parsed {
p.bad(why)
return []*parsed{p}
}
var keyObj map[string]json.RawMessage
json.Unmarshal(u.key, &keyObj)
if keyObj["concat"] != nil {
return fail("a map keyed by several fields (" + u.kind + ")")
}
// The elements: a named map's, or the anonymous one's.
var elems []json.RawMessage
mapName := ""
var ref string
if json.Unmarshal(u.data, &ref) == nil && strings.HasPrefix(ref, "@") {
m, ok := im.maps[ref[1:]]
if !ok {
return fail("the map " + ref + ", which isn't in the table")
}
mapName, elems = m.Name, m.Elem
} else {
var anon struct {
Set []json.RawMessage `json:"set"`
}
if json.Unmarshal(u.data, &anon) != nil || anon.Set == nil {
return fail("a " + u.kind + " map Housecarl can't read")
}
elems = anon.Set
}
if len(elems) == 0 {
im.note("rule handle %d looks up the empty map %s: left out", p.handle, map[bool]string{true: "@" + mapName, false: "{}"}[mapName != ""])
return nil
}
var out []*parsed
for _, e := range elems {
var pair []json.RawMessage
if json.Unmarshal(e, &pair) != nil || len(pair) != 2 {
return fail("a map element Housecarl can't read")
}
q := *p
q.unsupported = append([]string{}, p.unsupported...)
q.match(match{Op: "==", Left: u.key, Right: pair[0]})
switch u.kind {
case "dnat":
addr, port := mapTarget(pair[1])
if addr == "" {
return fail("a dnat map whose values aren't addresses (or address . port)")
}
q.dnatAddr, q.dnatPort = addr, port
if mapName != "" {
if k := values(pair[0]); len(k) == 1 {
q.name = mapName + "-" + k[0]
}
}
case "vmap":
var verdict map[string]json.RawMessage
if json.Unmarshal(pair[1], &verdict) != nil || len(verdict) != 1 {
return fail("a verdict map value Housecarl can't read")
}
for k, v := range verdict {
q.expr(k, v)
}
}
out = append(out, &q)
}
if mapName != "" {
what := map[string]string{"dnat": "port forward", "vmap": "rule"}[u.kind]
if !im.noted["map "+mapName] {
im.noted["map "+mapName] = true
if len(elems) == 1 {
im.note("map %s: its element became a %s", mapName, what)
} else {
im.note("map %s: its %d elements became %d %ss, one each", mapName, len(elems), len(elems), what)
}
}
}
return out
}
// mapTarget reads a dnat map's value: an address, or address . port.
func mapTarget(raw json.RawMessage) (addr, port string) {
var obj struct {
Concat []json.RawMessage `json:"concat"`
}
if json.Unmarshal(raw, &obj) == nil && len(obj.Concat) == 2 {
a, b := values(obj.Concat[0]), values(obj.Concat[1])
if len(a) == 1 && len(b) == 1 {
return a[0], b[0]
}
return "", ""
}
if a := values(raw); len(a) == 1 {
return a[0], ""
}
return "", ""
}
// 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.quota == nil && p.connLimit == 0 && !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 ""
}
// headRule is a rule at the head of a chain that is a built-in.
type headRule struct {
builtin string
p *parsed
}
// 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. Built-ins come before every rule, so once
// one at a chain's head stays a rule, the ones after it there stay rules
// too ("demoted"), in their order: an accept moved ahead of a drop (ICMPv6
// ahead of dropping invalid packets) would let through what the drop
// stopped.
func (im *importer) settleBuiltins(found map[string]map[string]string, heads map[string][]headRule) {
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
}
for {
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)
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}
changed := false
for chain, hs := range heads {
cut := false
for _, h := range hs {
// mss-clamp and flowtable decide nothing: their place
// doesn't matter.
if !kept[h.builtin] && h.builtin != "mss-clamp" && h.builtin != "flowtable" {
cut = true
}
if cut && found[h.builtin][chain] == "prefix" {
found[h.builtin][chain] = "demoted"
changed = true
}
}
}
if !changed {
break
}
}
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 that stay rules, in their place: say which.
for _, chain := range []string{model.ChainEarly, model.ChainInput, model.ChainForward, model.ChainOutput} {
for _, h := range heads[chain] {
if found[h.builtin][chain] == "demoted" {
im.note("%s in the %s chain stays a rule, in its place (the built-in isn't in every chain it covers, or a rule before it stays a rule)", h.builtin, chain)
}
}
}
}
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, Quota: p.quota, ConnLimit: p.connLimit, GroupV4: p.group4, GroupV6: p.group6,
Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
switch p.verdict {
case "accept", "drop", "reject":
r.Action = p.verdict
case "":
switch {
case len(p.unsupported) > 0:
// The verdict may be in what couldn't be read (a verdict map).
im.rawRule(r, p)
return
case !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 r.CountsTraffic() && r.Action == model.ActionAccept {
// Housecarl checks these before established connections are
// accepted, where accepting would skip the later rules: as written.
p.bad("an accept with a quota or a byte rate")
ok = false
}
if ok && r.CountsTraffic() && chain != model.ChainEarly {
im.note("%s rule handle %d has a quota or a byte rate: Housecarl checks it before established connections are accepted, so it counts every packet of a connection", chain, p.handle)
}
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)
}
var mapRefRe = regexp.MustCompile(`@([A-Za-z0-9_]+)`)
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])
}
// A named map isn't carried over: a raw rule naming it would make
// nftables refuse the whole ruleset.
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
if _, ok := im.maps[ref[1]]; ok {
im.note("rule handle %d of chain %s uses the map @%s, which couldn't be expanded (%s): left out, add it by hand: %s",
p.handle, r.Chain, ref[1], strings.Join(p.unsupported, ", "), text)
return
}
}
if r.Action == "" {
r.Action = model.ActionAccept
}
// Only the text counts: what was parsed of it goes.
*r = model.Rule{ID: r.ID, Chain: r.Chain, Position: r.Position, Comment: r.Comment, Action: r.Action, Raw: 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 p.name != "" {
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-"))
}
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: "8080 to web" becomes "fwd-8080-to-web", 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
}