Fixes from an audit of the importer, each of which could change what an imported ruleset does: two base chains on one hook imported their rules twice (and hoisted the second's built-ins), now in priority order with the stricter policy; raw rules from a jumped-to chain lost the jump's condition, now put in front; a jump and a rule matching one field are combined only when one's addresses lie within the other's; a second match on a field replaced the first, and != on hop limit and TCP flags read as ==, now kept raw; a forward accept without ct status dnat no longer folds into the port forward, nor an interface-limited IPv6 accept into an open pinhole; raw rules' set references are renamed (NAME_v4/_v6) or written out, so nftables takes them; DNATs with matches a forward can't hold are left out with a note. Sets with flags timeout become dynamic sets; a DNAT of several ports one forward per port. Zone names: --zone IFACE=NAME (and on the Import page), VLAN defines, more suffixes, and a note on where each name came from. testdata/audit.nft covers the cases, checked with nft 1.1.3. make test and make e2e (246 checks) pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2047 lines
62 KiB
Go
2047 lines
62 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"
|
|
"housecarl/internal/render"
|
|
)
|
|
|
|
// 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
|
|
// Zones names zones by interface ("enp2s0": "lan"), over the names
|
|
// the importer would choose; interfaces given one name share a zone.
|
|
Zones map[string]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"`
|
|
Flags []string `json:"flags"`
|
|
Timeout int `json:"timeout"`
|
|
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
|
|
// zoneFrom says where each zone's name came from, for the note.
|
|
zoneFrom map[string]string
|
|
}
|
|
|
|
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{}, zoneFrom: map[string]string{}}
|
|
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.noteZones()
|
|
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}\*?$`)
|
|
|
|
// vlanRe is a VLAN sub-interface: enp2s0.20.
|
|
var vlanRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_@-]*\.[0-9]{1,4}$`)
|
|
|
|
func validIface(s string) bool {
|
|
return len(s) <= 15 && (ifaceRe.MatchString(s) || vlanRe.MatchString(s))
|
|
}
|
|
|
|
// ---- 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)...)
|
|
}
|
|
name := setName(n)
|
|
if contains(s.Flags, "timeout") || contains(s.Flags, "dynamic") {
|
|
// Filled at run time (by a program, or a rule): a dynamic set.
|
|
// Its entries now are passing state, not configuration.
|
|
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Dynamic: true, Timeout: s.Timeout})
|
|
im.used[name] = true
|
|
im.note("set %s has entries that come and go (flags %s): it became a dynamic set, without its %d entries now; whatever fills it must now fill Housecarl's (hc set entries add %s ADDRESS, the API, or nft add element inet housecarl %s_v4 / %s_v6)",
|
|
n, strings.Join(s.Flags, ","), len(entries), name, name, name)
|
|
continue
|
|
}
|
|
if existing := im.st.Set(name); existing != nil {
|
|
existing.Entries = append(existing.Entries, entries...)
|
|
continue
|
|
}
|
|
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Entries: entries})
|
|
im.used[name] = 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. Its
|
|
// name: the one given for it (Input.Zones); else the name of a define
|
|
// holding the interface ("define lan_if = enp2s0": lan); else the
|
|
// interface's own.
|
|
func (im *importer) zone(iface string) string {
|
|
for _, z := range im.st.Zones {
|
|
for _, i := range z.Interfaces {
|
|
if i == iface {
|
|
return z.Name
|
|
}
|
|
}
|
|
}
|
|
if given, ok := im.in.Zones[iface]; ok {
|
|
name := setName(given)
|
|
// Interfaces given one name share its zone.
|
|
if z := im.st.Zone(name); z != nil && im.zoneFrom[name] == "given" {
|
|
z.Interfaces = append(z.Interfaces, iface)
|
|
return name
|
|
}
|
|
if im.st.Zone(name) == nil {
|
|
return im.newZone(name, iface, "given")
|
|
}
|
|
im.note("--zone %s=%s: the zone %s is taken (by %s), so %s is named as below", iface, given, name, strings.Join(im.st.Zone(name).Interfaces, ", "), iface)
|
|
}
|
|
name, from := iface, "its interface's name"
|
|
if d, ok := im.ifNames[iface]; ok {
|
|
name, from = d, "the define "+d
|
|
for _, suf := range []string{"_interface", "_ifname", "_iface", "_device", "_int", "_nic", "_if", "_dev"} {
|
|
name = strings.TrimSuffix(name, suf)
|
|
}
|
|
}
|
|
name = setName(strings.TrimSuffix(name, "*"))
|
|
if im.st.Zone(name) != nil {
|
|
name, from = setName(iface+"_zone"), "its interface's name (the other was taken)"
|
|
}
|
|
return im.newZone(name, iface, from)
|
|
}
|
|
|
|
// zone2 is the zone an interface is in, or "" (making none).
|
|
func (im *importer) zoneOfIface(iface string) string {
|
|
for _, z := range im.st.Zones {
|
|
if contains(z.Interfaces, iface) {
|
|
return z.Name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (im *importer) newZone(name, iface, from string) string {
|
|
im.st.Zones = append(im.st.Zones, &model.Zone{ID: im.newID(), Name: name, Interfaces: []string{iface}})
|
|
im.zoneFrom[name] = from
|
|
return name
|
|
}
|
|
|
|
// noteZones says how each zone was named, and how to change it.
|
|
func (im *importer) noteZones() {
|
|
ifaces := make([]string, 0, len(im.in.Zones))
|
|
for i := range im.in.Zones {
|
|
ifaces = append(ifaces, i)
|
|
}
|
|
sort.Strings(ifaces)
|
|
for _, i := range ifaces {
|
|
if im.zoneOfIface(i) == "" {
|
|
im.note("--zone %s=%s: no rule names the interface %s, so there is no zone for it", i, im.in.Zones[i], i)
|
|
}
|
|
}
|
|
if len(im.st.Zones) == 0 {
|
|
return
|
|
}
|
|
var parts []string
|
|
for _, z := range im.st.Zones {
|
|
from := im.zoneFrom[z.Name]
|
|
if from == "given" {
|
|
from = "as given"
|
|
}
|
|
ext := ""
|
|
if z.External {
|
|
ext = ", external"
|
|
}
|
|
parts = append(parts, fmt.Sprintf("%s (%s%s: %s)", z.Name, strings.Join(z.Interfaces, ", "), ext, from))
|
|
}
|
|
im.note("zones: %s; name them yourself with --zone INTERFACE=NAME (lan, iot, guest: what the network is for), or later with hc zone update NAME --name NEW", strings.Join(parts, "; "))
|
|
}
|
|
|
|
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
|
|
// inJump: the rule came from a chain jumped to; jumpText is what the
|
|
// jumps to it match, as nftables text (none when it isn't known), put
|
|
// in front of the rule's own text if it stays raw.
|
|
inJump bool
|
|
jumpText 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) {
|
|
if !contains(p.unsupported, what) {
|
|
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 {
|
|
case (x.Key == "iifname" || x.Key == "oifname") && anySet(right):
|
|
p.bad("meta " + x.Key + " in a named set")
|
|
case x.Key == "iifname" && len(p.inIf) > 0, x.Key == "oifname" && len(p.outIf) > 0:
|
|
// Both must hold; Housecarl's rule has one list: kept raw.
|
|
p.bad("two matches on meta " + x.Key)
|
|
case x.Key == "iifname":
|
|
p.inIf, p.notIn = right, not
|
|
case x.Key == "oifname":
|
|
p.outIf, p.notOut = right, not
|
|
case x.Key == "nfproto":
|
|
if len(right) == 1 && !not && (p.family == "" || p.family == right[0]) {
|
|
p.family = right[0]
|
|
} else {
|
|
p.bad("meta nfproto")
|
|
}
|
|
case x.Key == "l4proto":
|
|
if len(right) == 1 && !not && (p.proto == "" || p.proto == right[0]) {
|
|
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)
|
|
fam := map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
|
|
switch {
|
|
// A second match on a field Housecarl keeps one list for (both
|
|
// must hold): the rule stays raw, as written.
|
|
case x.Field == "saddr" && len(p.src) > 0, x.Field == "daddr" && len(p.dst) > 0,
|
|
x.Field == "dport" && len(p.dports) > 0, x.Field == "type" && len(p.icmp) > 0:
|
|
p.bad("two matches on " + x.Protocol + " " + x.Field)
|
|
case fam != "" && p.family != "" && p.family != fam && (x.Field == "saddr" || x.Field == "daddr"):
|
|
p.bad("both IPv4 and IPv6 addresses")
|
|
case (x.Protocol == "ip" || x.Protocol == "ip6") && (x.Field == "saddr" || x.Field == "daddr"):
|
|
p.family = fam
|
|
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.proto == x.Protocol):
|
|
p.proto, p.dports = x.Protocol, right
|
|
case x.Protocol == "tcp" && x.Field == "flags" && len(right) == 1 && right[0] == "syn" && !not:
|
|
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" && !not:
|
|
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 && len(p.ctState) == 0:
|
|
p.ctState = right
|
|
case x.Key == "status" && !not && len(p.ctStatus) == 0:
|
|
p.ctStatus = right
|
|
default:
|
|
p.bad("ct " + x.Key)
|
|
}
|
|
return
|
|
}
|
|
for k := range left {
|
|
p.bad(k)
|
|
}
|
|
}
|
|
|
|
// anySet reports whether a match's values name a set ("@name").
|
|
func anySet(vals []string) bool {
|
|
for _, v := range vals {
|
|
if strings.HasPrefix(v, "@") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// ---- converting
|
|
|
|
func (im *importer) run() {
|
|
type base struct {
|
|
name, kind string
|
|
prio int
|
|
}
|
|
var bases []base
|
|
names := make([]string, 0, len(im.chains))
|
|
for name := range im.chains {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
policies := map[string][]string{}
|
|
for _, name := range names {
|
|
c := im.chains[name]
|
|
switch {
|
|
case c.Type == "filter" && c.Hook == "prerouting":
|
|
bases = append(bases, base{name, model.ChainEarly, c.Prio})
|
|
if c.Prio > -200 {
|
|
im.note("chain %s hooks prerouting at priority %d, after connection tracking; Housecarl's early chain runs before it (raw), where connection states aren't known yet: check its rules", name, c.Prio)
|
|
}
|
|
case c.Type == "filter" && (c.Hook == "input" || c.Hook == "forward" || c.Hook == "output"):
|
|
bases = append(bases, base{name, c.Hook, c.Prio})
|
|
policies[c.Hook] = append(policies[c.Hook], c.Policy)
|
|
case c.Type == "nat" && c.Hook == "prerouting":
|
|
bases = append(bases, base{name, "dstnat", c.Prio})
|
|
case c.Type == "nat" && c.Hook == "postrouting":
|
|
bases = append(bases, base{name, "srcnat", c.Prio})
|
|
case c.Hook != "":
|
|
im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook)
|
|
}
|
|
}
|
|
// Several chains on one hook: a packet must get past each (an accept
|
|
// ends only its own chain), and any policy drop drops it.
|
|
set := &im.st.Settings
|
|
for hook, ps := range policies {
|
|
policy := model.PolicyAccept
|
|
if contains(ps, model.PolicyDrop) {
|
|
policy = model.PolicyDrop
|
|
}
|
|
switch hook {
|
|
case "input":
|
|
set.InputPolicy = policy
|
|
case "forward":
|
|
set.ForwardPolicy = policy
|
|
case "output":
|
|
set.OutputPolicy = policy
|
|
}
|
|
}
|
|
// NAT first: the filter chains' port-forward accepts are folded into
|
|
// the port forwards. Chains on the same hook in priority order.
|
|
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 {
|
|
if bases[i].kind != bases[j].kind {
|
|
return order[bases[i].kind] < order[bases[j].kind]
|
|
}
|
|
return bases[i].prio < bases[j].prio
|
|
})
|
|
for i := 1; i < len(bases); i++ {
|
|
if b, a := bases[i], bases[i-1]; b.kind == a.kind && b.kind != "dstnat" && b.kind != "srcnat" {
|
|
im.note("chains %s and %s both hook %s: a packet must get past each, but Housecarl has one %s chain, so their rules follow each other (by priority); that only does the same if what %s accepts, %s would accept too: check it", a.name, b.name, b.kind, b.kind, a.name, b.name)
|
|
}
|
|
}
|
|
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:
|
|
if !contains(chains, b.kind) {
|
|
chains = append(chains, b.kind)
|
|
}
|
|
// Built-ins come first only at the head of the hook's first
|
|
// chain (or of one after a chain of nothing but built-ins).
|
|
prefix := len(rest[b.kind]) == 0
|
|
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 = "", ""
|
|
jumpMatch.jumpText = joinText(p.jumpText, im.condText(p))
|
|
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)
|
|
}
|
|
|
|
// condText is a jump rule's text without its verdict, counter and
|
|
// comment: what it matches ("ip saddr 10.50.50.0/24"); "" when there is
|
|
// no text for it.
|
|
var verdictRe = regexp.MustCompile(`\s*\b(jump|goto)\s+\S+\s*$`)
|
|
|
|
func (im *importer) condText(p *parsed) string {
|
|
text, ok := im.text[p.handle]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
|
text = text[:i]
|
|
}
|
|
text = verdictRe.ReplaceAllString(strings.TrimSpace(text), "")
|
|
return strings.Join(strings.Fields(strings.ReplaceAll(" "+text+" ", " counter ", " ")), " ")
|
|
}
|
|
|
|
func joinText(a, b string) string {
|
|
return strings.TrimSpace(a + " " + b)
|
|
}
|
|
|
|
// merge puts a jump's matches in front of a jumped-to rule's: the rule
|
|
// applies where both match. When both match the same field, that holds as
|
|
// one list only when one's values lie within the other's (the narrower is
|
|
// kept); otherwise the rule is kept raw, after the jump's text.
|
|
func (im *importer) merge(prefix, p *parsed) *parsed {
|
|
q := *p
|
|
q.inJump, q.jumpText = true, prefix.jumpText
|
|
q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...)
|
|
both := func(what string) { q.bad("both the jump to it and the rule match " + what) }
|
|
addrs := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
|
|
switch {
|
|
case len(src) == 0:
|
|
case len(*dst) == 0:
|
|
*dst, *not = src, srcNot
|
|
case *not || srcNot:
|
|
if key(*dst) != key(src) || *not != srcNot {
|
|
both(what)
|
|
}
|
|
case im.within(*dst, src):
|
|
// The rule's own are narrower: they say it all.
|
|
case im.within(src, *dst):
|
|
*dst = src
|
|
default:
|
|
both(what)
|
|
}
|
|
}
|
|
same := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
|
|
switch {
|
|
case len(src) == 0:
|
|
case len(*dst) == 0:
|
|
*dst, *not = src, srcNot
|
|
case key(*dst) != key(src) || *not != srcNot:
|
|
both(what)
|
|
}
|
|
}
|
|
var noNot bool
|
|
same("interfaces in", &q.inIf, &q.notIn, prefix.inIf, prefix.notIn)
|
|
same("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut)
|
|
addrs("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc)
|
|
addrs("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst)
|
|
same("connection states", &q.ctState, &noNot, prefix.ctState, false)
|
|
same("connection status", &q.ctStatus, &noNot, prefix.ctStatus, false)
|
|
switch {
|
|
case prefix.family == "":
|
|
case q.family == "":
|
|
q.family = prefix.family
|
|
case q.family != prefix.family:
|
|
both("an address family")
|
|
}
|
|
switch {
|
|
case prefix.proto == "":
|
|
case q.proto == "":
|
|
q.proto, q.dports = prefix.proto, prefix.dports
|
|
case q.proto != prefix.proto:
|
|
both("a protocol")
|
|
default:
|
|
same("ports", &q.dports, &noNot, prefix.dports, false)
|
|
}
|
|
switch {
|
|
case prefix.icmpProto == "":
|
|
case q.icmpProto == "":
|
|
q.icmpProto, q.icmp = prefix.icmpProto, prefix.icmp
|
|
case q.icmpProto != prefix.icmpProto || key(q.icmp) != key(prefix.icmp):
|
|
both("ICMP types")
|
|
}
|
|
q.hop255 = q.hop255 || prefix.hop255
|
|
q.tcpSyn = q.tcpSyn || prefix.tcpSyn
|
|
if prefix.limit != nil || prefix.quota != nil || prefix.connLimit > 0 || prefix.log {
|
|
// Once in the jump, these count or log each packet once; on every
|
|
// rule they would act once per rule.
|
|
q.bad("a jump with a limit, quota or log")
|
|
}
|
|
return &q
|
|
}
|
|
|
|
// within reports whether every address of inner lies within outer's
|
|
// (literal addresses and prefixes, or sets of the table).
|
|
func (im *importer) within(inner, outer []string) bool {
|
|
resolve := func(list []string) ([]netip.Prefix, bool) {
|
|
var out []netip.Prefix
|
|
for _, e := range list {
|
|
vals := []string{e}
|
|
if strings.HasPrefix(e, "@") {
|
|
s, ok := im.sets[e[1:]]
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
vals = nil
|
|
for _, el := range s.Elem {
|
|
vals = append(vals, values(el)...)
|
|
}
|
|
}
|
|
for _, v := range vals {
|
|
pf, err := netip.ParsePrefix(v)
|
|
if err != nil {
|
|
a, err := netip.ParseAddr(v)
|
|
if err != nil {
|
|
return nil, false // a range, or something else
|
|
}
|
|
pf = netip.PrefixFrom(a, a.BitLen())
|
|
}
|
|
out = append(out, pf.Masked())
|
|
}
|
|
}
|
|
return out, len(out) > 0
|
|
}
|
|
in, ok1 := resolve(inner)
|
|
out, ok2 := resolve(outer)
|
|
if !ok1 || !ok2 {
|
|
return false
|
|
}
|
|
for _, a := range in {
|
|
found := false
|
|
for _, b := range out {
|
|
if b.Bits() <= a.Bits() && b.Contains(a.Addr()) {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ---- 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
|
|
}
|
|
|
|
// checkSets marks a rule naming a set that wasn't imported (not of
|
|
// addresses, or a meter) unsupported: it stays raw.
|
|
func (im *importer) checkSets(p *parsed) {
|
|
for _, e := range append(append([]string{}, p.src...), p.dst...) {
|
|
if n, ok := strings.CutPrefix(e, "@"); ok && (im.meters[n] || im.st.Set(setName(n)) == nil) {
|
|
p.bad("the set @" + n + ", which isn't an address set Housecarl imported")
|
|
}
|
|
}
|
|
}
|
|
|
|
// toMatch builds a Match; ok is false when something can't be said.
|
|
func (im *importer) toMatch(p *parsed) (model.Match, bool) {
|
|
im.checkSets(p)
|
|
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)
|
|
}
|
|
if p.log && !im.noted["log"] {
|
|
im.noted["log"] = true
|
|
im.note("rules that log now log to Housecarl's log (hc log, Traffic → Log), not the kernel log or the journal")
|
|
}
|
|
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])
|
|
}
|
|
// From a chain jumped to: only where the jumps matched.
|
|
if p.inJump {
|
|
if p.jumpText == "" {
|
|
im.note("rule handle %d of chain %s couldn't be expressed (%s), and the jump to it has no text to keep it raw with: add it by hand: %s", p.handle, r.Chain, strings.Join(p.unsupported, ", "), text)
|
|
return
|
|
}
|
|
text = p.jumpText + " " + text
|
|
}
|
|
// 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
|
|
}
|
|
}
|
|
text, bad := im.setRefs(text)
|
|
if bad != "" {
|
|
im.note("rule handle %d of chain %s uses the set @%s, which Housecarl's table doesn't have (%s): left out, add it by hand: %s",
|
|
p.handle, r.Chain, bad, 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))
|
|
}
|
|
|
|
var addrSetRefRe = regexp.MustCompile(`\b(ip6?) (saddr|daddr) (!= )?@([A-Za-z0-9_]+)`)
|
|
|
|
// setRefs makes a raw rule's set references work in Housecarl's table:
|
|
// an imported address set is NAME_v4 or NAME_v6 there, by the family the
|
|
// rule matches; another set of the old table (interface names, ports) is
|
|
// written out as an anonymous set of its elements. It returns the name of
|
|
// a set it couldn't resolve (a meter, an empty or unknown one).
|
|
func (im *importer) setRefs(text string) (string, string) {
|
|
text = addrSetRefRe.ReplaceAllStringFunc(text, func(m string) string {
|
|
sm := addrSetRefRe.FindStringSubmatch(m)
|
|
name := setName(sm[4])
|
|
if im.meters[sm[4]] || im.st.Set(name) == nil {
|
|
return m
|
|
}
|
|
return fmt.Sprintf("%s %s %s@%s", sm[1], sm[2], sm[3], render.SetName(name, sm[1] == "ip6"))
|
|
})
|
|
bad := ""
|
|
text = mapRefRe.ReplaceAllStringFunc(text, func(m string) string {
|
|
n := m[1:]
|
|
if strings.HasSuffix(n, "_v4") || strings.HasSuffix(n, "_v6") {
|
|
if im.st.Set(strings.TrimSuffix(strings.TrimSuffix(n, "_v4"), "_v6")) != nil {
|
|
return m // rewritten above
|
|
}
|
|
}
|
|
set, ok := im.sets[n]
|
|
if !ok || im.meters[n] || len(set.Elem) == 0 || contains(set.Flags, "timeout") || contains(set.Flags, "dynamic") {
|
|
bad = n
|
|
return m
|
|
}
|
|
var typ string
|
|
json.Unmarshal(set.Type, &typ)
|
|
var elems []string
|
|
for _, e := range set.Elem {
|
|
for _, v := range values(e) {
|
|
if typ == "ifname" || typ == "string" {
|
|
v = quote(v)
|
|
}
|
|
elems = append(elems, v)
|
|
}
|
|
}
|
|
return "{ " + strings.Join(elems, ", ") + " }"
|
|
})
|
|
return text, bad
|
|
}
|
|
|
|
func quote(s string) string { return `"` + strings.ReplaceAll(s, `"`, "") + `"` }
|
|
|
|
// ---- NAT
|
|
|
|
func (im *importer) dstnat(p *parsed) {
|
|
im.checkSets(p)
|
|
if a, err := netip.ParseAddr(p.dnatAddr); err == nil && a.Is6() {
|
|
p.bad("a DNAT to an IPv6 address (Housecarl's port forwards translate IPv4; IPv6 gets a pinhole)")
|
|
}
|
|
if p.notIn || len(p.outIf) > 0 || p.limit != nil || p.quota != nil || p.connLimit > 0 || p.log || len(p.ctState) > 0 && key(p.ctState) != "new" || len(p.icmp) > 0 {
|
|
p.bad("a DNAT with matches a port forward has no field for")
|
|
}
|
|
switch {
|
|
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) > 0 && !p.notDst && !p.notSrc:
|
|
var ports []string
|
|
for _, d := range p.dports {
|
|
n, err := model.NormalizePorts(d)
|
|
if err != nil {
|
|
p.bad("the port " + d)
|
|
break
|
|
}
|
|
ports = append(ports, n)
|
|
}
|
|
if len(p.unsupported) > 0 {
|
|
break
|
|
}
|
|
// "tcp dport { 80, 443 } dnat to X": a forward for each port.
|
|
for _, port := range ports {
|
|
im.portForward(p, port, len(ports) > 1)
|
|
}
|
|
for _, i := range p.inIf {
|
|
im.external(i)
|
|
}
|
|
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): add it by hand: %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
|
|
}
|
|
|
|
// portForward makes the port forward for one public port of a DNAT rule
|
|
// (several: the port goes into its name).
|
|
func (im *importer) portForward(p *parsed, ports string, several bool) {
|
|
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 several && f.Name != "" {
|
|
f.Name += "-" + ports
|
|
}
|
|
if f.ToPort == ports {
|
|
f.ToPort = ""
|
|
}
|
|
f.PublicAddresses = im.addrRefs(p.dst)
|
|
f.Sources = im.addrRefs(p.src)
|
|
// 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) {
|
|
if !contains(o.Protocols, p.proto) {
|
|
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)
|
|
}
|
|
|
|
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) {
|
|
im.checkSets(p)
|
|
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 ||
|
|
p.notIn || p.limit != nil || p.quota != nil || p.connLimit > 0 || p.log || len(p.ctState) > 0 && key(p.ctState) != "new" {
|
|
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, ":") {
|
|
// The forward's own accept takes only what was port forwarded: an
|
|
// accept of anything to that host and port stays a rule (inside
|
|
// clients reach the host directly through it).
|
|
if key(p.ctStatus) != "dnat" {
|
|
return false
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
// The pinhole lets in from everywhere: only an accept that did too.
|
|
if len(cands) == 1 && len(p.ctStatus) == 0 && len(p.inIf) == 0 && len(p.ctState) == 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
|
|
}
|