Import nftables maps: one port forward or rule per element

A DNAT map (named or anonymous) becomes one port forward per element,
named after the map; a verdict map becomes one rule per element, in
place, with jumps inlined. A lookup matches one element at most, so the
expanded rules do the same. A named map that can't be expanded (keyed by
several fields) is left out with a note instead of a raw rule naming it,
which nftables would refuse. Test fixture made with nft 1.1.3.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-19 10:24:28 +01:00
1 parent eaadbe6a3d
commit ebec2cfcca
7 files changed
+340 -4

No files matched your search

+182 -3
View File
@@ -81,6 +81,16 @@ type nftSet struct {
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"`
@@ -93,6 +103,7 @@ type importer struct {
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
@@ -120,7 +131,7 @@ func Import(in Input) (*Result, error) {
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{},
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 {
@@ -172,6 +183,12 @@ func Import(in Input) (*Result, error) {
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)
@@ -465,6 +482,20 @@ type parsed struct {
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 {
@@ -536,6 +567,16 @@ func (p *parsed) expr(k string, v json.RawMessage) {
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")
@@ -572,6 +613,13 @@ func (p *parsed) expr(k string, v json.RawMessage) {
}
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)
}
@@ -740,8 +788,11 @@ func (im *importer) run() {
// 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] {
p := parse(r)
rules = append(rules, im.expandMaps(parse(r))...)
}
for _, p := range rules {
if prefix != nil {
p = im.merge(prefix, p)
}
@@ -770,6 +821,115 @@ func (im *importer) flatten(chain string, prefix *parsed, depth int) []*parsed {
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) ||
@@ -1048,7 +1208,12 @@ func (im *importer) rule(chain string, p *parsed) {
case "accept", "drop", "reject":
r.Action = p.verdict
case "":
if !p.log {
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
}
@@ -1065,6 +1230,8 @@ func (im *importer) rule(chain string, p *parsed) {
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 {
@@ -1075,6 +1242,15 @@ func (im *importer) rawRule(r *model.Rule, p *parsed) {
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
}
@@ -1100,6 +1276,9 @@ func (im *importer) dstnat(p *parsed) {
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 = ""
}