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>
1442 lines
42 KiB
Go
1442 lines
42 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"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"housecarl/internal/model"
|
|
)
|
|
|
|
// Input is what to import.
|
|
type Input struct {
|
|
// JSON is `nft -j list ruleset`.
|
|
JSON []byte
|
|
// Text is `nft -a list ruleset` (optional): the source of raw rules.
|
|
Text string
|
|
// Defines is the ruleset file (optional): its define lines name sets,
|
|
// services and zones.
|
|
Defines string
|
|
// Table is the table to import ("inet main"); empty: the only table
|
|
// that isn't Housecarl's.
|
|
Table string
|
|
}
|
|
|
|
// Result is the imported configuration and what to know about it.
|
|
type Result struct {
|
|
State *model.State
|
|
Notes []string
|
|
// Raw lists the rules that became raw rules.
|
|
Raw []string
|
|
}
|
|
|
|
// ---- nftables JSON
|
|
|
|
type jsonDoc struct {
|
|
Nftables []map[string]json.RawMessage `json:"nftables"`
|
|
}
|
|
|
|
type nftTable struct {
|
|
Family string `json:"family"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
type nftChain struct {
|
|
Family string `json:"family"`
|
|
Table string `json:"table"`
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Hook string `json:"hook"`
|
|
Prio int `json:"prio"`
|
|
Policy string `json:"policy"`
|
|
}
|
|
|
|
type nftRule struct {
|
|
Family string `json:"family"`
|
|
Table string `json:"table"`
|
|
Chain string `json:"chain"`
|
|
Handle int `json:"handle"`
|
|
Comment string `json:"comment"`
|
|
Expr []json.RawMessage `json:"expr"`
|
|
}
|
|
|
|
type nftSet struct {
|
|
Family string `json:"family"`
|
|
Table string `json:"table"`
|
|
Name string `json:"name"`
|
|
Type json.RawMessage `json:"type"`
|
|
Elem []json.RawMessage `json:"elem"`
|
|
}
|
|
|
|
// nftMap is a named map: its elements are [key, value] pairs.
|
|
type nftMap struct {
|
|
Family string `json:"family"`
|
|
Table string `json:"table"`
|
|
Name string `json:"name"`
|
|
Type json.RawMessage `json:"type"`
|
|
Map json.RawMessage `json:"map"`
|
|
Elem []json.RawMessage `json:"elem"`
|
|
}
|
|
|
|
type nftFlowtable struct {
|
|
Table string `json:"table"`
|
|
Name string `json:"name"`
|
|
Dev []string `json:"dev"`
|
|
}
|
|
|
|
type importer struct {
|
|
in Input
|
|
table nftTable
|
|
chains map[string]nftChain
|
|
rules map[string][]nftRule // by chain, in order
|
|
sets map[string]nftSet
|
|
maps map[string]nftMap
|
|
flow []nftFlowtable
|
|
text map[int]string // rule text by handle
|
|
st *model.State
|
|
notes []string
|
|
raw []string
|
|
id int64
|
|
defines map[string]string // name → value text
|
|
addrNames map[string]string // canonical entry list → define name
|
|
portNames map[string]string // canonical port list → define name
|
|
ifNames map[string]string // interface → define name
|
|
used map[string]bool // sets created
|
|
noted map[string]bool
|
|
positions map[string]float64
|
|
}
|
|
|
|
func (im *importer) note(format string, args ...any) {
|
|
im.notes = append(im.notes, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func (im *importer) newID() int64 { im.id++; return im.id }
|
|
|
|
// Import converts a ruleset.
|
|
func Import(in Input) (*Result, error) {
|
|
var doc jsonDoc
|
|
if err := json.Unmarshal(in.JSON, &doc); err != nil {
|
|
return nil, fmt.Errorf("the input isn't nft's JSON (nft -j list ruleset): %v", err)
|
|
}
|
|
im := &importer{in: in, chains: map[string]nftChain{}, rules: map[string][]nftRule{}, sets: map[string]nftSet{}, maps: map[string]nftMap{},
|
|
text: map[int]string{}, st: model.NewState(), used: map[string]bool{}, noted: map[string]bool{}, positions: map[string]float64{}}
|
|
var tables []nftTable
|
|
for _, item := range doc.Nftables {
|
|
if raw, ok := item["table"]; ok {
|
|
var t nftTable
|
|
json.Unmarshal(raw, &t)
|
|
if !(t.Family == "inet" && t.Name == "housecarl") {
|
|
tables = append(tables, t)
|
|
}
|
|
}
|
|
}
|
|
switch {
|
|
case in.Table != "":
|
|
fam, name, _ := strings.Cut(strings.TrimSpace(in.Table), " ")
|
|
found := false
|
|
for _, t := range tables {
|
|
if t.Family == fam && t.Name == name {
|
|
im.table, found = t, true
|
|
}
|
|
}
|
|
if !found {
|
|
return nil, fmt.Errorf("there is no table %q in the ruleset (%s)", in.Table, tableList(tables))
|
|
}
|
|
case len(tables) == 0:
|
|
return nil, fmt.Errorf("the ruleset has no table to import")
|
|
case len(tables) > 1:
|
|
return nil, fmt.Errorf("the ruleset has several tables (%s): say which to import (--table %q)", tableList(tables), tables[0].Family+" "+tables[0].Name)
|
|
default:
|
|
im.table = tables[0]
|
|
}
|
|
ours := func(fam, table string) bool { return fam == im.table.Family && table == im.table.Name }
|
|
for _, item := range doc.Nftables {
|
|
switch {
|
|
case item["chain"] != nil:
|
|
var c nftChain
|
|
json.Unmarshal(item["chain"], &c)
|
|
if ours(c.Family, c.Table) {
|
|
im.chains[c.Name] = c
|
|
}
|
|
case item["rule"] != nil:
|
|
var r nftRule
|
|
json.Unmarshal(item["rule"], &r)
|
|
if ours(r.Family, r.Table) {
|
|
im.rules[r.Chain] = append(im.rules[r.Chain], r)
|
|
}
|
|
case item["set"] != nil:
|
|
var s nftSet
|
|
json.Unmarshal(item["set"], &s)
|
|
if ours(s.Family, s.Table) {
|
|
im.sets[s.Name] = s
|
|
}
|
|
case item["map"] != nil:
|
|
var m nftMap
|
|
json.Unmarshal(item["map"], &m)
|
|
if ours(m.Family, m.Table) {
|
|
im.maps[m.Name] = m
|
|
}
|
|
case item["flowtable"] != nil:
|
|
var f nftFlowtable
|
|
json.Unmarshal(item["flowtable"], &f)
|
|
if f.Table == im.table.Name {
|
|
im.flow = append(im.flow, f)
|
|
}
|
|
}
|
|
}
|
|
im.readText()
|
|
im.readDefines()
|
|
im.importSets()
|
|
im.run()
|
|
im.nameForwards()
|
|
im.st.Renumber()
|
|
return &Result{State: im.st, Notes: im.notes, Raw: im.raw}, nil
|
|
}
|
|
|
|
func tableList(ts []nftTable) string {
|
|
var out []string
|
|
for _, t := range ts {
|
|
out = append(out, t.Family+" "+t.Name)
|
|
}
|
|
return strings.Join(out, ", ")
|
|
}
|
|
|
|
// readText maps rule handles to their text, from nft -a list ruleset.
|
|
var handleRe = regexp.MustCompile(`^\s*(.*?)\s*# handle (\d+)$`)
|
|
var counterRe = regexp.MustCompile(`counter packets \d+ bytes \d+`)
|
|
|
|
func (im *importer) readText() {
|
|
inTable := false
|
|
for _, l := range strings.Split(im.in.Text, "\n") {
|
|
if strings.HasPrefix(l, "table ") {
|
|
inTable = strings.HasPrefix(l, "table "+im.table.Family+" "+im.table.Name+" ")
|
|
continue
|
|
}
|
|
if !inTable {
|
|
continue
|
|
}
|
|
m := handleRe.FindStringSubmatch(l)
|
|
if m == nil || strings.HasSuffix(m[1], "{") {
|
|
continue
|
|
}
|
|
h, _ := strconv.Atoi(m[2])
|
|
im.text[h] = strings.TrimSpace(counterRe.ReplaceAllString(m[1], "counter"))
|
|
}
|
|
}
|
|
|
|
// ---- defines
|
|
|
|
var defineRe = regexp.MustCompile(`(?m)^\s*define\s+([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.+?)\s*$`)
|
|
|
|
func (im *importer) readDefines() {
|
|
im.defines, im.addrNames, im.portNames, im.ifNames = map[string]string{}, map[string]string{}, map[string]string{}, map[string]string{}
|
|
for _, m := range defineRe.FindAllStringSubmatch(im.in.Defines, -1) {
|
|
im.defines[m[1]] = m[2]
|
|
}
|
|
expand := func(v string) []string {
|
|
var out []string
|
|
for _, part := range strings.Split(strings.Trim(strings.TrimSpace(v), "{}"), ",") {
|
|
part = strings.Trim(strings.TrimSpace(part), `"`)
|
|
if strings.HasPrefix(part, "$") {
|
|
if d, ok := im.defines[part[1:]]; ok {
|
|
for _, x := range strings.Split(strings.Trim(strings.TrimSpace(d), "{}"), ",") {
|
|
out = append(out, strings.Trim(strings.TrimSpace(x), `"`))
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
if part != "" {
|
|
out = append(out, part)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
names := make([]string, 0, len(im.defines))
|
|
for n := range im.defines {
|
|
names = append(names, n)
|
|
}
|
|
sort.Strings(names)
|
|
for _, n := range names {
|
|
vals := expand(im.defines[n])
|
|
if len(vals) == 0 {
|
|
continue
|
|
}
|
|
var addrs, ports []string
|
|
for _, v := range vals {
|
|
if a, err := model.NormalizeAddress(v); err == nil {
|
|
addrs = append(addrs, a)
|
|
} else if p, err := model.NormalizePorts(v); err == nil {
|
|
ports = append(ports, p)
|
|
}
|
|
}
|
|
switch {
|
|
case len(addrs) == len(vals):
|
|
// A set for a list, or for one prefix; one address stays as it is.
|
|
if len(addrs) > 1 || strings.Contains(addrs[0], "/") {
|
|
if _, taken := im.addrNames[key(addrs)]; !taken {
|
|
im.addrNames[key(addrs)] = n
|
|
}
|
|
}
|
|
case len(ports) == len(vals) && len(ports) > 1:
|
|
im.portNames[key(ports)] = n
|
|
case len(vals) == 1 && validIface(vals[0]):
|
|
im.ifNames[vals[0]] = n
|
|
}
|
|
}
|
|
}
|
|
|
|
func key(vals []string) string {
|
|
s := append([]string(nil), vals...)
|
|
sort.Strings(s)
|
|
return strings.Join(s, ",")
|
|
}
|
|
|
|
var ifaceRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.@-]{0,14}\*?$`)
|
|
|
|
func validIface(s string) bool {
|
|
return ifaceRe.MatchString(s) && !strings.ContainsAny(s, ".:") || s == "lo"
|
|
}
|
|
|
|
// ---- sets
|
|
|
|
// importSets turns the table's named address sets into sets.
|
|
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 {
|
|
s := im.sets[n]
|
|
var typ string
|
|
json.Unmarshal(s.Type, &typ)
|
|
if typ != "ipv4_addr" && typ != "ipv6_addr" {
|
|
im.note("set %s (%s) isn't an address set: not imported; rules using it become raw rules", n, string(s.Type))
|
|
continue
|
|
}
|
|
var entries []string
|
|
for _, e := range s.Elem {
|
|
entries = append(entries, values(e)...)
|
|
}
|
|
if existing := im.st.Set(n); existing != nil {
|
|
existing.Entries = append(existing.Entries, entries...)
|
|
continue
|
|
}
|
|
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: setName(n), Entries: entries})
|
|
im.used[n] = true
|
|
}
|
|
}
|
|
|
|
var nameRe = regexp.MustCompile(`[^A-Za-z0-9_-]+`)
|
|
|
|
func setName(s string) string {
|
|
s = nameRe.ReplaceAllString(s, "_")
|
|
if s == "" || !(s[0] >= 'A' && s[0] <= 'Z' || s[0] >= 'a' && s[0] <= 'z') {
|
|
s = "s_" + s
|
|
}
|
|
if len(s) > 48 {
|
|
s = s[:48]
|
|
}
|
|
return s
|
|
}
|
|
|
|
// namedSet returns a set name for a list of address entries: a define's
|
|
// name if one matches, else none ("" keeps them literal).
|
|
func (im *importer) namedSet(entries []string) string {
|
|
n, ok := im.addrNames[key(entries)]
|
|
if !ok {
|
|
return ""
|
|
}
|
|
name := setName(n)
|
|
if !im.used[name] {
|
|
im.used[name] = true
|
|
im.st.Sets = append(im.st.Sets, &model.AddressSet{ID: im.newID(), Name: name, Entries: append([]string(nil), entries...)})
|
|
}
|
|
return name
|
|
}
|
|
|
|
// zone returns the zone of an interface, making it on first use.
|
|
func (im *importer) zone(iface string) string {
|
|
for _, z := range im.st.Zones {
|
|
for _, i := range z.Interfaces {
|
|
if i == iface {
|
|
return z.Name
|
|
}
|
|
}
|
|
}
|
|
name := iface
|
|
if d, ok := im.ifNames[iface]; ok {
|
|
name = d
|
|
for _, suf := range []string{"_interface", "_iface", "_int", "_if", "_dev"} {
|
|
name = strings.TrimSuffix(name, suf)
|
|
}
|
|
}
|
|
name = setName(strings.TrimSuffix(name, "*"))
|
|
if im.st.Zone(name) != nil {
|
|
name = setName(iface + "_zone")
|
|
}
|
|
im.st.Zones = append(im.st.Zones, &model.Zone{ID: im.newID(), Name: name, Interfaces: []string{iface}})
|
|
return name
|
|
}
|
|
|
|
func (im *importer) external(iface string) {
|
|
if z := im.st.Zone(im.zone(iface)); z != nil && !z.External {
|
|
z.External = true
|
|
}
|
|
}
|
|
|
|
// ---- expressions
|
|
|
|
// values reads a right-hand value: an address, prefix, range, number,
|
|
// string, or an anonymous set of them, as entries.
|
|
func values(raw json.RawMessage) []string {
|
|
var s string
|
|
if json.Unmarshal(raw, &s) == nil {
|
|
return []string{s}
|
|
}
|
|
var n json.Number
|
|
if json.Unmarshal(raw, &n) == nil {
|
|
return []string{n.String()}
|
|
}
|
|
var list []json.RawMessage
|
|
if json.Unmarshal(raw, &list) == nil {
|
|
var out []string
|
|
for _, x := range list {
|
|
out = append(out, values(x)...)
|
|
}
|
|
return out
|
|
}
|
|
var obj map[string]json.RawMessage
|
|
if json.Unmarshal(raw, &obj) != nil {
|
|
return nil
|
|
}
|
|
if set, ok := obj["set"]; ok {
|
|
return values(set)
|
|
}
|
|
if p, ok := obj["prefix"]; ok {
|
|
var x struct {
|
|
Addr string `json:"addr"`
|
|
Len int `json:"len"`
|
|
}
|
|
json.Unmarshal(p, &x)
|
|
return []string{fmt.Sprintf("%s/%d", x.Addr, x.Len)}
|
|
}
|
|
if r, ok := obj["range"]; ok {
|
|
var x []json.RawMessage
|
|
json.Unmarshal(r, &x)
|
|
if len(x) == 2 {
|
|
a, b := values(x[0]), values(x[1])
|
|
if len(a) == 1 && len(b) == 1 {
|
|
return []string{a[0] + "-" + b[0]}
|
|
}
|
|
}
|
|
}
|
|
if e, ok := obj["elem"]; ok {
|
|
var x struct {
|
|
Val json.RawMessage `json:"val"`
|
|
}
|
|
json.Unmarshal(e, &x)
|
|
return values(x.Val)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// parsed is one rule, read.
|
|
type parsed struct {
|
|
inIf, outIf []string
|
|
notIn, notOut bool
|
|
src, dst []string // entries, or "@set"
|
|
notSrc, notDst bool
|
|
family string
|
|
proto string
|
|
dports []string
|
|
icmp []string
|
|
icmpProto string
|
|
ctState, ctStatus []string
|
|
hop255 bool
|
|
tcpSyn bool
|
|
limit *model.Limit
|
|
log bool
|
|
logPrefix string
|
|
verdict string // accept, drop, reject, jump, goto, return
|
|
rejectWith string
|
|
target string
|
|
dnatAddr, dnatPort string
|
|
snatAddr string
|
|
masq bool
|
|
redirect string
|
|
mss, flowAdd bool
|
|
unsupported []string
|
|
comment string
|
|
handle int
|
|
// mapUse: a dnat to a map, or a verdict map, still to be expanded
|
|
// into one rule per element (expandMaps).
|
|
mapUse *mapUse
|
|
// name: a port forward's name, when the rule's comment won't do
|
|
// (one of a map's elements).
|
|
name string
|
|
}
|
|
|
|
// mapUse is a lookup in a map: "dnat ip to tcp dport map @forwards" or
|
|
// "tcp dport vmap { 22 : accept }".
|
|
type mapUse struct {
|
|
kind string // dnat or vmap
|
|
key json.RawMessage // what is looked up: {"payload": ...}, {"meta": ...}
|
|
data json.RawMessage // "@name", or {"set": [[key, value], ...]}
|
|
}
|
|
|
|
type match struct {
|
|
Op string `json:"op"`
|
|
Left json.RawMessage `json:"left"`
|
|
Right json.RawMessage `json:"right"`
|
|
}
|
|
|
|
func parse(r nftRule) *parsed {
|
|
p := &parsed{comment: r.Comment, handle: r.Handle}
|
|
for _, raw := range r.Expr {
|
|
var e map[string]json.RawMessage
|
|
json.Unmarshal(raw, &e)
|
|
for k, v := range e {
|
|
p.expr(k, v)
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func (p *parsed) bad(what string) { p.unsupported = append(p.unsupported, what) }
|
|
|
|
func (p *parsed) expr(k string, v json.RawMessage) {
|
|
switch k {
|
|
case "match":
|
|
var m match
|
|
json.Unmarshal(v, &m)
|
|
p.match(m)
|
|
case "counter":
|
|
case "accept", "drop", "return":
|
|
p.verdict = k
|
|
case "jump", "goto":
|
|
var t struct{ Target string }
|
|
json.Unmarshal(v, &t)
|
|
p.verdict, p.target = k, t.Target
|
|
case "reject":
|
|
p.verdict = "reject"
|
|
var x struct {
|
|
Type string `json:"type"`
|
|
}
|
|
json.Unmarshal(v, &x)
|
|
if x.Type == "tcp reset" {
|
|
p.rejectWith = "tcp-reset"
|
|
}
|
|
case "log":
|
|
var x struct {
|
|
Prefix string `json:"prefix"`
|
|
}
|
|
json.Unmarshal(v, &x)
|
|
p.log, p.logPrefix = true, x.Prefix
|
|
case "limit":
|
|
var x struct {
|
|
Rate int `json:"rate"`
|
|
Per string `json:"per"`
|
|
Burst int `json:"burst"`
|
|
Inv bool `json:"inv"`
|
|
Unit string `json:"rate_unit"`
|
|
}
|
|
json.Unmarshal(v, &x)
|
|
if x.Unit != "" && x.Unit != "packets" {
|
|
p.bad("a byte rate limit")
|
|
return
|
|
}
|
|
p.limit = &model.Limit{Rate: x.Rate, Per: x.Per, Burst: x.Burst, Over: x.Inv}
|
|
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)
|
|
}
|
|
}
|
|
|
|
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 x.Key {
|
|
case "iifname":
|
|
p.inIf, p.notIn = right, not
|
|
case "oifname":
|
|
p.outIf, p.notOut = right, not
|
|
case "nfproto":
|
|
if len(right) == 1 && !not {
|
|
p.family = right[0]
|
|
} else {
|
|
p.bad("meta nfproto")
|
|
}
|
|
case "l4proto":
|
|
if len(right) == 1 && !not {
|
|
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)
|
|
switch {
|
|
case (x.Protocol == "ip" || x.Protocol == "ip6") && (x.Field == "saddr" || x.Field == "daddr"):
|
|
if p.family == "" {
|
|
p.family = map[string]string{"ip": "ipv4", "ip6": "ipv6"}[x.Protocol]
|
|
}
|
|
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.dports = x.Protocol, right
|
|
case x.Protocol == "tcp" && x.Field == "flags" && len(right) == 1 && right[0] == "syn":
|
|
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":
|
|
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:
|
|
p.ctState = right
|
|
case x.Key == "status" && !not:
|
|
p.ctStatus = right
|
|
default:
|
|
p.bad("ct " + x.Key)
|
|
}
|
|
return
|
|
}
|
|
for k := range left {
|
|
p.bad(k)
|
|
}
|
|
}
|
|
|
|
// ---- converting
|
|
|
|
func (im *importer) run() {
|
|
type base struct {
|
|
name, kind string
|
|
}
|
|
var bases []base
|
|
for name, c := range im.chains {
|
|
switch {
|
|
case c.Type == "filter" && c.Hook == "prerouting":
|
|
bases = append(bases, base{name, model.ChainEarly})
|
|
case c.Type == "filter" && (c.Hook == "input" || c.Hook == "forward" || c.Hook == "output"):
|
|
bases = append(bases, base{name, c.Hook})
|
|
case c.Type == "nat" && c.Hook == "prerouting":
|
|
bases = append(bases, base{name, "dstnat"})
|
|
case c.Type == "nat" && c.Hook == "postrouting":
|
|
bases = append(bases, base{name, "srcnat"})
|
|
case c.Hook != "":
|
|
im.note("chain %s (%s %s) isn't imported: Housecarl doesn't manage that hook", name, c.Type, c.Hook)
|
|
}
|
|
set := &im.st.Settings
|
|
switch {
|
|
case c.Hook == "input" && c.Type == "filter":
|
|
set.InputPolicy = c.Policy
|
|
case c.Hook == "forward" && c.Type == "filter":
|
|
set.ForwardPolicy = c.Policy
|
|
case c.Hook == "output" && c.Type == "filter":
|
|
set.OutputPolicy = c.Policy
|
|
}
|
|
}
|
|
// NAT first: the filter chains' port-forward accepts are folded into
|
|
// the port forwards.
|
|
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 { return order[bases[i].kind] < order[bases[j].kind] })
|
|
im.st.Settings.Builtins = model.Builtins{}
|
|
found := map[string]map[string]string{} // builtin → chain → "prefix" or "later"
|
|
var deferred []func()
|
|
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:
|
|
prefix := true
|
|
for _, p := range rules {
|
|
p := p
|
|
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 {
|
|
continue
|
|
}
|
|
}
|
|
prefix = false
|
|
kind := b.kind
|
|
deferred = append(deferred, func() { im.rule(kind, p) })
|
|
}
|
|
}
|
|
}
|
|
im.settleBuiltins(found)
|
|
for _, f := range deferred {
|
|
f()
|
|
}
|
|
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 = "", ""
|
|
out = append(out, im.flatten(p.target, &jumpMatch, depth+1)...)
|
|
if p.verdict == "goto" {
|
|
im.note("the goto to %s became the same rules followed by the rest of %s: check the order", p.target, chain)
|
|
}
|
|
continue
|
|
case "return":
|
|
if prefix != nil && len(p.unsupported) == 0 && !hasMatch(p, prefix) {
|
|
return out // an unconditional return ends the jumped-to chain
|
|
}
|
|
p.bad("a conditional return")
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// expandMaps turns a rule that looks something up in a map into one rule
|
|
// per element, each matching the element's key and doing what its value
|
|
// says: "tcp dport vmap { 22 : accept, 23 : drop }" becomes "tcp dport 22
|
|
// accept" and "tcp dport 23 drop"; "dnat ip to tcp dport map @forwards"
|
|
// one dnat (later a port forward) per element. A lookup finds at most one
|
|
// element, and their keys don't overlap, so the rules do the same in
|
|
// order. A map that can't be expanded leaves the rule unsupported.
|
|
func (im *importer) expandMaps(p *parsed) []*parsed {
|
|
u := p.mapUse
|
|
if u == nil {
|
|
return []*parsed{p}
|
|
}
|
|
p.mapUse = nil
|
|
fail := func(why string) []*parsed {
|
|
p.bad(why)
|
|
return []*parsed{p}
|
|
}
|
|
var keyObj map[string]json.RawMessage
|
|
json.Unmarshal(u.key, &keyObj)
|
|
if keyObj["concat"] != nil {
|
|
return fail("a map keyed by several fields (" + u.kind + ")")
|
|
}
|
|
// The elements: a named map's, or the anonymous one's.
|
|
var elems []json.RawMessage
|
|
mapName := ""
|
|
var ref string
|
|
if json.Unmarshal(u.data, &ref) == nil && strings.HasPrefix(ref, "@") {
|
|
m, ok := im.maps[ref[1:]]
|
|
if !ok {
|
|
return fail("the map " + ref + ", which isn't in the table")
|
|
}
|
|
mapName, elems = m.Name, m.Elem
|
|
} else {
|
|
var anon struct {
|
|
Set []json.RawMessage `json:"set"`
|
|
}
|
|
if json.Unmarshal(u.data, &anon) != nil || anon.Set == nil {
|
|
return fail("a " + u.kind + " map Housecarl can't read")
|
|
}
|
|
elems = anon.Set
|
|
}
|
|
if len(elems) == 0 {
|
|
im.note("rule handle %d looks up the empty map %s: left out", p.handle, map[bool]string{true: "@" + mapName, false: "{}"}[mapName != ""])
|
|
return nil
|
|
}
|
|
var out []*parsed
|
|
for _, e := range elems {
|
|
var pair []json.RawMessage
|
|
if json.Unmarshal(e, &pair) != nil || len(pair) != 2 {
|
|
return fail("a map element Housecarl can't read")
|
|
}
|
|
q := *p
|
|
q.unsupported = append([]string{}, p.unsupported...)
|
|
q.match(match{Op: "==", Left: u.key, Right: pair[0]})
|
|
switch u.kind {
|
|
case "dnat":
|
|
addr, port := mapTarget(pair[1])
|
|
if addr == "" {
|
|
return fail("a dnat map whose values aren't addresses (or address . port)")
|
|
}
|
|
q.dnatAddr, q.dnatPort = addr, port
|
|
if mapName != "" {
|
|
if k := values(pair[0]); len(k) == 1 {
|
|
q.name = mapName + "-" + k[0]
|
|
}
|
|
}
|
|
case "vmap":
|
|
var verdict map[string]json.RawMessage
|
|
if json.Unmarshal(pair[1], &verdict) != nil || len(verdict) != 1 {
|
|
return fail("a verdict map value Housecarl can't read")
|
|
}
|
|
for k, v := range verdict {
|
|
q.expr(k, v)
|
|
}
|
|
}
|
|
out = append(out, &q)
|
|
}
|
|
if mapName != "" {
|
|
what := map[string]string{"dnat": "port forward", "vmap": "rule"}[u.kind]
|
|
if !im.noted["map "+mapName] {
|
|
im.noted["map "+mapName] = true
|
|
if len(elems) == 1 {
|
|
im.note("map %s: its element became a %s", mapName, what)
|
|
} else {
|
|
im.note("map %s: its %d elements became %d %ss, one each", mapName, len(elems), len(elems), what)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mapTarget reads a dnat map's value: an address, or address . port.
|
|
func mapTarget(raw json.RawMessage) (addr, port string) {
|
|
var obj struct {
|
|
Concat []json.RawMessage `json:"concat"`
|
|
}
|
|
if json.Unmarshal(raw, &obj) == nil && len(obj.Concat) == 2 {
|
|
a, b := values(obj.Concat[0]), values(obj.Concat[1])
|
|
if len(a) == 1 && len(b) == 1 {
|
|
return a[0], b[0]
|
|
}
|
|
return "", ""
|
|
}
|
|
if a := values(raw); len(a) == 1 {
|
|
return a[0], ""
|
|
}
|
|
return "", ""
|
|
}
|
|
|
|
// hasMatch reports whether p matches more than the jump's own matches.
|
|
func hasMatch(p, prefix *parsed) bool {
|
|
return len(p.dports) != len(prefix.dports) || len(p.src) != len(prefix.src) || len(p.dst) != len(prefix.dst) ||
|
|
len(p.inIf) != len(prefix.inIf) || len(p.outIf) != len(prefix.outIf) || len(p.ctState) != len(prefix.ctState) || len(p.icmp) != len(prefix.icmp)
|
|
}
|
|
|
|
// merge puts a jump's matches in front of a jumped-to rule's. When both
|
|
// match the same field, the rule's own (narrower, usually) is kept and
|
|
// noted.
|
|
func (im *importer) merge(prefix, p *parsed) *parsed {
|
|
q := *p
|
|
q.unsupported = append(append([]string{}, prefix.unsupported...), p.unsupported...)
|
|
fill := func(what string, dst *[]string, not *bool, src []string, srcNot bool) {
|
|
if len(*dst) == 0 && len(src) > 0 {
|
|
*dst, *not = src, srcNot
|
|
} else if len(*dst) > 0 && len(src) > 0 && key(*dst) != key(src) {
|
|
im.note("rule handle %d: both the jump to it and the rule match %s; the rule's own (%s) are kept, the jump's (%s) dropped: check it",
|
|
p.handle, what, strings.Join(*dst, ", "), strings.Join(src, ", "))
|
|
}
|
|
}
|
|
fill("interfaces in", &q.inIf, &q.notIn, prefix.inIf, prefix.notIn)
|
|
fill("interfaces out", &q.outIf, &q.notOut, prefix.outIf, prefix.notOut)
|
|
fill("sources", &q.src, &q.notSrc, prefix.src, prefix.notSrc)
|
|
fill("destinations", &q.dst, &q.notDst, prefix.dst, prefix.notDst)
|
|
if q.family == "" {
|
|
q.family = prefix.family
|
|
}
|
|
if len(q.ctState) == 0 {
|
|
q.ctState = prefix.ctState
|
|
}
|
|
if q.proto == "" {
|
|
q.proto, q.dports = prefix.proto, prefix.dports
|
|
}
|
|
return &q
|
|
}
|
|
|
|
// ---- built-ins
|
|
|
|
var essentials6 = map[string]bool{"destination-unreachable": true, "packet-too-big": true, "time-exceeded": true, "parameter-problem": true, "echo-request": true, "echo-reply": true}
|
|
var nd6 = map[string]bool{"nd-router-solicit": true, "nd-router-advert": true, "nd-neighbor-solicit": true, "nd-neighbor-advert": true}
|
|
|
|
func plain(p *parsed) bool {
|
|
return len(p.unsupported) == 0 && p.limit == nil && !p.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 ""
|
|
}
|
|
|
|
// 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.
|
|
func (im *importer) settleBuiltins(found map[string]map[string]string) {
|
|
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
|
|
}
|
|
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)
|
|
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 found in one chain's head but not another's: explicit rules
|
|
// in the chains where they were found.
|
|
for name, chains := range found {
|
|
for chain, where := range chains {
|
|
if where != "prefix" {
|
|
continue
|
|
}
|
|
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}[name]
|
|
if !kept {
|
|
im.note("%s in the %s chain stays a rule (it isn't in every chain the built-in covers, or comes after other rules)", name, chain)
|
|
im.builtinRule(name, chain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// builtinRule writes a built-in that stays off as a rule of its chain.
|
|
func (im *importer) builtinRule(name, chain string) {
|
|
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain) - 1000}
|
|
switch name {
|
|
case "established":
|
|
r.CtState, r.Action = []string{"established", "related"}, model.ActionAccept
|
|
case "invalid":
|
|
r.CtState, r.Action = []string{"invalid"}, model.ActionDrop
|
|
case "loopback":
|
|
r.Raw, r.Action = `iifname "lo" accept`, model.ActionAccept
|
|
default:
|
|
im.note("built-in %s of the %s chain couldn't be kept; add it by hand", name, chain)
|
|
return
|
|
}
|
|
im.st.Rules = append(im.st.Rules, r)
|
|
}
|
|
|
|
func (im *importer) nextPos(chain string) float64 {
|
|
im.positions[chain]++
|
|
return im.positions[chain]
|
|
}
|
|
|
|
// ---- rules
|
|
|
|
// addrRefs turns address entries into references: "@set" → the set, a
|
|
// list matching a define → its set, else the literal entries.
|
|
func (im *importer) addrRefs(entries []string) []string {
|
|
var out, lit []string
|
|
for _, e := range entries {
|
|
if strings.HasPrefix(e, "@") {
|
|
out = append(out, setName(e[1:]))
|
|
continue
|
|
}
|
|
if n, err := model.NormalizeAddress(e); err == nil {
|
|
lit = append(lit, n)
|
|
} else {
|
|
lit = append(lit, e)
|
|
}
|
|
}
|
|
if len(lit) > 0 {
|
|
if name := im.namedSet(lit); name != "" {
|
|
out = append(out, name)
|
|
} else {
|
|
out = append(out, lit...)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (im *importer) services(p *parsed) ([]string, bool) {
|
|
var out []string
|
|
if p.icmpProto != "" {
|
|
for _, t := range p.icmp {
|
|
out = append(out, p.icmpProto+"/"+t)
|
|
}
|
|
}
|
|
if p.proto != "" {
|
|
if len(p.dports) == 0 {
|
|
if p.proto != "tcp" && p.proto != "udp" && p.proto != "sctp" {
|
|
return nil, false
|
|
}
|
|
return append(out, p.proto+"/1-65535"), true
|
|
}
|
|
var ports []string
|
|
for _, d := range p.dports {
|
|
if strings.HasPrefix(d, "@") {
|
|
return nil, false
|
|
}
|
|
n, err := model.NormalizePorts(d)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
ports = append(ports, n)
|
|
}
|
|
if name, ok := im.portNames[key(ports)]; ok {
|
|
svc := setName(name)
|
|
if im.st.Service(svc) == nil || im.st.Service(svc).Builtin {
|
|
var entries []string
|
|
for _, x := range ports {
|
|
entries = append(entries, p.proto+"/"+x)
|
|
}
|
|
im.st.Services = append(im.st.Services, &model.Service{ID: im.newID(), Name: svc, Entries: entries})
|
|
}
|
|
return append(out, svc), true
|
|
}
|
|
for _, x := range ports {
|
|
out = append(out, p.proto+"/"+x)
|
|
}
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func (im *importer) zones(ifaces []string) []string {
|
|
var out []string
|
|
for _, i := range ifaces {
|
|
out = append(out, im.zone(i))
|
|
}
|
|
return out
|
|
}
|
|
|
|
// toMatch builds a Match; ok is false when something can't be said.
|
|
func (im *importer) toMatch(p *parsed) (model.Match, bool) {
|
|
svcs, ok := im.services(p)
|
|
if !ok || len(p.unsupported) > 0 {
|
|
return model.Match{}, false
|
|
}
|
|
m := model.Match{InZones: im.zones(p.inIf), NotIn: p.notIn, OutZones: im.zones(p.outIf), NotOut: p.notOut,
|
|
Sources: im.addrRefs(p.src), NotSources: p.notSrc, Destinations: im.addrRefs(p.dst), NotDests: p.notDst, Services: svcs}
|
|
if len(p.src) == 0 && len(p.dst) == 0 && p.icmpProto == "" && p.family != "" {
|
|
m.Family = p.family
|
|
}
|
|
return m, true
|
|
}
|
|
|
|
// rule converts a filter rule (or keeps it raw).
|
|
func (im *importer) rule(chain string, p *parsed) {
|
|
if chain == model.ChainForward && im.absorbForward(p) {
|
|
return
|
|
}
|
|
r := &model.Rule{ID: im.newID(), Chain: chain, Position: im.nextPos(chain), Comment: p.comment, CtState: p.ctState, CtStatus: p.ctStatus,
|
|
Limit: p.limit, Log: p.log, LogPrefix: p.logPrefix, RejectWith: p.rejectWith}
|
|
switch p.verdict {
|
|
case "accept", "drop", "reject":
|
|
r.Action = p.verdict
|
|
case "":
|
|
switch {
|
|
case len(p.unsupported) > 0:
|
|
// The verdict may be in what couldn't be read (a verdict map).
|
|
im.rawRule(r, p)
|
|
return
|
|
case !p.log:
|
|
im.note("a %s rule with no verdict (handle %d) was left out", chain, p.handle)
|
|
return
|
|
}
|
|
r.Action = model.ActionLog
|
|
default:
|
|
p.bad(p.verdict)
|
|
}
|
|
m, ok := im.toMatch(p)
|
|
if ok && !p.hop255 && !p.mss && !p.tcpSyn && !p.flowAdd && p.dnatAddr == "" && !p.masq {
|
|
r.Match = m
|
|
im.st.Rules = append(im.st.Rules, r)
|
|
return
|
|
}
|
|
im.rawRule(r, p)
|
|
}
|
|
|
|
var mapRefRe = regexp.MustCompile(`@([A-Za-z0-9_]+)`)
|
|
|
|
func (im *importer) rawRule(r *model.Rule, p *parsed) {
|
|
text, ok := im.text[p.handle]
|
|
if !ok {
|
|
im.note("rule handle %d of chain %s couldn't be expressed (%s), and there is no text to keep it raw: add it by hand (import with the nft -a list ruleset text)", p.handle, r.Chain, strings.Join(p.unsupported, ", "))
|
|
return
|
|
}
|
|
// The original's comment goes into Housecarl's.
|
|
if i := strings.LastIndex(text, ` comment "`); i >= 0 {
|
|
text = strings.TrimSpace(text[:i])
|
|
}
|
|
// A named map isn't carried over: a raw rule naming it would make
|
|
// nftables refuse the whole ruleset.
|
|
for _, ref := range mapRefRe.FindAllStringSubmatch(text, -1) {
|
|
if _, ok := im.maps[ref[1]]; ok {
|
|
im.note("rule handle %d of chain %s uses the map @%s, which couldn't be expanded (%s): left out, add it by hand: %s",
|
|
p.handle, r.Chain, ref[1], strings.Join(p.unsupported, ", "), text)
|
|
return
|
|
}
|
|
}
|
|
if r.Action == "" {
|
|
r.Action = model.ActionAccept
|
|
}
|
|
r.Match, r.Raw = model.Match{}, text
|
|
im.st.Rules = append(im.st.Rules, r)
|
|
why := strings.Join(p.unsupported, ", ")
|
|
if why == "" {
|
|
why = "a match Housecarl has no field for"
|
|
}
|
|
im.raw = append(im.raw, fmt.Sprintf("%s: %s (%s)", r.Chain, text, why))
|
|
if strings.Contains(text, "@") {
|
|
im.note("raw rule %q names a set of the old table: Housecarl's sets are NAME_v4 and NAME_v6", text)
|
|
}
|
|
}
|
|
|
|
// ---- NAT
|
|
|
|
func (im *importer) dstnat(p *parsed) {
|
|
switch {
|
|
case p.dnatAddr != "" && len(p.unsupported) == 0 && p.proto != "" && len(p.dports) == 1 && !p.notDst && !p.notSrc:
|
|
ports, err := model.NormalizePorts(p.dports[0])
|
|
if err != nil {
|
|
break
|
|
}
|
|
f := &model.PortForward{Protocols: []string{p.proto}, Ports: ports, To: p.dnatAddr, ToPort: p.dnatPort, Comment: p.comment, Name: forwardName(p.comment)}
|
|
if p.name != "" {
|
|
f.Name = strings.ToLower(nameRe.ReplaceAllString(p.name, "-"))
|
|
}
|
|
if f.ToPort == ports {
|
|
f.ToPort = ""
|
|
}
|
|
f.PublicAddresses = im.addrRefs(p.dst)
|
|
f.Sources = im.addrRefs(p.src)
|
|
for _, i := range p.inIf {
|
|
im.external(i)
|
|
}
|
|
// The same forward for another protocol: one forward, both.
|
|
for _, o := range im.st.Forwards {
|
|
if o.Ports == f.Ports && o.To == f.To && o.ToPort == f.ToPort && key(o.PublicAddresses) == key(f.PublicAddresses) && key(o.Sources) == key(f.Sources) {
|
|
o.Protocols = append(o.Protocols, p.proto)
|
|
return
|
|
}
|
|
}
|
|
f.ID, f.Position = im.newID(), float64(len(im.st.Forwards)+1)
|
|
im.st.Forwards = append(im.st.Forwards, f)
|
|
return
|
|
case p.redirect != "" && p.redirect != "?" && len(p.unsupported) == 0:
|
|
m, ok := im.toMatch(p)
|
|
if ok {
|
|
im.st.Redirects = append(im.st.Redirects, &model.Redirect{ID: im.newID(), Position: float64(len(im.st.Redirects) + 1), Match: m, ToPort: p.redirect, Comment: p.comment})
|
|
return
|
|
}
|
|
}
|
|
im.note("the dstnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
|
|
}
|
|
|
|
var forwardWords = regexp.MustCompile(`(?i)^\s*port\s*forward(ing)?\s*[:-]?\s*`)
|
|
|
|
func forwardName(comment string) string {
|
|
n := strings.TrimSpace(forwardWords.ReplaceAllString(comment, ""))
|
|
return strings.Trim(strings.ToLower(nameRe.ReplaceAllString(n, "-")), "-")
|
|
}
|
|
|
|
// nameForwards makes the forwards' names (from their comments) valid and
|
|
// unique: "8096 to jellyfin" becomes "fwd-8096-to-jellyfin", a second
|
|
// "web" becomes "web-2".
|
|
func (im *importer) nameForwards() {
|
|
seen := map[string]bool{}
|
|
for _, f := range im.st.Forwards {
|
|
n := f.Name
|
|
if n == "" {
|
|
continue
|
|
}
|
|
if !(n[0] >= 'a' && n[0] <= 'z') {
|
|
n = "fwd-" + n
|
|
}
|
|
if len(n) > 44 {
|
|
n = strings.TrimRight(n[:44], "-")
|
|
}
|
|
base := n
|
|
for i := 2; seen[n]; i++ {
|
|
n = fmt.Sprintf("%s-%d", base, i)
|
|
}
|
|
seen[n] = true
|
|
f.Name = n
|
|
}
|
|
}
|
|
|
|
func (im *importer) srcnat(p *parsed) {
|
|
if len(p.unsupported) > 0 {
|
|
im.note("the srcnat rule handle %d couldn't be imported (%s): %s", p.handle, strings.Join(p.unsupported, ", "), im.text[p.handle])
|
|
return
|
|
}
|
|
// Hairpin: masquerading connections that were port forwarded, to the
|
|
// forwards' hosts.
|
|
if p.masq && key(p.ctStatus) == "dnat" && len(p.dst) > 0 && !p.notDst {
|
|
matched := 0
|
|
for _, f := range im.st.Forwards {
|
|
for _, d := range p.dst {
|
|
if d == f.To {
|
|
f.Hairpin, f.HairpinSources = true, im.addrRefs(p.src)
|
|
if p.notSrc {
|
|
f.HairpinSources = nil
|
|
im.note("hairpin for %s: the original excluded sources; Housecarl's hairpin covers all inside sources", f.Label())
|
|
}
|
|
matched++
|
|
}
|
|
}
|
|
}
|
|
if matched > 0 {
|
|
return
|
|
}
|
|
}
|
|
m, ok := im.toMatch(p)
|
|
if !ok || !p.masq && p.snatAddr == "" {
|
|
im.note("the srcnat rule handle %d couldn't be imported: %s", p.handle, im.text[p.handle])
|
|
return
|
|
}
|
|
for _, i := range p.outIf {
|
|
if !p.notOut && p.masq {
|
|
im.external(i)
|
|
}
|
|
}
|
|
if p.family == "ipv6" && len(p.src) == 0 {
|
|
m.Family = model.FamilyIPv6
|
|
}
|
|
for _, s := range p.src {
|
|
if a, err := model.ParseAddress(s); err == nil && a.IsV6() {
|
|
m.Family = model.FamilyIPv6
|
|
}
|
|
}
|
|
im.st.SNATs = append(im.st.SNATs, &model.SourceNAT{ID: im.newID(), Position: float64(len(im.st.SNATs) + 1), Match: m, To: p.snatAddr, Comment: p.comment})
|
|
}
|
|
|
|
// absorbForward folds a port forward's own forward-chain accept (and an
|
|
// IPv6 pinhole on its port) into the port forward.
|
|
func (im *importer) absorbForward(p *parsed) bool {
|
|
if p.verdict != "accept" || len(p.unsupported) > 0 || len(p.dst) != 1 || p.notDst || len(p.dports) != 1 || p.proto == "" || len(p.src) > 0 || len(p.outIf) > 0 {
|
|
return false
|
|
}
|
|
port, err := model.NormalizePorts(p.dports[0])
|
|
if err != nil {
|
|
return false
|
|
}
|
|
dst := p.dst[0]
|
|
if p.family == "ipv4" || !strings.Contains(dst, ":") {
|
|
for _, f := range im.st.Forwards {
|
|
inside := f.ToPort
|
|
if inside == "" {
|
|
inside = f.Ports
|
|
}
|
|
if f.To == dst && inside == port && contains(f.Protocols, p.proto) {
|
|
if len(p.inIf) > 0 && !im.noted["forward-iif"] {
|
|
im.noted["forward-iif"] = true
|
|
im.note("port forwards' own accepts aren't limited to arriving on %s any more: they only let port-forwarded connections through (ct status dnat), which hairpin needs from inside too", strings.Join(p.inIf, ", "))
|
|
}
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
// IPv6: a pinhole for the one forward on that port.
|
|
var cands []*model.PortForward
|
|
for _, f := range im.st.Forwards {
|
|
inside := f.ToPort
|
|
if inside == "" {
|
|
inside = f.Ports
|
|
}
|
|
if inside == port && contains(f.Protocols, p.proto) && f.To6 == "" {
|
|
cands = append(cands, f)
|
|
}
|
|
}
|
|
if len(cands) == 1 && len(p.ctStatus) == 0 {
|
|
cands[0].To6 = dst
|
|
im.note("the IPv6 rule to %s port %s became %s's IPv6 pinhole", dst, port, cands[0].Label())
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|