Files
tomswall/internal/shorewall/parser_test.go
T
benvin e0f54ef320 Add tomswall agent (control-plane pull mode)
Add `tomswall agent`: it pulls this device's compiled config from tomswallapi,
differentially applies it, and reports the applied generation. It caches the
last known-good config and, when the control plane is unreachable, keeps
applying that cache — it never fails closed.

- internal/agent: rendered-config types, HTTP client (fetch + status report),
  on-disk cache, on-device DNS resolver for dns sets (honors the device's
  configured resolver, fail-safe on lookup failure), and the pull-apply-report
  loop behind a mockable Applier.
- Translate the interface-agnostic, address-matched rendered model into native
  tomswall config using the "all:<cidr>" any-interface source/dest form, reusing
  the existing differential engine. Named-set members are inlined as concrete
  addresses (native nft set references are a tracked follow-up).
- cmd/tomswall: wire the `agent` subcommand (flags + TOMSWALL_* env, --once).
- Unit tests: translation, cache, and the don't-fail-closed fallback loop.
- Add DESIGN.md documenting the control-plane architecture.
2026-07-20 20:05:49 +10:00

414 lines
9.2 KiB
Go

package shorewall
import (
"os"
"path/filepath"
"testing"
)
func TestParseFile_Basic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "zones")
content := `# This is a comment
fw firewall
net ipv4
loc ipv4
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
rows, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if len(rows) != 3 {
t.Fatalf("expected 3 rows, got %d", len(rows))
}
if rows[0][0] != "fw" || rows[0][1] != "firewall" {
t.Errorf("row 0 = %v, want [fw firewall]", rows[0])
}
if rows[1][0] != "net" || rows[1][1] != "ipv4" {
t.Errorf("row 1 = %v, want [net ipv4]", rows[1])
}
}
func TestParseFile_BlankLinesAndComments(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test")
content := `
# full line comment
# indented comment
field1 field2
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
rows, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0][0] != "field1" || rows[0][1] != "field2" {
t.Errorf("row 0 = %v, want [field1 field2]", rows[0])
}
}
func TestParseFile_Continuation(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test")
content := `first \
second third
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
rows, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if len(rows[0]) != 3 {
t.Fatalf("expected 3 fields, got %d: %v", len(rows[0]), rows[0])
}
if rows[0][0] != "first" || rows[0][1] != "second" || rows[0][2] != "third" {
t.Errorf("row 0 = %v, want [first second third]", rows[0])
}
}
func TestParseFile_QuestionDirective(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test")
content := `?COMMENT this is a comment directive
field1 field2
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
rows, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
// ?COMMENT lines should be skipped (starts with ?)
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d: %v", len(rows), rows)
}
if rows[0][0] != "field1" {
t.Errorf("expected field1, got %s", rows[0][0])
}
}
func TestParseFile_SectionMarker(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test")
// Note: ?SECTION lines are caught by the generic "?" prefix handler
// before the SECTION check, so only bare SECTION lines produce markers.
content := `SECTION NEW
ACCEPT net fw tcp 22
SECTION ESTABLISHED
ACCEPT all all
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
rows, err := ParseFile(path)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if len(rows) != 4 {
t.Fatalf("expected 4 rows, got %d: %v", len(rows), rows)
}
// First row should be the SECTION marker
if rows[0][0] != "?SECTION" || rows[0][1] != "NEW" {
t.Errorf("row 0 = %v, want [?SECTION NEW]", rows[0])
}
// Second row is a regular rule
if rows[1][0] != "ACCEPT" {
t.Errorf("row 1[0] = %s, want ACCEPT", rows[1][0])
}
// Third row is SECTION ESTABLISHED
if rows[2][0] != "?SECTION" || rows[2][1] != "ESTABLISHED" {
t.Errorf("row 2 = %v, want [?SECTION ESTABLISHED]", rows[2])
}
// Fourth row is the rule
if rows[3][0] != "ACCEPT" {
t.Errorf("row 3[0] = %s, want ACCEPT", rows[3][0])
}
}
func TestParseFile_NotExist(t *testing.T) {
rows, err := ParseFile("/nonexistent/path/zones")
if err != nil {
t.Fatalf("expected nil error for nonexistent file, got %v", err)
}
if rows != nil {
t.Fatalf("expected nil rows, got %v", rows)
}
}
func TestParseConf(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "shorewall.conf")
content := `# Shorewall config
IP_FORWARDING=Yes
LOG_LEVEL=info
STARTUP_ENABLED=Yes
QUOTED_VALUE="some value"
SINGLE_QUOTED='another'
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
conf, err := ParseConf(path)
if err != nil {
t.Fatalf("ParseConf: %v", err)
}
tests := map[string]string{
"IP_FORWARDING": "Yes",
"LOG_LEVEL": "info",
"STARTUP_ENABLED": "Yes",
"QUOTED_VALUE": "some value",
"SINGLE_QUOTED": "another",
}
for k, want := range tests {
got, ok := conf[k]
if !ok {
t.Errorf("key %q not found in conf", k)
continue
}
if got != want {
t.Errorf("conf[%q] = %q, want %q", k, got, want)
}
}
}
func TestParseConf_CommentsAndBlanks(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf")
content := `
# comment
KEY1=val1
# another comment
KEY2=val2
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
conf, err := ParseConf(path)
if err != nil {
t.Fatalf("ParseConf: %v", err)
}
if len(conf) != 2 {
t.Fatalf("expected 2 entries, got %d", len(conf))
}
}
func TestParseConf_NoEquals(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "conf")
content := `NOEQUALS
KEY=val
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
conf, err := ParseConf(path)
if err != nil {
t.Fatalf("ParseConf: %v", err)
}
if len(conf) != 1 {
t.Fatalf("expected 1 entry (lines without = skipped), got %d", len(conf))
}
if conf["KEY"] != "val" {
t.Errorf("conf[KEY] = %q, want %q", conf["KEY"], "val")
}
}
func TestParseParams(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "params")
content := `# params file
NET_IF=eth0
LOC_IF=eth1
NET_ADDR=192.168.1.0/24
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
params, err := ParseParams(path)
if err != nil {
t.Fatalf("ParseParams: %v", err)
}
if params["NET_IF"] != "eth0" {
t.Errorf("NET_IF = %q, want %q", params["NET_IF"], "eth0")
}
if params["LOC_IF"] != "eth1" {
t.Errorf("LOC_IF = %q, want %q", params["LOC_IF"], "eth1")
}
if params["NET_ADDR"] != "192.168.1.0/24" {
t.Errorf("NET_ADDR = %q, want %q", params["NET_ADDR"], "192.168.1.0/24")
}
}
func TestParseParams_NotExist(t *testing.T) {
params, err := ParseParams("/nonexistent/params")
if err != nil {
t.Fatalf("expected nil error for nonexistent file, got %v", err)
}
if params != nil {
t.Fatalf("expected nil params, got %v", params)
}
}
func TestParseParams_SkipsShellSyntax(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "params")
content := `GOOD_VAR=value
$(bad)=nope
KEY WITH SPACES=no
ALSO_GOOD=yes
`
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatal(err)
}
params, err := ParseParams(path)
if err != nil {
t.Fatalf("ParseParams: %v", err)
}
if _, ok := params["$(bad)"]; ok {
t.Error("should skip key with shell metacharacters")
}
if params["GOOD_VAR"] != "value" {
t.Errorf("GOOD_VAR = %q, want %q", params["GOOD_VAR"], "value")
}
if params["ALSO_GOOD"] != "yes" {
t.Errorf("ALSO_GOOD = %q, want %q", params["ALSO_GOOD"], "yes")
}
}
func TestSplitFields(t *testing.T) {
tests := []struct {
input string
want []string
}{
{"ACCEPT net fw tcp 22", []string{"ACCEPT", "net", "fw", "tcp", "22"}},
{"ACCEPT net fw # inline comment", []string{"ACCEPT", "net", "fw"}},
{"ACCEPT net fw #comment", []string{"ACCEPT", "net", "fw"}},
{"single", []string{"single"}},
{" spaced out ", []string{"spaced", "out"}},
}
for _, tt := range tests {
got := splitFields(tt.input)
if len(got) != len(tt.want) {
t.Errorf("splitFields(%q) = %v (len %d), want %v (len %d)",
tt.input, got, len(got), tt.want, len(tt.want))
continue
}
for i := range got {
if got[i] != tt.want[i] {
t.Errorf("splitFields(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
}
}
func TestIsDash(t *testing.T) {
tests := []struct {
input string
want bool
}{
{"-", true},
{"", true},
{"eth0", false},
{"tcp", false},
{"--", false},
}
for _, tt := range tests {
got := isDash(tt.input)
if got != tt.want {
t.Errorf("isDash(%q) = %v, want %v", tt.input, got, tt.want)
}
}
}
func TestDirExists(t *testing.T) {
t.Run("with zones file", func(t *testing.T) {
dir := t.TempDir()
zonesPath := filepath.Join(dir, "zones")
if err := os.WriteFile(zonesPath, []byte("fw firewall\n"), 0644); err != nil {
t.Fatal(err)
}
if !DirExists(dir) {
t.Error("DirExists should return true when zones file exists")
}
})
t.Run("with shorewall.conf only", func(t *testing.T) {
dir := t.TempDir()
confPath := filepath.Join(dir, "shorewall.conf")
if err := os.WriteFile(confPath, []byte("IP_FORWARDING=Yes\n"), 0644); err != nil {
t.Fatal(err)
}
if !DirExists(dir) {
t.Error("DirExists should return true when shorewall.conf exists")
}
})
t.Run("empty directory", func(t *testing.T) {
dir := t.TempDir()
if DirExists(dir) {
t.Error("DirExists should return false for empty directory")
}
})
t.Run("zones is a directory not a file", func(t *testing.T) {
dir := t.TempDir()
zonesDir := filepath.Join(dir, "zones")
if err := os.Mkdir(zonesDir, 0755); err != nil {
t.Fatal(err)
}
// zones exists but is a directory, and no shorewall.conf
if DirExists(dir) {
t.Error("DirExists should return false when zones is a directory and no shorewall.conf")
}
})
}