Build Housecarl 0.1.0: the nftables firewall manager
Zones, address sets (IPv4 and IPv6 mixed, dynamic sets with timeouts) and services, ordered rules in the early, input, forward and output chains, port forwards with hairpin NAT and IPv6 pinholes, source NAT, redirects, and switchable built-ins (loopback, conntrack, ICMPv6, antispoofing, MSS clamping, DHCPv6 client, flowtable). Every change is validated, checked with nft -c and applied in one transaction to Housecarl's own table, inet housecarl; changes made over the network roll back unless confirmed, and the confirmed ruleset is loaded at boot by housecarl-ruleset.service. An empty configuration applies nothing. Also: hit counters and the drop log (nflog), 'hc import' of an existing nftables ruleset and 'hc takeover', the hc CLI, the REST API and the web UI, users, scoped tokens (sets:write:NAME), the audit log, the web UI's certificate through Jarl, Debian packages, docs, unit tests with real nft 1.1.3, and an e2e test with real traffic (71 checks). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
54beb0bba3
commit
34292eeebb
118 files changed
+22165
-114
No files matched your search
@@ -0,0 +1,838 @@
|
||||
// Package render builds the nftables ruleset from a configuration: one
|
||||
// table, inet housecarl, replaced as a whole in one transaction. Nothing
|
||||
// else on the machine is touched (docs/design.md).
|
||||
//
|
||||
// A rule can become several nftables rules: one per address family it
|
||||
// matches, per named set it refers to (a set can't be ORed with another in
|
||||
// one rule) and per transport protocol of its services. Each carries a
|
||||
// counter and a comment "hc:<kind><id>", which is how counters are read
|
||||
// back, and Ruleset.Lines maps each line to its object, for nftables'
|
||||
// error messages.
|
||||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// Table is Housecarl's table.
|
||||
const Table = "housecarl"
|
||||
|
||||
// LogGroup is the netlink log group logged packets are sent to, where
|
||||
// housecarld reads them (internal/nflog).
|
||||
const LogGroup = 4171
|
||||
|
||||
// Options carry what rendering needs from the running system.
|
||||
type Options struct {
|
||||
// Interfaces that exist now; flowtable devices that don't are left
|
||||
// out (nftables refuses them). Nil: assume they all exist.
|
||||
Interfaces map[string]bool
|
||||
// Elements are dynamic sets' current entries, re-added in the same
|
||||
// transaction so a reload keeps them: set name → entries.
|
||||
Elements map[string][]Element
|
||||
}
|
||||
|
||||
// Element is a dynamic set's entry with its remaining lifetime.
|
||||
type Element struct {
|
||||
Addr string `json:"address"`
|
||||
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
||||
}
|
||||
|
||||
// Ruleset is the rendered nftables script.
|
||||
type Ruleset struct {
|
||||
Text string
|
||||
// Lines maps a line number (1-based) to the object it came from
|
||||
// ("input #12 (ssh)", "built-in: established").
|
||||
Lines map[int]string
|
||||
}
|
||||
|
||||
// Object returns what the line belongs to (nearest object above it).
|
||||
func (r *Ruleset) Object(line int) string {
|
||||
for l := line; l > 0; l-- {
|
||||
if o, ok := r.Lines[l]; ok {
|
||||
return o
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type writer struct {
|
||||
b strings.Builder
|
||||
line int
|
||||
lines map[int]string
|
||||
}
|
||||
|
||||
func (w *writer) emit(obj, format string, args ...any) {
|
||||
w.line++
|
||||
if obj != "" {
|
||||
w.lines[w.line] = obj
|
||||
}
|
||||
fmt.Fprintf(&w.b, format, args...)
|
||||
w.b.WriteByte('\n')
|
||||
}
|
||||
|
||||
// Render builds the ruleset for st.
|
||||
func Render(st *model.State, opt Options) *Ruleset {
|
||||
w := &writer{lines: map[int]string{}}
|
||||
r := &renderer{st: st, opt: opt, w: w}
|
||||
r.render()
|
||||
return &Ruleset{Text: w.b.String(), Lines: w.lines}
|
||||
}
|
||||
|
||||
type renderer struct {
|
||||
st *model.State
|
||||
opt Options
|
||||
w *writer
|
||||
// meters are the per-source limit and connection sets the rules need.
|
||||
meters []string
|
||||
}
|
||||
|
||||
func (r *renderer) render() {
|
||||
w := r.w
|
||||
w.emit("", "# Built by Housecarl from its database; changes made here are lost.")
|
||||
w.emit("", "table inet %s {}", Table)
|
||||
w.emit("", "delete table inet %s", Table)
|
||||
w.emit("", "table inet %s {", Table)
|
||||
r.sets()
|
||||
devices := r.flowtableDevices()
|
||||
if len(devices) > 0 {
|
||||
w.emit("built-in: flowtable", "\tflowtable ft { hook ingress priority filter; devices = { %s }; }", strings.Join(quoteAll(devices), ", "))
|
||||
}
|
||||
// The chains are written to a buffer first: per-source limits add
|
||||
// meter sets, which must come before them.
|
||||
chains := &writer{lines: map[int]string{}}
|
||||
saved := r.w
|
||||
r.w = chains
|
||||
r.early()
|
||||
r.input()
|
||||
r.forward(len(devices) > 0)
|
||||
r.output()
|
||||
r.dstnat()
|
||||
r.srcnat()
|
||||
r.w = saved
|
||||
for _, m := range r.meters {
|
||||
w.emit("built-in: meters", "%s", m)
|
||||
}
|
||||
base := w.line
|
||||
w.b.WriteString(chains.b.String())
|
||||
for l, o := range chains.lines {
|
||||
w.lines[base+l] = o
|
||||
}
|
||||
w.line = base + chains.line
|
||||
w.emit("", "}")
|
||||
r.elements()
|
||||
}
|
||||
|
||||
// ---- sets
|
||||
|
||||
func (r *renderer) sets() {
|
||||
for _, s := range r.st.Sets {
|
||||
obj := "set " + s.Name
|
||||
if s.Dynamic {
|
||||
for _, f := range []string{"v4", "v6"} {
|
||||
timeout := ""
|
||||
if s.Timeout > 0 {
|
||||
timeout = fmt.Sprintf(" timeout %ds;", s.Timeout)
|
||||
}
|
||||
r.w.emit(obj, "\tset %s { type %s; flags timeout;%s }", setName(s.Name, f == "v6"), addrType(f == "v6"), timeout)
|
||||
}
|
||||
continue
|
||||
}
|
||||
has4, has6 := s.Families()
|
||||
for _, v6 := range []bool{false, true} {
|
||||
if v6 && !has6 || !v6 && !has4 {
|
||||
continue
|
||||
}
|
||||
r.w.emit(obj, "\tset %s { type %s; flags interval; auto-merge; elements = { %s } }",
|
||||
setName(s.Name, v6), addrType(v6), strings.Join(s.FamilyEntries(v6), ", "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) elements() {
|
||||
names := make([]string, 0, len(r.opt.Elements))
|
||||
for n := range r.opt.Elements {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
s := r.st.Set(n)
|
||||
if s == nil || !s.Dynamic {
|
||||
continue
|
||||
}
|
||||
for _, e := range r.opt.Elements[n] {
|
||||
a, err := model.ParseAddress(e.Addr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
timeout := ""
|
||||
if e.Timeout > 0 {
|
||||
timeout = fmt.Sprintf(" timeout %ds", e.Timeout)
|
||||
}
|
||||
r.w.emit("set "+s.Name, "add element inet %s %s { %s%s }", Table, setName(s.Name, a.IsV6()), a.String(), timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SetName is the nftables name of a set's family part.
|
||||
func SetName(name string, v6 bool) string { return setName(name, v6) }
|
||||
|
||||
func setName(name string, v6 bool) string {
|
||||
if v6 {
|
||||
return name + "_v6"
|
||||
}
|
||||
return name + "_v4"
|
||||
}
|
||||
|
||||
func addrType(v6 bool) string {
|
||||
if v6 {
|
||||
return "ipv6_addr"
|
||||
}
|
||||
return "ipv4_addr"
|
||||
}
|
||||
|
||||
func (r *renderer) flowtableDevices() []string {
|
||||
var out []string
|
||||
for _, d := range r.st.Settings.FlowtableDevices {
|
||||
if r.opt.Interfaces == nil || r.opt.Interfaces[d] {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- chains
|
||||
|
||||
func (r *renderer) chain(name, hook, prio, policy string) {
|
||||
r.w.emit("", "\tchain %s {", name)
|
||||
r.w.emit("", "\t\ttype %s hook %s priority %s; policy %s;", map[bool]string{true: "nat", false: "filter"}[prio == "dstnat" || prio == "srcnat"], hook, prio, policy)
|
||||
}
|
||||
|
||||
func (r *renderer) end() { r.w.emit("", "\t}") }
|
||||
|
||||
func (r *renderer) builtin(name, format string, args ...any) {
|
||||
r.w.emit("built-in: "+name, "\t\t"+format+" comment \"hc:b-%s\"", append(args, name)...)
|
||||
}
|
||||
|
||||
func (r *renderer) early() {
|
||||
st := r.st
|
||||
r.chain("early", "prerouting", "raw", "accept")
|
||||
ext := st.ExternalInterfaces()
|
||||
if st.Settings.Builtins.Antispoof && len(ext) > 0 {
|
||||
v4 := append([]string(nil), st.Settings.AntispoofV4...)
|
||||
v6 := append([]string(nil), st.Settings.AntispoofV6...)
|
||||
for _, p := range st.Settings.OwnPrefixes {
|
||||
if a, err := model.ParseAddress(p); err == nil && a.IsV6() {
|
||||
v6 = append(v6, a.String())
|
||||
} else if err == nil {
|
||||
v4 = append(v4, a.String())
|
||||
}
|
||||
}
|
||||
if len(v4) > 0 {
|
||||
r.builtin("antispoof", "iifname %s ip saddr { %s } counter drop", ifaces(ext), strings.Join(v4, ", "))
|
||||
}
|
||||
if len(v6) > 0 {
|
||||
r.builtin("antispoof6", "iifname %s ip6 saddr { %s } counter drop", ifaces(ext), strings.Join(v6, ", "))
|
||||
}
|
||||
}
|
||||
r.rules(model.ChainEarly)
|
||||
r.end()
|
||||
}
|
||||
|
||||
var (
|
||||
icmpv6Errors = "destination-unreachable, packet-too-big, time-exceeded, parameter-problem"
|
||||
icmpv6ND = "nd-router-solicit, nd-router-advert, nd-neighbor-solicit, nd-neighbor-advert"
|
||||
icmpv6MLD = "mld-listener-query, mld-listener-report, mld-listener-done, mld2-listener-report"
|
||||
)
|
||||
|
||||
func (r *renderer) connBuiltins() {
|
||||
b := r.st.Settings.Builtins
|
||||
if b.Established {
|
||||
r.builtin("established", "ct state established,related counter accept")
|
||||
}
|
||||
if b.DropInvalid {
|
||||
r.builtin("invalid", "ct state invalid counter drop")
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) input() {
|
||||
st := r.st
|
||||
b := st.Settings.Builtins
|
||||
r.chain("input", "input", "filter", st.Settings.InputPolicy)
|
||||
if b.Loopback {
|
||||
r.builtin("loopback", "iifname \"lo\" accept")
|
||||
}
|
||||
r.connBuiltins()
|
||||
ext := st.ExternalInterfaces()
|
||||
if b.ICMPv6 {
|
||||
r.builtin("icmpv6", "icmpv6 type { %s, echo-request, echo-reply } counter accept", icmpv6Errors)
|
||||
r.builtin("icmpv6-nd", "icmpv6 type { %s } ip6 hoplimit 255 counter accept", icmpv6ND)
|
||||
r.builtin("icmpv6-mld", "ip6 saddr fe80::/10 icmpv6 type { %s } counter accept", icmpv6MLD)
|
||||
}
|
||||
if b.PingInternal {
|
||||
if len(ext) > 0 {
|
||||
r.builtin("ping", "iifname != %s icmp type echo-request counter accept", ifaces(ext))
|
||||
} else {
|
||||
r.builtin("ping", "icmp type echo-request counter accept")
|
||||
}
|
||||
}
|
||||
if b.DHCPv6Client && len(ext) > 0 {
|
||||
r.builtin("dhcpv6-client", "iifname %s ip6 saddr fe80::/10 udp dport 546 counter accept", ifaces(ext))
|
||||
}
|
||||
r.rules(model.ChainInput)
|
||||
r.policyTail("input", st.Settings.InputPolicy)
|
||||
r.end()
|
||||
}
|
||||
|
||||
func (r *renderer) forward(flowtable bool) {
|
||||
st := r.st
|
||||
b := st.Settings.Builtins
|
||||
r.chain("forward", "forward", "filter", st.Settings.ForwardPolicy)
|
||||
ext := st.ExternalInterfaces()
|
||||
if b.MSSClamp && len(ext) > 0 {
|
||||
r.builtin("mss-clamp", "oifname %s tcp flags syn tcp option maxseg size set rt mtu", ifaces(ext))
|
||||
}
|
||||
if flowtable {
|
||||
r.builtin("flowtable", "meta l4proto { tcp, udp } flow add @ft")
|
||||
}
|
||||
r.connBuiltins()
|
||||
if b.ICMPv6 {
|
||||
r.builtin("icmpv6", "icmpv6 type { %s } counter accept", icmpv6Errors)
|
||||
}
|
||||
r.forwardAccepts()
|
||||
r.rules(model.ChainForward)
|
||||
r.policyTail("forward", st.Settings.ForwardPolicy)
|
||||
r.end()
|
||||
}
|
||||
|
||||
func (r *renderer) output() {
|
||||
st := r.st
|
||||
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
|
||||
if st.Settings.OutputPolicy == model.PolicyDrop {
|
||||
if st.Settings.Builtins.Loopback {
|
||||
r.builtin("loopback", "oifname \"lo\" accept")
|
||||
}
|
||||
if st.Settings.Builtins.Established {
|
||||
r.builtin("established", "ct state established,related counter accept")
|
||||
}
|
||||
if st.Settings.Builtins.ICMPv6 {
|
||||
r.builtin("icmpv6", "icmpv6 type { %s, echo-request, echo-reply, %s, %s } counter accept", icmpv6Errors, icmpv6ND, icmpv6MLD)
|
||||
}
|
||||
}
|
||||
r.rules(model.ChainOutput)
|
||||
r.policyTail("output", st.Settings.OutputPolicy)
|
||||
r.end()
|
||||
}
|
||||
|
||||
// policyTail counts (and optionally logs) what reaches the policy.
|
||||
func (r *renderer) policyTail(chain, policy string) {
|
||||
if policy != model.PolicyDrop {
|
||||
return
|
||||
}
|
||||
obj := "policy: " + chain
|
||||
if r.st.Settings.LogPolicyDrops && chain != "output" {
|
||||
r.w.emit(obj, "\t\tlimit rate 10/second burst 20 packets log prefix %s group %d", quote("hc "+chain+" policy drop: "), LogGroup)
|
||||
}
|
||||
r.w.emit(obj, "\t\tcounter comment \"hc:p-%s\"", chain)
|
||||
}
|
||||
|
||||
// ---- rules
|
||||
|
||||
func (r *renderer) rules(chain string) {
|
||||
for _, rule := range r.st.Rules {
|
||||
if rule.Chain != chain || rule.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := rule.Label()
|
||||
mark := fmt.Sprintf("r%d", rule.ID)
|
||||
if rule.Raw != "" {
|
||||
r.w.emit(obj, "\t\t%s", rule.Raw)
|
||||
continue
|
||||
}
|
||||
var tail []string
|
||||
if len(rule.CtState) > 0 {
|
||||
tail = append(tail, "ct state "+list(rule.CtState))
|
||||
}
|
||||
if len(rule.CtStatus) > 0 {
|
||||
tail = append(tail, "ct status "+list(rule.CtStatus))
|
||||
}
|
||||
if len(rule.Days) > 0 {
|
||||
var ds []string
|
||||
for _, d := range rule.Days {
|
||||
ds = append(ds, quote(strings.ToUpper(d[:1])+strings.ToLower(d[1:])))
|
||||
}
|
||||
tail = append(tail, "meta day "+set(ds))
|
||||
}
|
||||
if rule.HourFrom != "" {
|
||||
tail = append(tail, hours(rule.HourFrom, rule.HourTo))
|
||||
}
|
||||
verdict := r.verdict(rule, mark)
|
||||
perSource := rule.ConnLimit > 0 || rule.Limit != nil && rule.Limit.PerSource
|
||||
for _, line := range r.expand(rule.Match, perSource) {
|
||||
parts := append([]string{}, line.parts...)
|
||||
parts = append(parts, tail...)
|
||||
if l := rule.Limit; l != nil {
|
||||
parts = append(parts, r.limit(rule, line, l))
|
||||
}
|
||||
if rule.ConnLimit > 0 {
|
||||
parts = append(parts, r.connLimit(rule, line))
|
||||
}
|
||||
parts = append(parts, verdict)
|
||||
r.w.emit(obj, "\t\t%s", strings.Join(nonEmpty(parts), " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) verdict(rule *model.Rule, mark string) string {
|
||||
var v []string
|
||||
v = append(v, "counter")
|
||||
if rule.Log || rule.Action == model.ActionLog {
|
||||
prefix := rule.LogPrefix
|
||||
if prefix == "" {
|
||||
prefix = fmt.Sprintf("hc %s #%d %s: ", rule.Chain, rule.ID, rule.Action)
|
||||
}
|
||||
v = append(v, fmt.Sprintf("log prefix %s group %d", quote(prefix), LogGroup))
|
||||
}
|
||||
switch rule.Action {
|
||||
case model.ActionAccept, model.ActionDrop:
|
||||
v = append(v, rule.Action)
|
||||
case model.ActionReject:
|
||||
if rule.RejectWith == "tcp-reset" {
|
||||
v = append(v, "reject with tcp reset")
|
||||
} else {
|
||||
v = append(v, "reject")
|
||||
}
|
||||
}
|
||||
v = append(v, "comment "+comment(mark, rule.Comment))
|
||||
return strings.Join(v, " ")
|
||||
}
|
||||
|
||||
// limit renders a rate limit, per source with a meter set per family.
|
||||
func (r *renderer) limit(rule *model.Rule, line expanded, l *model.Limit) string {
|
||||
rate := fmt.Sprintf("limit rate %s%d/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, l.Per)
|
||||
if l.Burst > 0 {
|
||||
rate += fmt.Sprintf(" burst %d packets", l.Burst)
|
||||
}
|
||||
if !l.PerSource {
|
||||
return rate
|
||||
}
|
||||
name := fmt.Sprintf("meter_r%d_%s", rule.ID, line.fam)
|
||||
if !r.hasMeter(name) {
|
||||
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic,timeout; timeout 1%s; }", name, addrType(line.fam == "v6"), map[string]string{"second": "m", "minute": "m", "hour": "h", "day": "d"}[l.Per]))
|
||||
}
|
||||
return fmt.Sprintf("add @%s { %s saddr %s }", name, ipKeyword(line.fam), rate)
|
||||
}
|
||||
|
||||
func (r *renderer) connLimit(rule *model.Rule, line expanded) string {
|
||||
name := fmt.Sprintf("conn_r%d_%s", rule.ID, line.fam)
|
||||
if !r.hasMeter(name) {
|
||||
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535; }", name, addrType(line.fam == "v6")))
|
||||
}
|
||||
return fmt.Sprintf("add @%s { %s saddr ct count over %d }", name, ipKeyword(line.fam), rule.ConnLimit)
|
||||
}
|
||||
|
||||
func (r *renderer) hasMeter(name string) bool {
|
||||
for _, m := range r.meters {
|
||||
if strings.Contains(m, "set "+name+" ") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// expanded is one nftables rule's match: its family ("v4", "v6" or ""
|
||||
// for both) and its parts.
|
||||
type expanded struct {
|
||||
fam string
|
||||
parts []string
|
||||
}
|
||||
|
||||
// expand turns a Match into nftables matches: one per family, named set
|
||||
// and protocol it needs (see the package comment).
|
||||
func (r *renderer) expand(m model.Match, perSource bool) []expanded {
|
||||
st := r.st
|
||||
var head []string
|
||||
if len(m.InZones) > 0 {
|
||||
head = append(head, "iifname "+neg(m.NotIn)+ifaces(r.zoneIfaces(m.InZones)))
|
||||
}
|
||||
if len(m.OutZones) > 0 {
|
||||
head = append(head, "oifname "+neg(m.NotOut)+ifaces(r.zoneIfaces(m.OutZones)))
|
||||
}
|
||||
s4, s6 := st.SplitRefs(m.Sources)
|
||||
d4, d6 := st.SplitRefs(m.Destinations)
|
||||
entries := st.ServiceEntries(m.Services)
|
||||
hasICMP := false
|
||||
for _, e := range entries {
|
||||
hasICMP = hasICMP || e.IsICMP()
|
||||
}
|
||||
// A rule that names no addresses and no ICMP needs no family split,
|
||||
// unless it is limited to one family or keeps per-source state.
|
||||
perFamily := perSource || len(m.Sources) > 0 || len(m.Destinations) > 0 || hasICMP || m.Family != ""
|
||||
if !perFamily {
|
||||
var out []expanded
|
||||
for _, svc := range serviceAlts(entries, "") {
|
||||
out = append(out, expanded{"", append(append([]string{}, head...), svc...)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
var out []expanded
|
||||
for _, fam := range []string{"v4", "v6"} {
|
||||
if m.Family == model.FamilyIPv4 && fam == "v6" || m.Family == model.FamilyIPv6 && fam == "v4" {
|
||||
continue
|
||||
}
|
||||
src, dst := s4, d4
|
||||
if fam == "v6" {
|
||||
src, dst = s6, d6
|
||||
}
|
||||
srcAlts, ok := addrAlts(fam, "saddr", src, len(m.Sources) > 0, m.NotSources)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
dstAlts, ok := addrAlts(fam, "daddr", dst, len(m.Destinations) > 0, m.NotDests)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
svcs := serviceAlts(entries, fam)
|
||||
if svcs == nil {
|
||||
continue
|
||||
}
|
||||
for _, sa := range srcAlts {
|
||||
for _, da := range dstAlts {
|
||||
for _, sv := range svcs {
|
||||
parts := append([]string{}, head...)
|
||||
if sa == "" && da == "" && !strings.HasPrefix(strings.Join(sv, " "), "icmp") {
|
||||
parts = append(parts, "meta nfproto "+map[string]string{"v4": "ipv4", "v6": "ipv6"}[fam])
|
||||
}
|
||||
parts = append(parts, sa, da)
|
||||
parts = append(parts, sv...)
|
||||
out = append(out, expanded{fam, parts})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// addrAlts returns the alternatives for one side's addresses in one
|
||||
// family: one per named set, plus one for the literals. Negated, all go
|
||||
// into one rule. ok is false when the side was given but has nothing of
|
||||
// this family (the rule can't match it).
|
||||
func addrAlts(fam, dir string, refs model.FamilyRefs, given, negated bool) (alts []string, ok bool) {
|
||||
kw := ipKeyword(fam) + " " + dir + " "
|
||||
if !given {
|
||||
return []string{""}, true
|
||||
}
|
||||
if negated {
|
||||
var parts []string
|
||||
for _, s := range refs.Sets {
|
||||
parts = append(parts, kw+"!= @"+setName(s, fam == "v6"))
|
||||
}
|
||||
if len(refs.Literals) > 0 {
|
||||
parts = append(parts, kw+"!= "+set(refs.Literals))
|
||||
}
|
||||
return []string{strings.Join(parts, " ")}, true
|
||||
}
|
||||
if refs.Empty() {
|
||||
return nil, false
|
||||
}
|
||||
for _, s := range refs.Sets {
|
||||
alts = append(alts, kw+"@"+setName(s, fam == "v6"))
|
||||
}
|
||||
if len(refs.Literals) > 0 {
|
||||
alts = append(alts, kw+set(refs.Literals))
|
||||
}
|
||||
return alts, true
|
||||
}
|
||||
|
||||
// serviceAlts groups service entries into nftables matches: one per
|
||||
// transport protocol, and one for the family's ICMP types. fam "" is both
|
||||
// families (no ICMP then). nil means nothing matches this family.
|
||||
func serviceAlts(entries []model.ServiceEntry, fam string) [][]string {
|
||||
if len(entries) == 0 {
|
||||
return [][]string{{}}
|
||||
}
|
||||
ports := map[string][]string{}
|
||||
var protos []string
|
||||
var icmp []string
|
||||
for _, e := range entries {
|
||||
switch {
|
||||
case e.Proto == "icmp" && fam == "v4", e.Proto == "icmpv6" && fam == "v6":
|
||||
icmp = append(icmp, e.Type)
|
||||
case !e.IsICMP():
|
||||
if _, ok := ports[e.Proto]; !ok {
|
||||
protos = append(protos, e.Proto)
|
||||
}
|
||||
ports[e.Proto] = append(ports[e.Proto], e.Ports)
|
||||
}
|
||||
}
|
||||
var out [][]string
|
||||
for _, p := range protos {
|
||||
out = append(out, []string{p + " dport " + set(ports[p])})
|
||||
}
|
||||
if len(icmp) > 0 {
|
||||
out = append(out, []string{map[string]string{"v4": "icmp", "v6": "icmpv6"}[fam] + " type " + set(icmp)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (r *renderer) zoneIfaces(zones []string) []string {
|
||||
var out []string
|
||||
for _, n := range zones {
|
||||
if z := r.st.Zone(n); z != nil {
|
||||
out = append(out, z.Interfaces...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- NAT
|
||||
|
||||
func (r *renderer) dstnat() {
|
||||
st := r.st
|
||||
r.chain("dstnat", "prerouting", "dstnat", "accept")
|
||||
for _, x := range st.Redirects {
|
||||
if x.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := x.Label()
|
||||
mark := fmt.Sprintf("d%d", x.ID)
|
||||
for _, line := range r.expand(x.Match, false) {
|
||||
to := x.ToPort
|
||||
if to == "" {
|
||||
for _, e := range st.ServiceEntries(x.Services) {
|
||||
if !e.IsICMP() {
|
||||
lo, _ := model.PortRange(e.Ports)
|
||||
to = fmt.Sprint(lo)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter redirect to :%s comment %s", strings.Join(nonEmpty(line.parts), " "), to, comment(mark, x.Comment))
|
||||
}
|
||||
}
|
||||
ext := st.ExternalInterfaces()
|
||||
for _, f := range st.Forwards {
|
||||
if f.Disabled || f.To == "" {
|
||||
continue
|
||||
}
|
||||
obj := f.Label()
|
||||
mark := fmt.Sprintf("f%d", f.ID)
|
||||
where := []string{}
|
||||
if len(f.PublicAddresses) > 0 {
|
||||
pub, _ := st.SplitRefs(f.PublicAddresses)
|
||||
alts, _ := addrAlts("v4", "daddr", pub, true, false)
|
||||
where = alts
|
||||
} else {
|
||||
where = []string{"iifname " + ifaces(ext) + " meta nfproto ipv4"}
|
||||
}
|
||||
src4, _ := st.SplitRefs(f.Sources)
|
||||
srcAlts, ok := addrAlts("v4", "saddr", src4, len(f.Sources) > 0, false)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
to := f.To
|
||||
if f.ToPort != "" {
|
||||
to += ":" + f.ToPort
|
||||
}
|
||||
for _, wh := range where {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter dnat ip to %s comment %s", strings.Join(nonEmpty([]string{wh, sa, p + " dport " + f.Ports}), " "), to, comment(mark, f.Name+" "+f.Comment))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.end()
|
||||
}
|
||||
|
||||
// forwardAccepts lets port-forwarded connections through, and opens the
|
||||
// IPv6 pinholes.
|
||||
func (r *renderer) forwardAccepts() {
|
||||
st := r.st
|
||||
for _, f := range st.Forwards {
|
||||
if f.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := f.Label()
|
||||
mark := fmt.Sprintf("f%d", f.ID)
|
||||
ports := f.Ports
|
||||
if f.ToPort != "" {
|
||||
ports = f.ToPort
|
||||
}
|
||||
src4, src6 := st.SplitRefs(f.Sources)
|
||||
if f.To != "" {
|
||||
if srcAlts, ok := addrAlts("v4", "saddr", src4, len(f.Sources) > 0, false); ok {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter accept comment %s", strings.Join(nonEmpty([]string{"ip daddr " + f.To, sa, p + " dport " + ports, "ct status dnat"}), " "), comment(mark, f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.To6 != "" {
|
||||
if srcAlts, ok := addrAlts("v6", "saddr", src6, len(f.Sources) > 0, false); ok {
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
// No translation for IPv6: the host's own port.
|
||||
r.w.emit(obj, "\t\t%s counter accept comment %s", strings.Join(nonEmpty([]string{"ip6 daddr " + f.To6, sa, p + " dport " + ports}), " "), comment(mark+"-6", f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *renderer) srcnat() {
|
||||
st := r.st
|
||||
r.chain("srcnat", "postrouting", "srcnat", "accept")
|
||||
ext := st.ExternalInterfaces()
|
||||
for _, f := range st.Forwards {
|
||||
if f.Disabled || !f.Hairpin || f.To == "" {
|
||||
continue
|
||||
}
|
||||
obj := f.Label()
|
||||
mark := fmt.Sprintf("f%d-hairpin", f.ID)
|
||||
ports := f.Ports
|
||||
if f.ToPort != "" {
|
||||
ports = f.ToPort
|
||||
}
|
||||
var srcAlts []string
|
||||
if len(f.HairpinSources) > 0 {
|
||||
h4, _ := st.SplitRefs(f.HairpinSources)
|
||||
alts, ok := addrAlts("v4", "saddr", h4, true, false)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
srcAlts = alts
|
||||
} else if len(ext) > 0 {
|
||||
srcAlts = []string{"iifname != " + ifaces(ext)}
|
||||
} else {
|
||||
srcAlts = []string{""}
|
||||
}
|
||||
for _, sa := range srcAlts {
|
||||
for _, p := range f.Protocols {
|
||||
r.w.emit(obj, "\t\t%s counter masquerade comment %s", strings.Join(nonEmpty([]string{sa, "ip daddr " + f.To, p + " dport " + ports, "ct status dnat"}), " "), comment(mark, f.Name))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, x := range st.SNATs {
|
||||
if x.Disabled {
|
||||
continue
|
||||
}
|
||||
obj := x.Label()
|
||||
mark := fmt.Sprintf("s%d", x.ID)
|
||||
for _, line := range r.expand(x.Match, false) {
|
||||
// Masquerading is for IPv4; IPv6 only when asked for (NAT66).
|
||||
if x.To == "" && line.fam == "v6" && x.Family != model.FamilyIPv6 {
|
||||
continue
|
||||
}
|
||||
if x.To == "" && line.fam == "" && x.Family != model.FamilyIPv6 {
|
||||
line.parts = append([]string{"meta nfproto ipv4"}, line.parts...)
|
||||
}
|
||||
action := "masquerade"
|
||||
if x.To != "" {
|
||||
if a, err := model.ParseAddress(x.To); err == nil && a.IsV6() {
|
||||
if line.fam == "v4" {
|
||||
continue
|
||||
}
|
||||
action = "snat ip6 to " + x.To
|
||||
} else {
|
||||
if line.fam == "v6" {
|
||||
continue
|
||||
}
|
||||
action = "snat ip to " + x.To
|
||||
}
|
||||
}
|
||||
r.w.emit(obj, "\t\t%s counter %s comment %s", strings.Join(nonEmpty(line.parts), " "), action, comment(mark, x.Comment))
|
||||
}
|
||||
}
|
||||
r.end()
|
||||
}
|
||||
|
||||
// ---- helpers
|
||||
|
||||
// hours writes a time window. nftables can't take a range past midnight,
|
||||
// so 22:00-06:00 becomes "not 06:00-21:59:59".
|
||||
func hours(from, to string) string {
|
||||
if from <= to {
|
||||
return fmt.Sprintf("meta hour %s-%s", quote(from), quote(to))
|
||||
}
|
||||
h, m := 0, 0
|
||||
fmt.Sscanf(from, "%d:%d", &h, &m)
|
||||
mins := h*60 + m - 1
|
||||
return fmt.Sprintf("meta hour != %s-%s", quote(to), quote(fmt.Sprintf("%02d:%02d:59", mins/60, mins%60)))
|
||||
}
|
||||
|
||||
func ipKeyword(fam string) string {
|
||||
if fam == "v6" {
|
||||
return "ip6"
|
||||
}
|
||||
return "ip"
|
||||
}
|
||||
|
||||
func neg(b bool) string {
|
||||
if b {
|
||||
return "!= "
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ifaces writes interface names: one quoted, or an anonymous set.
|
||||
func ifaces(names []string) string {
|
||||
q := quoteAll(names)
|
||||
if len(q) == 1 {
|
||||
return q[0]
|
||||
}
|
||||
return "{ " + strings.Join(q, ", ") + " }"
|
||||
}
|
||||
|
||||
func quoteAll(in []string) []string {
|
||||
out := make([]string, len(in))
|
||||
for i, s := range in {
|
||||
out[i] = quote(s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func quote(s string) string {
|
||||
return `"` + strings.NewReplacer(`"`, `'`, `\`, `/`, "\n", " ").Replace(s) + `"`
|
||||
}
|
||||
|
||||
// set writes one value, or an anonymous set of several.
|
||||
func set(vals []string) string {
|
||||
if len(vals) == 1 {
|
||||
return vals[0]
|
||||
}
|
||||
return "{ " + strings.Join(vals, ", ") + " }"
|
||||
}
|
||||
|
||||
// list writes ct state/status values: one, or a comma list.
|
||||
func list(vals []string) string {
|
||||
return strings.Join(vals, ",")
|
||||
}
|
||||
|
||||
func nonEmpty(in []string) []string {
|
||||
var out []string
|
||||
for _, s := range in {
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// comment writes the "hc:<mark> text" comment counters are read back by
|
||||
// (nftables allows 128 bytes).
|
||||
func comment(mark, text string) string {
|
||||
c := strings.TrimSpace("hc:" + mark + " " + strings.TrimSpace(text))
|
||||
var b strings.Builder
|
||||
for _, r := range c {
|
||||
if r >= 32 && r != 127 && b.Len()+len(string(r)) <= 120 {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return quote(b.String())
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
)
|
||||
|
||||
// router is a configuration like the maintainer's router: PPPoE WAN,
|
||||
// a LAN, WireGuard, Incus bridges, port forwards with hairpin NAT and an
|
||||
// IPv6 pinhole, plus the common features the tests exercise.
|
||||
func router() *model.State {
|
||||
st := model.NewState()
|
||||
st.Settings.Builtins.MSSClamp = true
|
||||
st.Settings.OwnPrefixes = []string{"2a02:8013:7f3d::/48"}
|
||||
st.Settings.FlowtableDevices = []string{"lo"}
|
||||
st.Settings.LogPolicyDrops = true
|
||||
id := int64(0)
|
||||
next := func() int64 { id++; return id }
|
||||
st.Zones = []*model.Zone{
|
||||
{ID: next(), Name: "wan", Interfaces: []string{"wan"}, External: true},
|
||||
{ID: next(), Name: "lan", Interfaces: []string{"enp2s0"}},
|
||||
{ID: next(), Name: "wg", Interfaces: []string{"wg0"}},
|
||||
{ID: next(), Name: "incus", Interfaces: []string{"incusbr*"}},
|
||||
}
|
||||
st.Sets = []*model.AddressSet{
|
||||
{ID: next(), Name: "trusted", Entries: []string{"10.0.10.0/24", "10.20.30.0/24", "10.50.50.0/24", "2a02:8013:7f3d:10::/64"}},
|
||||
{ID: next(), Name: "wg_net", Entries: []string{"10.50.50.0/24"}},
|
||||
{ID: next(), Name: "wg_admins", Entries: []string{"10.50.50.3", "10.50.50.6"}},
|
||||
{ID: next(), Name: "dns_servers", Entries: []string{"10.0.10.5", "217.155.8.194", "fdab:abab:abab:10::5", "2a02:8013:7f3d:10::5"}},
|
||||
{ID: next(), Name: "guests", Dynamic: true, Timeout: 3600},
|
||||
}
|
||||
st.Services = []*model.Service{
|
||||
{ID: next(), Name: "jarl", Entries: []string{"tcp/8053"}},
|
||||
{ID: next(), Name: "doh-dot", Entries: []string{"tcp/8443", "tcp/853"}},
|
||||
}
|
||||
pos := map[string]float64{}
|
||||
rule := func(chain string, r model.Rule) {
|
||||
pos[chain]++
|
||||
r.ID, r.Chain, r.Position = next(), chain, pos[chain]
|
||||
if r.Action == "" {
|
||||
r.Action = model.ActionAccept
|
||||
}
|
||||
st.Rules = append(st.Rules, &r)
|
||||
}
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"wireguard"}}, CtState: []string{"new"}, Comment: "WireGuard"})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_net"}, Services: []string{"dns"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_admins"}, Services: []string{"ssh"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"wg_net"}, Destinations: []string{"10.50.50.1"}}, Action: model.ActionDrop})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"dns"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Destinations: []string{"dns_servers"}, Services: []string{"doh-dot"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Sources: []string{"trusted"}, Services: []string{"ssh", "jarl", "tcp/8067"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"lan"}, Services: []string{"dhcp"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"lan"}, Sources: []string{"fe80::/10"}, Services: []string{"dhcpv6"}}})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{InZones: []string{"wan"}, Services: []string{"ssh"}}, Action: model.ActionDrop,
|
||||
Limit: &model.Limit{Rate: 3, Per: "minute", Burst: 5, Over: true, PerSource: true}, Log: true})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"ssh"}}, ConnLimit: 10, Action: model.ActionReject, RejectWith: "tcp-reset"})
|
||||
rule(model.ChainInput, model.Rule{Match: model.Match{Services: []string{"http"}}, Days: []string{"monday", "Friday"}, HourFrom: "22:00", HourTo: "06:00"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{NotIn: true, InZones: []string{"wan"}, Destinations: []string{"trusted"}}, CtState: []string{"invalid"}, Comment: "asymmetric routing"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"lan"}, OutZones: []string{"wan"}, Sources: []string{"2a02:8013:7f3d:10::/64"}}})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{InZones: []string{"wan"}}, Action: model.ActionDrop, Comment: "nothing else in"})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{Destinations: []string{"wg_net"}}, Action: model.ActionDrop})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{Services: []string{"ping"}}})
|
||||
rule(model.ChainForward, model.Rule{Match: model.Match{Sources: []string{"wg_net", "trusted", "guests"}}})
|
||||
rule(model.ChainEarly, model.Rule{Match: model.Match{InZones: []string{"wan"}, Sources: []string{"198.51.100.0/24"}}, Action: model.ActionDrop})
|
||||
rule(model.ChainOutput, model.Rule{Raw: "meta skuid 0 counter accept", Comment: "raw"})
|
||||
st.Forwards = []*model.PortForward{
|
||||
{ID: next(), Position: 1, Name: "jellyfin", Protocols: []string{"tcp"}, Ports: "8096", PublicAddresses: []string{"217.155.8.194"},
|
||||
To: "10.0.10.57", ToPort: "443", To6: "2a02:8013:7f3d:10::57", Hairpin: true, HairpinSources: []string{"10.0.10.0/24"}},
|
||||
{ID: next(), Position: 2, Name: "terraria", Protocols: []string{"tcp", "udp"}, Ports: "7777-7780", PublicAddresses: []string{"217.155.8.194"},
|
||||
To: "10.0.10.76", Hairpin: true},
|
||||
{ID: next(), Position: 3, Name: "smtp", Protocols: []string{"tcp"}, Ports: "25", To: "10.0.10.25", Sources: []string{"trusted"}},
|
||||
}
|
||||
st.SNATs = []*model.SourceNAT{
|
||||
{ID: next(), Position: 1, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"trusted"}}, Comment: "external masquerade"},
|
||||
{ID: next(), Position: 2, Match: model.Match{NotOut: true, OutZones: []string{"wg"}, Sources: []string{"wg_net"}}},
|
||||
{ID: next(), Position: 3, Match: model.Match{OutZones: []string{"wan"}, Sources: []string{"fdab:abab:abab::/48"}, Family: "ipv6"}, To: "2a02:8013:7f3d::1"},
|
||||
}
|
||||
st.Redirects = []*model.Redirect{
|
||||
{ID: next(), Position: 1, Match: model.Match{InZones: []string{"lan"}, NotDests: true, Destinations: []string{"10.0.10.5"}, Services: []string{"udp/53"}}, Comment: "force DNS"},
|
||||
}
|
||||
st.Sort()
|
||||
return st
|
||||
}
|
||||
|
||||
func TestRenderRouter(t *testing.T) {
|
||||
rs := Render(router(), Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}}}})
|
||||
text := rs.Text
|
||||
for _, want := range []string{
|
||||
"table inet housecarl {}\ndelete table inet housecarl\ntable inet housecarl {",
|
||||
`set trusted_v4 { type ipv4_addr; flags interval; auto-merge; elements = { 10.0.10.0/24, 10.20.30.0/24, 10.50.50.0/24 } }`,
|
||||
`set trusted_v6 { type ipv6_addr; flags interval; auto-merge; elements = { 2a02:8013:7f3d:10::/64 } }`,
|
||||
`set guests_v4 { type ipv4_addr; flags timeout; timeout 3600s; }`,
|
||||
`iifname "wan" ip saddr { 0.0.0.0/8,`,
|
||||
`iifname "wan" ip6 saddr { ::1/128, ::/128, fc00::/7, 2001:db8::/32, ff00::/8, 2a02:8013:7f3d::/48 } counter drop`,
|
||||
`oifname "wan" tcp flags syn tcp option maxseg size set rt mtu comment "hc:b-mss-clamp"`,
|
||||
`udp dport 51820 ct state new counter accept comment "hc:r`,
|
||||
// DNS from the WireGuard network: v4 only, both protocols.
|
||||
`ip saddr @wg_net_v4 udp dport 53 counter accept`,
|
||||
`ip saddr @wg_net_v4 tcp dport 53 counter accept`,
|
||||
// A set holding both families: one rule per family.
|
||||
`ip daddr @dns_servers_v4 tcp dport { 8443, 853 } counter accept`,
|
||||
`ip6 daddr @dns_servers_v6 tcp dport { 8443, 853 } counter accept`,
|
||||
`ip saddr @trusted_v4 tcp dport { 22, 8053, 8067 } counter accept`,
|
||||
`ip6 saddr @trusted_v6 tcp dport { 22, 8053, 8067 } counter accept`,
|
||||
`iifname "enp2s0" ip6 saddr fe80::/10 udp dport 547 counter accept`,
|
||||
`iifname "wan" meta nfproto ipv4 tcp dport 22 add @meter_r`,
|
||||
`ip saddr limit rate over 3/minute burst 5 packets } counter log prefix "hc input #`,
|
||||
`tcp dport 22 add @conn_r`,
|
||||
`counter reject with tcp reset comment`,
|
||||
`meta day { "Monday", "Friday" } meta hour != "06:00"-"21:59:59"`,
|
||||
`iifname != "wan" ip daddr @trusted_v4 ct state invalid counter accept comment "hc:r`,
|
||||
`icmp type echo-request counter accept`,
|
||||
`icmpv6 type echo-request counter accept`,
|
||||
`ip saddr @guests_v4 counter accept`,
|
||||
`ip6 saddr @guests_v6 counter accept`,
|
||||
`meta skuid 0 counter accept`,
|
||||
`ip daddr 217.155.8.194 tcp dport 8096 counter dnat ip to 10.0.10.57:443 comment "hc:f`,
|
||||
`ip daddr 217.155.8.194 udp dport 7777-7780 counter dnat ip to 10.0.10.76 comment`,
|
||||
`iifname "wan" meta nfproto ipv4 ip saddr @trusted_v4 tcp dport 25 counter dnat ip to 10.0.10.25`,
|
||||
`ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter accept`,
|
||||
`ip6 daddr 2a02:8013:7f3d:10::57 tcp dport 443 counter accept`,
|
||||
`ip saddr 10.0.10.0/24 ip daddr 10.0.10.57 tcp dport 443 ct status dnat counter masquerade`,
|
||||
`iifname != "wan" ip daddr 10.0.10.76 udp dport 7777-7780 ct status dnat counter masquerade`,
|
||||
`oifname "wan" ip saddr @trusted_v4 counter masquerade`,
|
||||
`oifname != "wg0" ip saddr @wg_net_v4 counter masquerade`,
|
||||
`oifname "wan" ip6 saddr fdab:abab:abab::/48 counter snat ip6 to 2a02:8013:7f3d::1`,
|
||||
`iifname "enp2s0" ip daddr != 10.0.10.5 udp dport 53 counter redirect to :53 comment "hc:d`,
|
||||
`limit rate 10/second burst 20 packets log prefix "hc forward policy drop: " group 4171`,
|
||||
`counter comment "hc:p-forward"`,
|
||||
`flowtable ft { hook ingress priority filter; devices = { "lo" }; }`,
|
||||
`add element inet housecarl guests_v4 { 10.0.30.5 timeout 1700s }`,
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("missing %q", want)
|
||||
}
|
||||
}
|
||||
// The trusted masquerade has no IPv6 part: masquerading the IPv6 LAN
|
||||
// isn't wanted (its sources are global).
|
||||
if strings.Contains(text, "ip6 saddr @trusted_v6 counter masquerade") {
|
||||
t.Errorf("masquerade rendered for IPv6 too")
|
||||
}
|
||||
if t.Failed() {
|
||||
t.Log(text)
|
||||
}
|
||||
// Lines map back to their objects.
|
||||
for i, l := range strings.Split(text, "\n") {
|
||||
if strings.Contains(l, "8096 counter dnat") && rs.Object(i+1) != "jellyfin" {
|
||||
t.Errorf("line %d belongs to %q", i+1, rs.Object(i+1))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderedRulesetPassesNft has the real nft check the router's
|
||||
// ruleset (and a minimal one). Set HOUSECARL_NFT to how nft is run, e.g.
|
||||
//
|
||||
// HOUSECARL_NFT="docker run --rm -i --cap-add NET_ADMIN housecarl-nft nft"
|
||||
//
|
||||
// (nft 1.1.3, Debian 13's), or run as root with nft installed.
|
||||
func TestRenderedRulesetPassesNft(t *testing.T) {
|
||||
cmd := strings.Fields(os.Getenv("HOUSECARL_NFT"))
|
||||
if len(cmd) == 0 {
|
||||
t.Skip("HOUSECARL_NFT not set")
|
||||
}
|
||||
for name, st := range map[string]*model.State{"router": router(), "empty": model.NewState()} {
|
||||
rs := Render(st, Options{Elements: map[string][]Element{"guests": {{Addr: "10.0.30.5", Timeout: 1700}, {Addr: "2a02:8013:7f3d:30::5"}}}})
|
||||
c := exec.CommandContext(context.Background(), cmd[0], append(cmd[1:], "-c", "-f", "/dev/stdin")...)
|
||||
c.Stdin = strings.NewReader(rs.Text)
|
||||
if out, err := c.CombinedOutput(); err != nil {
|
||||
t.Errorf("%s: nft -c: %v\n%s", name, err, out)
|
||||
for i, l := range strings.Split(rs.Text, "\n") {
|
||||
t.Logf("%3d %s", i+1, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user