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:
1 parent
eaadbe6a3d
commit
ebec2cfcca
7 files changed
+340
-4
No files matched your search
@@ -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 = ""
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user