diff --git a/CHANGELOG.md b/CHANGELOG.md index c8383a5..1bee88b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,8 @@ the way Jarl and Steward are. - **Import** of an existing nftables ruleset (`hc import`): rules keep their order, defines name the sets, DNAT becomes port forwards, what can't be translated stays as raw rules, with notes; `hc takeover` - deletes the old table, on probation (docs/import.md). + deletes the old table, on probation (docs/import.md). DNAT maps and + verdict maps are expanded into one port forward or rule per element. - **The `hc` CLI, the REST API and the web UI** (dashboard, rules per chain with hits and reordering, NAT, zones, sets, services, the log, settings, import, history with undo), with users, roles, scoped API diff --git a/docs/import.md b/docs/import.md index 7a51969..ceb68f1 100644 --- a/docs/import.md +++ b/docs/import.md @@ -57,6 +57,15 @@ Import a ruleset. become part of the port forward, and a masquerade for inside sources to the same host becomes its hairpin. An IPv6 accept to the same port becomes the forward's IPv6 pinhole when there is only one candidate. +* **Maps are expanded**, one element at a time. A DNAT map + (`dnat ip to tcp dport map @forwards`, named or written in the rule) + becomes one port forward per element; forwards from a named map are + called after it (`forwards-443`). A verdict map + (`tcp dport vmap { 22 : accept, 23 : drop }`) becomes one rule per + element, in place, with `jump`s inlined as above. A lookup matches at + most one element, so the rules do the same. A map keyed by several + fields (`ip saddr . tcp dport`) can't be expanded: a rule using a named + one is left out with a note, to be added by hand. * **Masquerade and SNAT** become source NAT; **redirect** becomes a redirect. * **Policies** become the chains' policies. diff --git a/internal/importer/importer.go b/internal/importer/importer.go index c6c166e..267e8ae 100644 --- a/internal/importer/importer.go +++ b/internal/importer/importer.go @@ -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 = "" } diff --git a/internal/importer/importer_test.go b/internal/importer/importer_test.go index b6585e4..58f0c6c 100644 --- a/internal/importer/importer_test.go +++ b/internal/importer/importer_test.go @@ -1,6 +1,7 @@ package importer import ( + "fmt" "os" "os/exec" "strings" @@ -173,3 +174,68 @@ func TestImportedRulesetPassesNft(t *testing.T) { t.Errorf("nft -c: %v\n%s", err, out) } } + +// TestImportMaps: dnat maps become port forwards and verdict maps rules, +// one per element (testdata/maps.nft); a map that can't be expanded is +// left out with a note, never kept as a raw rule naming it. +func TestImportMaps(t *testing.T) { + js, _ := os.ReadFile("testdata/maps.json") + txt, _ := os.ReadFile("testdata/maps.txt") + res, err := Import(Input{JSON: js, Text: string(txt)}) + if err != nil { + t.Fatal(err) + } + var fwds []string + for _, f := range res.State.Forwards { + fwds = append(fwds, fmt.Sprintf("%s %s/%s->%s:%s", f.Name, strings.Join(f.Protocols, "+"), f.Ports, f.To, f.ToPort)) + } + var rules []string + for _, r := range res.State.Rules { + rules = append(rules, fmt.Sprintf("%s in=%s from=%s %s %s%s", r.Chain, strings.Join(r.InZones, ","), strings.Join(r.Sources, ","), + strings.Join(r.Services, ","), r.Action, r.Raw)) + } + want := []string{ + "portforwards-80 tcp/80->192.168.1.50:8080", + "portforwards-443 tcp/443->192.168.1.50:", + "hosts-2222 tcp/2222->192.168.1.60:", + " udp/5000->192.168.1.70:5001", + " udp/6000->192.168.1.71:", + } + if got := strings.Join(fwds, "\n"); got != strings.Join(want, "\n") { + t.Errorf("forwards:\n%s\nwant:\n%s", got, strings.Join(want, "\n")) + } + // In order; the jump in the verdict map is inlined. + want = []string{ + "input in= from= accept", + "input in=lan from= tcp/22 accept", + "input in=lan from= tcp/23 drop", + "input in=lan from=192.168.1.0/24 tcp/80 accept", + "input in=wan from= udp/53 accept", + "input in=wan from= udp/123 drop", + "input in= from=10.0.0.0/8 accept", + "input in= from=192.0.2.1 drop", + } + if got := strings.Join(rules, "\n"); got != strings.Join(want, "\n") { + t.Errorf("rules:\n%s\nwant:\n%s", got, strings.Join(want, "\n")) + } + notes := strings.Join(res.Notes, "\n") + for _, n := range []string{"map portforwards: its 2 elements became 2 port forwards", "map hosts: its element became a port forward", + "uses the map @pairs, which couldn't be expanded"} { + if !strings.Contains(notes, n) { + t.Errorf("note %q missing:\n%s", n, notes) + } + } + if len(res.Raw) > 0 { + t.Errorf("raw rules: %v", res.Raw) + } + if ps := validate.Errors(validate.Check(res.State, nil)); len(ps) > 0 { + t.Errorf("invalid: %+v", ps) + } + if cmd := strings.Fields(os.Getenv("HOUSECARL_NFT")); len(cmd) > 0 { + c := exec.Command(cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...) + c.Stdin = strings.NewReader(render.Render(res.State, render.Options{}).Text) + if out, err := c.CombinedOutput(); err != nil { + t.Errorf("nft -c: %v\n%s", err, out) + } + } +} diff --git a/internal/importer/testdata/maps.json b/internal/importer/testdata/maps.json new file mode 100644 index 0000000..64d6410 --- /dev/null +++ b/internal/importer/testdata/maps.json @@ -0,0 +1 @@ +{"nftables": [{"metainfo": {"version": "1.1.3", "release_name": "Commodore Bullmoose #4", "json_schema_version": 1}}, {"table": {"family": "inet", "name": "main", "handle": 3}}, {"chain": {"family": "inet", "table": "main", "name": "web", "handle": 1}}, {"chain": {"family": "inet", "table": "main", "name": "prerouting", "handle": 2, "type": "nat", "hook": "prerouting", "prio": -100, "policy": "accept"}}, {"chain": {"family": "inet", "table": "main", "name": "input", "handle": 3, "type": "filter", "hook": "input", "prio": 0, "policy": "drop"}}, {"map": {"family": "inet", "name": "portforwards", "table": "main", "type": "inet_service", "handle": 4, "map": ["ipv4_addr", "inet_service"], "elem": [[80, {"concat": ["192.168.1.50", 8080]}], [443, {"concat": ["192.168.1.50", 443]}]]}}, {"map": {"family": "inet", "name": "hosts", "table": "main", "type": "inet_service", "handle": 5, "map": "ipv4_addr", "elem": [[2222, "192.168.1.60"]]}}, {"map": {"family": "inet", "name": "lan_services", "table": "main", "type": "inet_service", "handle": 6, "map": "verdict", "elem": [[22, {"accept": null}], [23, {"drop": null}], [80, {"jump": {"target": "web"}}]]}}, {"map": {"family": "inet", "name": "pairs", "table": "main", "type": ["ipv4_addr", "inet_service"], "handle": 7, "map": "verdict", "elem": [[{"concat": ["192.168.1.9", 22]}, {"accept": null}]]}}, {"rule": {"family": "inet", "table": "main", "chain": "web", "handle": 8, "expr": [{"match": {"op": "==", "left": {"payload": {"protocol": "ip", "field": "saddr"}}, "right": {"prefix": {"addr": "192.168.1.0", "len": 24}}}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 9, "comment": "Port forwards", "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@portforwards"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 10, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@hosts"}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "prerouting", "handle": 12, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"dnat": {"family": "ip", "addr": {"map": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[5000, {"concat": ["192.168.1.70", 5001]}], [6000, {"concat": ["192.168.1.71", 6000]}]]}}}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 13, "expr": [{"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}, {"accept": null}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 14, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "lan"}}, {"vmap": {"key": {"payload": {"protocol": "tcp", "field": "dport"}}, "data": "@lan_services"}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 16, "expr": [{"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": "wan"}}, {"vmap": {"key": {"payload": {"protocol": "udp", "field": "dport"}}, "data": {"set": [[53, {"accept": null}], [123, {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 18, "expr": [{"vmap": {"key": {"payload": {"protocol": "ip", "field": "saddr"}}, "data": {"set": [[{"prefix": {"addr": "10.0.0.0", "len": 8}}, {"accept": null}], ["192.0.2.1", {"drop": null}]]}}}]}}, {"rule": {"family": "inet", "table": "main", "chain": "input", "handle": 19, "expr": [{"vmap": {"key": {"concat": [{"payload": {"protocol": "ip", "field": "saddr"}}, {"payload": {"protocol": "tcp", "field": "dport"}}]}, "data": "@pairs"}}]}}]} diff --git a/internal/importer/testdata/maps.nft b/internal/importer/testdata/maps.nft new file mode 100644 index 0000000..e9535ca --- /dev/null +++ b/internal/importer/testdata/maps.nft @@ -0,0 +1,36 @@ +# Maps and verdict maps, as found in hand-written rulesets (importer_test.go). +table inet main { + map portforwards { + type inet_service : ipv4_addr . inet_service + elements = { 443 : 192.168.1.50 . 443, 80 : 192.168.1.50 . 8080 } + } + map hosts { + type inet_service : ipv4_addr + elements = { 2222 : 192.168.1.60 } + } + map lan_services { + type inet_service : verdict + elements = { 22 : accept, 23 : drop, 80 : jump web } + } + map pairs { + type ipv4_addr . inet_service : verdict + elements = { 192.168.1.9 . 22 : accept } + } + chain web { + ip saddr 192.168.1.0/24 accept + } + chain prerouting { + type nat hook prerouting priority dstnat; policy accept; + iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards" + iifname "wan" dnat ip to tcp dport map @hosts + iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 } + } + chain input { + type filter hook input priority filter; policy drop; + ct state established,related accept + iifname "lan" tcp dport vmap @lan_services + iifname "wan" udp dport vmap { 53 : accept, 123 : drop } + ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop } + ip saddr . tcp dport vmap @pairs + } +} diff --git a/internal/importer/testdata/maps.txt b/internal/importer/testdata/maps.txt new file mode 100644 index 0000000..eac2b75 --- /dev/null +++ b/internal/importer/testdata/maps.txt @@ -0,0 +1,44 @@ +table inet main { # handle 3 + map portforwards { # handle 4 + type inet_service : ipv4_addr . inet_service + elements = { 80 : 192.168.1.50 . 8080, + 443 : 192.168.1.50 . 443 } + } + + map hosts { # handle 5 + type inet_service : ipv4_addr + elements = { 2222 : 192.168.1.60 } + } + + map lan_services { # handle 6 + type inet_service : verdict + elements = { 22 : accept, + 23 : drop, + 80 : jump web } + } + + map pairs { # handle 7 + type ipv4_addr . inet_service : verdict + elements = { 192.168.1.9 . 22 : accept } + } + + chain web { # handle 1 + ip saddr 192.168.1.0/24 accept # handle 8 + } + + chain prerouting { # handle 2 + type nat hook prerouting priority dstnat; policy accept; + iifname "wan" dnat ip to tcp dport map @portforwards comment "Port forwards" # handle 9 + iifname "wan" dnat ip to tcp dport map @hosts # handle 10 + iifname "wan" dnat ip to udp dport map { 5000 : 192.168.1.70 . 5001, 6000 : 192.168.1.71 . 6000 } # handle 12 + } + + chain input { # handle 3 + type filter hook input priority filter; policy drop; + ct state established,related accept # handle 13 + iifname "lan" tcp dport vmap @lan_services # handle 14 + iifname "wan" udp dport vmap { 53 : accept, 123 : drop } # handle 16 + ip saddr vmap { 10.0.0.0/8 : accept, 192.0.2.1 : drop } # handle 18 + ip saddr . tcp dport vmap @pairs # handle 19 + } +}