e0f54ef320
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.
414 lines
9.2 KiB
Go
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")
|
|
}
|
|
})
|
|
}
|