- Connections: the kernel's tracked connections, read and closed over ctnetlink (internal/conntrack, raw netlink like the drop log), with their NAT and TCP state; closing one makes the rules check its next packet again (GET /connections, POST /connections/close; hc connections; Traffic → Connections). - Rules can check every packet (every_packet, --every-packet): before established connections are accepted, so a drop or reject also stops connections already open. They can't accept. - A packet tracer (internal/trace): a table of its own, inet housecarl-trace, marks the packets a filter picks, `nft monitor trace` reports every rule and policy they meet in every table, and the tracer names what accepted or dropped them, Housecarl's rules by name (POST, GET, DELETE /trace; hc trace; Traffic → Trace). One trace at a time, at most 200 packets and 20 a second; the table goes when it ends. e2e: 157 checks, among them an upload a normal block doesn't stop and an every-packet one does, and a trace naming the blocking rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
952 lines
27 KiB
Go
952 lines
27 KiB
Go
// 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"
|
|
"net/netip"
|
|
"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
|
|
// Quotas are the rules' quotas' used bytes (quota name, quota_r12),
|
|
// and QuotaSets the per-address quotas' entries (set name,
|
|
// quota_r12_v4), so a reload keeps what was used.
|
|
Quotas map[string]int64
|
|
QuotaSets map[string][]Element
|
|
}
|
|
|
|
// Element is a dynamic set's entry with its remaining lifetime, or a
|
|
// per-address quota's entry with what it used.
|
|
type Element struct {
|
|
Addr string `json:"address"`
|
|
Timeout int `json:"timeout,omitempty"` // seconds left (0: none)
|
|
Used int64 `json:"used,omitempty"` // bytes
|
|
}
|
|
|
|
// QuotaName is the name of a rule's quota: the named quota of a total
|
|
// quota, and (with _v4, _v6) the sets of a per-address one.
|
|
func QuotaName(ruleID int64) string { return fmt.Sprintf("quota_r%d", ruleID) }
|
|
|
|
// 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-address limit, connection and quota sets, and
|
|
// the named quotas, 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, true)
|
|
r.rules(model.ChainEarly, false)
|
|
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")
|
|
}
|
|
// Rules that count traffic see every packet: before the established
|
|
// connections are accepted.
|
|
r.rules(model.ChainInput, true)
|
|
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, false)
|
|
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))
|
|
}
|
|
r.rules(model.ChainForward, true)
|
|
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, false)
|
|
r.policyTail("forward", st.Settings.ForwardPolicy)
|
|
r.end()
|
|
}
|
|
|
|
func (r *renderer) output() {
|
|
st := r.st
|
|
r.chain("output", "output", "filter", st.Settings.OutputPolicy)
|
|
r.rules(model.ChainOutput, true)
|
|
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, false)
|
|
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
|
|
|
|
// rules writes a chain's rules: those that count traffic (traffic true),
|
|
// or the others.
|
|
func (r *renderer) rules(chain string, traffic bool) {
|
|
for _, rule := range r.st.Rules {
|
|
if rule.Chain != chain || rule.Disabled || rule.ChecksEveryPacket() != traffic {
|
|
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)
|
|
perAddress := rule.ConnLimit > 0 || rule.Limit.PerAddress() != "" || rule.Quota.PerAddress() != ""
|
|
for _, line := range r.expand(rule.Match, perAddress) {
|
|
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))
|
|
}
|
|
if rule.Quota != nil {
|
|
parts = append(parts, r.quota(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: of packets or bytes; per source or
|
|
// destination with a meter set per family, whose entries are refreshed
|
|
// by each packet (update) and forgotten once a source is quiet.
|
|
func (r *renderer) limit(rule *model.Rule, line expanded, l *model.Limit) string {
|
|
unit := ""
|
|
if _, ok := model.ByteUnits[l.Unit]; ok {
|
|
unit = " " + l.Unit
|
|
}
|
|
rate := fmt.Sprintf("limit rate %s%d%s/%s", map[bool]string{true: "over ", false: ""}[l.Over], l.Rate, unit, l.Per)
|
|
if l.Burst > 0 {
|
|
burstUnit := " packets"
|
|
if unit != "" {
|
|
burstUnit = unit
|
|
}
|
|
rate += fmt.Sprintf(" burst %d%s", l.Burst, burstUnit)
|
|
}
|
|
addr := l.PerAddress()
|
|
if addr == "" {
|
|
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("update @%s { %s %s }", name, r.addrKey(rule, line.fam, addr), rate)
|
|
}
|
|
|
|
// addrKey is what a per-address meter counts by: "ip saddr", or grouped
|
|
// by a prefix, "ip6 saddr and ffff:ffff:ffff:ffff::".
|
|
func (r *renderer) addrKey(rule *model.Rule, fam, addr string) string {
|
|
v4, v6 := rule.Groups()
|
|
bits, size := v4, 32
|
|
if fam == "v6" {
|
|
bits, size = v6, 128
|
|
}
|
|
key := ipKeyword(fam) + " " + addr
|
|
if bits >= size {
|
|
return key
|
|
}
|
|
mask := make([]byte, size/8)
|
|
for i := 0; i < bits; i++ {
|
|
mask[i/8] |= 0x80 >> (i % 8)
|
|
}
|
|
m, _ := netip.AddrFromSlice(mask)
|
|
return key + " and " + m.String()
|
|
}
|
|
|
|
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 ct count over %d }", name, r.addrKey(rule, line.fam, "saddr"), rule.ConnLimit)
|
|
}
|
|
|
|
// quota renders a rule's quota: a named quota shared by the rule's lines
|
|
// (both families count against one allowance), or per address a set of
|
|
// quotas per family. What was used comes back from Options, so a reload
|
|
// doesn't reset it.
|
|
func (r *renderer) quota(rule *model.Rule, line expanded) string {
|
|
q := rule.Quota
|
|
amount := quotaAmount(q)
|
|
name := QuotaName(rule.ID)
|
|
addr := q.PerAddress()
|
|
if addr == "" {
|
|
if !r.hasMeter(name) {
|
|
used := ""
|
|
if u := r.opt.Quotas[name]; u > 0 {
|
|
used = fmt.Sprintf(" used %d bytes", u)
|
|
}
|
|
r.meters = append(r.meters, fmt.Sprintf("\tquota %s { %s%s }", name, amount, used))
|
|
}
|
|
return fmt.Sprintf("quota name %s", quote(name))
|
|
}
|
|
set := name + "_" + line.fam
|
|
if !r.hasMeter(set) {
|
|
var elems []string
|
|
for _, e := range r.opt.QuotaSets[set] {
|
|
a, err := netip.ParseAddr(e.Addr)
|
|
if err != nil || a.Is6() != (line.fam == "v6") {
|
|
continue
|
|
}
|
|
used := ""
|
|
if e.Used > 0 {
|
|
used = fmt.Sprintf(" used %d bytes", e.Used)
|
|
}
|
|
elems = append(elems, fmt.Sprintf("%s quota %s%s", a, amount, used))
|
|
}
|
|
list := ""
|
|
if len(elems) > 0 {
|
|
list = " elements = { " + strings.Join(elems, ", ") + " }"
|
|
}
|
|
r.meters = append(r.meters, fmt.Sprintf("\tset %s { type %s; flags dynamic; size 65535;%s }", set, addrType(line.fam == "v6"), list))
|
|
}
|
|
return fmt.Sprintf("add @%s { %s quota %s }", set, r.addrKey(rule, line.fam, addr), amount)
|
|
}
|
|
|
|
// quotaAmount writes a quota: "over 5000 mbytes" (nftables 1.1.3 has no
|
|
// gbytes).
|
|
func quotaAmount(q *model.Quota) string {
|
|
n, unit := q.Bytes, "bytes"
|
|
for _, u := range []string{"mbytes", "kbytes"} {
|
|
if n >= model.ByteUnits[u] && n%model.ByteUnits[u] == 0 {
|
|
n, unit = n/model.ByteUnits[u], u
|
|
break
|
|
}
|
|
}
|
|
if q.Over {
|
|
return fmt.Sprintf("over %d %s", n, unit)
|
|
}
|
|
return fmt.Sprintf("until %d %s", n, unit)
|
|
}
|
|
|
|
func (r *renderer) hasMeter(name string) bool {
|
|
for _, m := range r.meters {
|
|
if strings.Contains(m, "set "+name+" ") || strings.Contains(m, "quota "+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())
|
|
}
|