Changesets, as in Jarl: configuration changes are operations that can be staged in a changeset, checked as they are added (validation and nft -c), reviewed with the diff of the ruleset, and applied together as one history entry, then on probation like any change made over the network. The web UI collects edits for review by default (pending rows, a bar with Review and Apply now, a Changes page, a switch for live edits); hc stages with -C ID or HOUSECARL_CHANGESET (checked like JARL_CHANGESET) and has hc changeset create/list/show/diff/apply/discard/remove. Undo, import and takeover refuse a changeset. --dry-run shows the ruleset's diff. Bandwidth and quotas: rate limits in bytes, per source or destination; quotas per rule or per address that reset daily, weekly, monthly on a day, or by hand, with their usage carried across reloads, saved for reboots, shown by hc quotas and on the rules page, and reset with hc rule reset-quota. Rules that count traffic are checked before established connections are accepted (so they see every packet) and can't accept. Per-address limits group IPv6 by /64 by default (--group-v4/-v6). Per-source rate meters use update. The importer turns meters, byte rates and quotas into these fields. The examples in the web UI, hc's help and the docs use documentation addresses and generic names instead of the maintainer's network. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1652 lines
48 KiB
Go
1652 lines
48 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"
|
|
)
|
|
|
|
// 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
|
|
meters map[string]bool // sets used as meters
|
|
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.findMeters()
|
|
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.
|
|
// 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)...)
|
|
}
|
|
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
|
|
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
|
|
}
|
|
|
|
// 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":
|
|
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 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.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 ""
|
|
}
|
|
|
|
// 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, 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)
|
|
}
|
|
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])
|
|
}
|
|
// 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: "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) {
|
|
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
|
|
}
|