// 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< 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 }