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,431 @@
|
||||
// Package nft runs the nft command: it checks and applies rendered
|
||||
// rulesets, reads counters and dynamic sets back as JSON, and maps nft's
|
||||
// error messages to the objects they came from.
|
||||
//
|
||||
// The nft command is used rather than a netlink library: it checks a
|
||||
// ruleset without applying it (nft -c), applies a whole script in one
|
||||
// transaction, and its errors are readable.
|
||||
package nft
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
)
|
||||
|
||||
// Runner runs nft.
|
||||
type Runner struct {
|
||||
// Command is how nft is run: ["nft"] (the default), or a wrapper such
|
||||
// as ["docker", "run", "--rm", "-i", "--cap-add", "NET_ADMIN", "IMAGE",
|
||||
// "nft"] in tests.
|
||||
Command []string
|
||||
}
|
||||
|
||||
// Wrapper reads HOUSECARL_NFT, a command line that runs nft (tests point
|
||||
// it at a container), or returns ["nft"].
|
||||
func Wrapper() []string {
|
||||
if v := strings.TrimSpace(os.Getenv("HOUSECARL_NFT")); v != "" {
|
||||
return strings.Fields(v)
|
||||
}
|
||||
return []string{"nft"}
|
||||
}
|
||||
|
||||
func (r *Runner) cmd(ctx context.Context, args ...string) *exec.Cmd {
|
||||
c := r.Command
|
||||
if len(c) == 0 {
|
||||
c = []string{"nft"}
|
||||
}
|
||||
return exec.CommandContext(ctx, c[0], append(append([]string{}, c[1:]...), args...)...)
|
||||
}
|
||||
|
||||
// run runs nft with args, script on stdin.
|
||||
func (r *Runner) run(ctx context.Context, script string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 60*time.Second)
|
||||
defer cancel()
|
||||
c := r.cmd(ctx, args...)
|
||||
if script != "" {
|
||||
c.Stdin = strings.NewReader(script)
|
||||
}
|
||||
var out, errb bytes.Buffer
|
||||
c.Stdout, c.Stderr = &out, &errb
|
||||
if err := c.Run(); err != nil {
|
||||
msg := strings.TrimSpace(errb.String())
|
||||
if msg == "" {
|
||||
msg = err.Error()
|
||||
}
|
||||
return out.Bytes(), &Error{Output: msg}
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// Error is nft's complaint.
|
||||
type Error struct {
|
||||
Output string
|
||||
// Object is what the first failing line came from, when known.
|
||||
Object string
|
||||
// Message is the first error line, without the file and position.
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e.Object != "" && e.Message != "" {
|
||||
return e.Object + ": " + e.Message
|
||||
}
|
||||
if e.Message != "" {
|
||||
return e.Message
|
||||
}
|
||||
return e.Output
|
||||
}
|
||||
|
||||
// errLine matches "/dev/stdin:12:3-10: Error: …".
|
||||
var errLine = regexp.MustCompile(`(?m)^[^:\n]*:(\d+):[0-9-]+: Error: (.*)$`)
|
||||
|
||||
// explain fills in Object and Message from nft's output and the rendered
|
||||
// ruleset.
|
||||
func explain(err error, rs *render.Ruleset) error {
|
||||
e, ok := err.(*Error)
|
||||
if !ok {
|
||||
return err
|
||||
}
|
||||
if m := errLine.FindStringSubmatch(e.Output); m != nil {
|
||||
line, _ := strconv.Atoi(m[1])
|
||||
e.Message = strings.TrimSpace(m[2])
|
||||
e.Object = rs.Object(line)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Check has nft check a ruleset without applying it.
|
||||
func (r *Runner) Check(ctx context.Context, rs *render.Ruleset) error {
|
||||
_, err := r.run(ctx, rs.Text, "-c", "-f", "/dev/stdin")
|
||||
return explain(err, rs)
|
||||
}
|
||||
|
||||
// Apply applies a ruleset (one transaction: all or nothing).
|
||||
func (r *Runner) Apply(ctx context.Context, rs *render.Ruleset) error {
|
||||
_, err := r.run(ctx, rs.Text, "-f", "/dev/stdin")
|
||||
return explain(err, rs)
|
||||
}
|
||||
|
||||
// ListTable returns Housecarl's table as nft JSON (nil if it doesn't
|
||||
// exist).
|
||||
func (r *Runner) ListTable(ctx context.Context) ([]byte, error) {
|
||||
out, err := r.run(ctx, "", "-j", "list", "table", "inet", render.Table)
|
||||
if err != nil {
|
||||
if e, ok := err.(*Error); ok && strings.Contains(e.Output, "No such file or directory") {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListRuleset returns the whole ruleset as nft JSON (the importer's
|
||||
// input on this machine).
|
||||
func (r *Runner) ListRuleset(ctx context.Context) ([]byte, error) {
|
||||
return r.run(ctx, "", "-j", "list", "ruleset")
|
||||
}
|
||||
|
||||
// TableText returns a table as an nft script that recreates it.
|
||||
func (r *Runner) TableText(ctx context.Context, family, name string) (string, error) {
|
||||
out, err := r.run(ctx, "", "list", "table", family, name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "table " + family + " " + name + " {}\ndelete table " + family + " " + name + "\n" + string(out), nil
|
||||
}
|
||||
|
||||
// ApplyScript applies an nft script (a takeover's rollback).
|
||||
func (r *Runner) ApplyScript(ctx context.Context, script string) error {
|
||||
_, err := r.run(ctx, script, "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteTable deletes a table (hc takeover: the ruleset Housecarl
|
||||
// replaces).
|
||||
func (r *Runner) DeleteTable(ctx context.Context, family, name string) error {
|
||||
_, err := r.run(ctx, "", "delete", "table", family, name)
|
||||
return err
|
||||
}
|
||||
|
||||
// AddElement adds an entry to a dynamic set's family part, with a timeout
|
||||
// in seconds (0: the set's default).
|
||||
func (r *Runner) AddElement(ctx context.Context, set, addr string, timeout int) error {
|
||||
a, err := model.ParseAddress(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t := ""
|
||||
if timeout > 0 {
|
||||
t = fmt.Sprintf(" timeout %ds", timeout)
|
||||
}
|
||||
_, err = r.run(ctx, fmt.Sprintf("add element inet %s %s { %s%s }\n", render.Table, render.SetName(set, a.IsV6()), a, t), "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteElement removes an entry from a dynamic set.
|
||||
func (r *Runner) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
a, err := model.ParseAddress(addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.run(ctx, fmt.Sprintf("delete element inet %s %s { %s }\n", render.Table, render.SetName(set, a.IsV6()), a), "-f", "/dev/stdin")
|
||||
return err
|
||||
}
|
||||
|
||||
// ---- reading JSON
|
||||
|
||||
// Table is what Housecarl reads back from its table: counters by mark
|
||||
// and the dynamic sets' entries.
|
||||
type Table struct {
|
||||
// Counters by the comment's mark ("r12", "f3", "b-established",
|
||||
// "p-input"); several nftables rules of one object add up.
|
||||
Counters map[string]Counter
|
||||
// Elements by set name (both families together).
|
||||
Elements map[string][]render.Element
|
||||
// Since is when the counters started (housecarld's first apply).
|
||||
Since time.Time
|
||||
// Tables are all the machine's tables ("inet main"), when the whole
|
||||
// ruleset was read.
|
||||
Tables []string
|
||||
}
|
||||
|
||||
// Counter is packets and bytes.
|
||||
type Counter struct {
|
||||
Packets uint64 `json:"packets"`
|
||||
Bytes uint64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type jsonDoc struct {
|
||||
Nftables []map[string]json.RawMessage `json:"nftables"`
|
||||
}
|
||||
|
||||
type jsonRule struct {
|
||||
Comment string `json:"comment"`
|
||||
Expr []json.RawMessage `json:"expr"`
|
||||
}
|
||||
|
||||
type jsonSet struct {
|
||||
Name string `json:"name"`
|
||||
Elem []json.RawMessage `json:"elem"`
|
||||
}
|
||||
|
||||
// ParseTable reads `nft -j list table inet housecarl`.
|
||||
func ParseTable(data []byte) (*Table, error) {
|
||||
t := &Table{Counters: map[string]Counter{}, Elements: map[string][]render.Element{}}
|
||||
if len(data) == 0 {
|
||||
return t, nil
|
||||
}
|
||||
var doc jsonDoc
|
||||
if err := json.Unmarshal(data, &doc); err != nil {
|
||||
return nil, fmt.Errorf("reading nft's JSON: %w", err)
|
||||
}
|
||||
for _, item := range doc.Nftables {
|
||||
if raw, ok := item["rule"]; ok {
|
||||
var r jsonRule
|
||||
if json.Unmarshal(raw, &r) != nil || !strings.HasPrefix(r.Comment, "hc:") {
|
||||
continue
|
||||
}
|
||||
mark, _, _ := strings.Cut(strings.TrimPrefix(r.Comment, "hc:"), " ")
|
||||
for _, e := range r.Expr {
|
||||
var c struct {
|
||||
Counter *Counter `json:"counter"`
|
||||
}
|
||||
if json.Unmarshal(e, &c) == nil && c.Counter != nil {
|
||||
cur := t.Counters[mark]
|
||||
cur.Packets += c.Counter.Packets
|
||||
cur.Bytes += c.Counter.Bytes
|
||||
t.Counters[mark] = cur
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, ok := item["set"]; ok {
|
||||
var s jsonSet
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
continue
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimSuffix(s.Name, "_v4"), "_v6")
|
||||
if name == s.Name || strings.HasPrefix(s.Name, "meter_") || strings.HasPrefix(s.Name, "conn_") {
|
||||
continue
|
||||
}
|
||||
for _, e := range s.Elem {
|
||||
if el, ok := parseElem(e); ok {
|
||||
t.Elements[name] = append(t.Elements[name], el)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// parseElem reads a set element: "10.0.0.5", or {"elem": {"val":
|
||||
// "10.0.0.5", "timeout": 1800, "expires": 1799}}.
|
||||
func parseElem(raw json.RawMessage) (render.Element, bool) {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) == nil {
|
||||
return render.Element{Addr: s}, true
|
||||
}
|
||||
var w struct {
|
||||
Elem struct {
|
||||
Val json.RawMessage `json:"val"`
|
||||
Expires int `json:"expires"`
|
||||
} `json:"elem"`
|
||||
}
|
||||
if json.Unmarshal(raw, &w) != nil || json.Unmarshal(w.Elem.Val, &s) != nil {
|
||||
return render.Element{}, false
|
||||
}
|
||||
return render.Element{Addr: s, Timeout: w.Elem.Expires}, true
|
||||
}
|
||||
|
||||
// ---- the firewall
|
||||
|
||||
// Firewall is the engine's Firewall: it renders configurations and has
|
||||
// nft check and apply them, carrying dynamic sets' entries over.
|
||||
type Firewall struct {
|
||||
Runner *Runner
|
||||
// Interfaces lists the interfaces that exist now (nil: assume all).
|
||||
Interfaces func() map[string]bool
|
||||
// BootFile is where the confirmed ruleset is saved for the boot unit
|
||||
// (housecarl-ruleset.service; "" for none).
|
||||
BootFile string
|
||||
|
||||
// Replacing the table resets its counters: base keeps the totals of
|
||||
// the tables replaced since housecarld started.
|
||||
mu sync.Mutex
|
||||
base map[string]Counter
|
||||
since time.Time
|
||||
}
|
||||
|
||||
func (f *Firewall) options() render.Options {
|
||||
var o render.Options
|
||||
if f.Interfaces != nil {
|
||||
o.Interfaces = f.Interfaces()
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// Render builds the ruleset for st as Apply would.
|
||||
func (f *Firewall) Render(st *model.State) *render.Ruleset { return render.Render(st, f.options()) }
|
||||
|
||||
// Check implements engine.Firewall.
|
||||
func (f *Firewall) Check(ctx context.Context, st *model.State) error {
|
||||
return f.Runner.Check(ctx, render.Render(st, f.options()))
|
||||
}
|
||||
|
||||
// Apply implements engine.Firewall.
|
||||
func (f *Firewall) Apply(ctx context.Context, st *model.State) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
opt := f.options()
|
||||
var old *Table
|
||||
if data, err := f.Runner.ListTable(ctx); err == nil {
|
||||
if t, err := ParseTable(data); err == nil {
|
||||
opt.Elements = t.Elements
|
||||
old = t
|
||||
}
|
||||
}
|
||||
if err := f.Runner.Apply(ctx, render.Render(st, opt)); err != nil {
|
||||
return err
|
||||
}
|
||||
if f.base == nil {
|
||||
f.base, f.since = map[string]Counter{}, time.Now()
|
||||
}
|
||||
if old != nil {
|
||||
for k, c := range old.Counters {
|
||||
b := f.base[k]
|
||||
b.Packets += c.Packets
|
||||
b.Bytes += c.Bytes
|
||||
f.base[k] = b
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveBoot writes the ruleset of st for the boot unit, atomically.
|
||||
func (f *Firewall) SaveBoot(st *model.State) error {
|
||||
if f.BootFile == "" {
|
||||
return nil
|
||||
}
|
||||
rs := render.Render(st, f.options())
|
||||
tmp := f.BootFile + ".tmp"
|
||||
if err := os.MkdirAll(filepath.Dir(f.BootFile), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(tmp, []byte(rs.Text), 0o640); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, f.BootFile)
|
||||
}
|
||||
|
||||
// Table reads Housecarl's table back, with counters totalled since
|
||||
// housecarld started (across reloads).
|
||||
func (f *Firewall) Table(ctx context.Context) (*Table, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
data, err := f.Runner.ListTable(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data == nil {
|
||||
return nil, ErrNoTable
|
||||
}
|
||||
t, err := ParseTable(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for k, b := range f.base {
|
||||
c := t.Counters[k]
|
||||
c.Packets += b.Packets
|
||||
c.Bytes += b.Bytes
|
||||
t.Counters[k] = c
|
||||
}
|
||||
t.Since = f.since
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// ErrNoTable: Housecarl's table isn't in the ruleset.
|
||||
var ErrNoTable = errors.New("there is no table inet housecarl: Housecarl's ruleset isn't in force")
|
||||
|
||||
// AddElement adds an entry to a dynamic set.
|
||||
func (f *Firewall) AddElement(ctx context.Context, set, addr string, timeout int) error {
|
||||
return f.Runner.AddElement(ctx, set, addr, timeout)
|
||||
}
|
||||
|
||||
// DeleteElement removes an entry from a dynamic set.
|
||||
func (f *Firewall) DeleteElement(ctx context.Context, set, addr string) error {
|
||||
return f.Runner.DeleteElement(ctx, set, addr)
|
||||
}
|
||||
|
||||
// ListRuleset returns the machine's whole ruleset as JSON.
|
||||
func (f *Firewall) ListRuleset(ctx context.Context) ([]byte, error) { return f.Runner.ListRuleset(ctx) }
|
||||
|
||||
// TableText returns another table as a script that recreates it.
|
||||
func (f *Firewall) TableText(ctx context.Context, family, name string) (string, error) {
|
||||
return f.Runner.TableText(ctx, family, name)
|
||||
}
|
||||
|
||||
// ApplyScript applies an nft script.
|
||||
func (f *Firewall) ApplyScript(ctx context.Context, script string) error {
|
||||
return f.Runner.ApplyScript(ctx, script)
|
||||
}
|
||||
|
||||
// DeleteTable deletes another table (takeover).
|
||||
func (f *Firewall) DeleteTable(ctx context.Context, family, name string) error {
|
||||
if name == render.Table && family == "inet" {
|
||||
return fmt.Errorf("that is Housecarl's own table")
|
||||
}
|
||||
return f.Runner.DeleteTable(ctx, family, name)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package nft
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"housecarl/internal/model"
|
||||
"housecarl/internal/render"
|
||||
)
|
||||
|
||||
func TestParseTable(t *testing.T) {
|
||||
data := []byte(`{"nftables": [{"metainfo": {"version": "1.1.3"}},
|
||||
{"set": {"family": "inet", "name": "guests_v4", "table": "housecarl", "type": "ipv4_addr", "flags": ["timeout"],
|
||||
"elem": [{"elem": {"val": "10.0.30.5", "timeout": 1800, "expires": 1799}}, "10.0.30.6"]}},
|
||||
{"set": {"family": "inet", "name": "meter_r4_v4", "table": "housecarl", "elem": ["10.0.0.1"]}},
|
||||
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
|
||||
"expr": [{"counter": {"packets": 5, "bytes": 300}}, {"accept": null}]}},
|
||||
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "hc:r12 ssh",
|
||||
"expr": [{"counter": {"packets": 2, "bytes": 100}}, {"accept": null}]}},
|
||||
{"rule": {"family": "inet", "table": "housecarl", "chain": "input", "comment": "not ours",
|
||||
"expr": [{"counter": {"packets": 9, "bytes": 900}}]}}]}`)
|
||||
tb, err := ParseTable(data)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c := tb.Counters["r12"]; c.Packets != 7 || c.Bytes != 400 || len(tb.Counters) != 1 {
|
||||
t.Errorf("counters %+v", tb.Counters)
|
||||
}
|
||||
if e := tb.Elements["guests"]; len(e) != 2 || e[0] != (render.Element{Addr: "10.0.30.5", Timeout: 1799}) || e[1].Addr != "10.0.30.6" || len(tb.Elements) != 1 {
|
||||
t.Errorf("elements %+v", tb.Elements)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplain(t *testing.T) {
|
||||
rs := &render.Ruleset{Lines: map[int]string{10: "input #4 (ssh)"}}
|
||||
err := explain(&Error{Output: "/dev/stdin:11:3-12: Error: syntax error, unexpected junk\n\t\tjunk\n"}, rs)
|
||||
if err.Error() != "input #4 (ssh): syntax error, unexpected junk" {
|
||||
t.Errorf("explained: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirewallInContainer applies rulesets in a container with nft
|
||||
// (HOUSECARL_NFT_IMAGE, e.g. housecarl-nft: Debian 13 with nftables):
|
||||
// dynamic sets keep their entries across a reload, counters come back,
|
||||
// and a broken rule is reported against its object.
|
||||
func TestFirewallInContainer(t *testing.T) {
|
||||
image := os.Getenv("HOUSECARL_NFT_IMAGE")
|
||||
if image == "" {
|
||||
t.Skip("HOUSECARL_NFT_IMAGE not set")
|
||||
}
|
||||
name := "housecarl-nft-test"
|
||||
exec.Command("docker", "rm", "-f", name).Run()
|
||||
if out, err := exec.Command("docker", "run", "-d", "--name", name, "--cap-add", "NET_ADMIN", image, "sleep", "300").CombinedOutput(); err != nil {
|
||||
t.Fatalf("starting the container: %v %s", err, out)
|
||||
}
|
||||
t.Cleanup(func() { exec.Command("docker", "rm", "-f", name).Run() })
|
||||
fw := &Firewall{Runner: &Runner{Command: []string{"docker", "exec", "-i", name, "nft"}}}
|
||||
ctx := context.Background()
|
||||
|
||||
st := model.NewState()
|
||||
st.Zones = []*model.Zone{{ID: 1, Name: "wan", Interfaces: []string{"eth0"}, External: true}}
|
||||
st.Sets = []*model.AddressSet{{ID: 2, Name: "guests", Dynamic: true, Timeout: 3600}}
|
||||
st.Rules = []*model.Rule{{ID: 3, Chain: model.ChainOutput, Position: 1, Action: model.ActionAccept, Match: model.Match{Destinations: []string{"guests"}}}}
|
||||
if err := fw.Check(ctx, st); err != nil {
|
||||
t.Fatalf("check: %v", err)
|
||||
}
|
||||
if _, err := fw.Table(ctx); err != ErrNoTable {
|
||||
t.Errorf("before applying, the table: %v", err)
|
||||
}
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("apply: %v", err)
|
||||
}
|
||||
if err := fw.Runner.AddElement(ctx, "guests", "127.0.0.2", 600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := fw.Runner.AddElement(ctx, "guests", "::2", 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exec.Command("docker", "exec", name, "ping", "-c", "2", "-W", "1", "127.0.0.2").Run()
|
||||
// Reapplying keeps the entries.
|
||||
st.Settings.ForwardPolicy = model.PolicyAccept
|
||||
if err := fw.Apply(ctx, st); err != nil {
|
||||
t.Fatalf("reapply: %v", err)
|
||||
}
|
||||
tb, err := fw.Table(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, e := range tb.Elements["guests"] {
|
||||
got = append(got, e.Addr)
|
||||
if e.Addr == "127.0.0.2" && (e.Timeout < 500 || e.Timeout > 600) {
|
||||
t.Errorf("timeout after reload: %+v", e)
|
||||
}
|
||||
}
|
||||
if strings.Join(got, ",") != "127.0.0.2,::2" {
|
||||
t.Errorf("entries after reload: %v", got)
|
||||
}
|
||||
if _, ok := tb.Counters["r3"]; !ok {
|
||||
t.Errorf("counters %+v", tb.Counters)
|
||||
}
|
||||
if tb.Counters["b-established"].Packets == 0 && tb.Counters["r3"].Packets == 0 {
|
||||
t.Logf("no packets counted (ping may be missing): %+v", tb.Counters)
|
||||
}
|
||||
// A broken raw rule: refused, named, and nothing changes.
|
||||
st.Rules = append(st.Rules, &model.Rule{ID: 4, Chain: model.ChainInput, Position: 1, Action: model.ActionAccept, Raw: "tcp dport bogus accept", Comment: "broken"})
|
||||
err = fw.Check(ctx, st)
|
||||
if err == nil || !strings.Contains(err.Error(), "input #4 (broken)") {
|
||||
t.Errorf("check of a broken rule: %v", err)
|
||||
}
|
||||
if err := fw.Apply(ctx, st); err == nil {
|
||||
t.Error("a broken ruleset was applied")
|
||||
}
|
||||
tb, _ = fw.Table(ctx)
|
||||
if len(tb.Elements["guests"]) != 2 {
|
||||
t.Errorf("a refused apply changed the table: %+v", tb.Elements)
|
||||
}
|
||||
// The table is Housecarl's alone: another one is left alone.
|
||||
exec.Command("docker", "exec", name, "nft", "add", "table", "inet", "other").Run()
|
||||
fw.Apply(ctx, st.Clone())
|
||||
if out, _ := exec.Command("docker", "exec", name, "nft", "list", "tables").CombinedOutput(); !strings.Contains(string(out), "inet other") {
|
||||
t.Errorf("another table was touched: %s", out)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user