396 lines
15 KiB
Go
396 lines
15 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// novox/hq ADR 0100: on an adopted node the found firewall stays in force. The mesh declares
|
|
// openings where it would have loaded a filter, and guards its own ports in a table that only
|
|
// refuses.
|
|
|
|
// hub is the private network's generator on the hub: it opens the hub's port from anywhere.
|
|
type hub struct{}
|
|
|
|
func (hub) Resources(string) ([]map[string]any, bool, error) {
|
|
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
|
|
"content": "[Interface]\n"}}, true, nil
|
|
}
|
|
func (hub) Listens(string) ([]Listening, error) {
|
|
return []Listening{{Port: 51820, Protocol: "udp", From: FromEverywhere}}, nil
|
|
}
|
|
|
|
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
|
|
// a served module and the filter module.
|
|
func anAdoptedAnchor() Resolution {
|
|
return Resolution{Node: "anchor", Modules: []Manifest{
|
|
{Module: "network", Computed: "overlay"},
|
|
{Module: "postgres", Guards: []int{5432},
|
|
Listens: []Listening{{Port: 5432, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-store",
|
|
"ports": []any{"5432:5432"}}}},
|
|
{Module: "lavinmq", Guards: []int{15672},
|
|
Listens: []Listening{{Port: 5671, From: FromMesh}, {Port: 5672, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "mesh-broker",
|
|
"ports": []any{"5671:5671", "5672:5672", "127.0.0.1:15672:15672"}}}},
|
|
{Module: "distribution",
|
|
Listens: []Listening{{Port: 5000, From: FromMesh}},
|
|
Resources: []map[string]any{{"id": "store", "type": "container", "name": "registry",
|
|
"ports": []any{"5000"}}}},
|
|
{Module: "hello-web",
|
|
Listens: []Listening{{Port: 8080, From: FromEverywhere}},
|
|
Resources: []map[string]any{{"id": "server", "type": "container", "name": "hello-web",
|
|
"ports": []any{"8080"}}}},
|
|
{Module: "helper", Listens: []Listening{{Port: 9000, From: FromMachine}}},
|
|
{Module: "nftables", Filtering: &Filtering{Into: "/etc/nftables.conf"},
|
|
Resources: []map[string]any{{"id": "load", "type": "service", "unit": "mesh-filter.service",
|
|
"state": "running", "restart-on": []any{"filtering"}}}},
|
|
}}
|
|
}
|
|
|
|
func anchorRendering(adopted bool) Rendering {
|
|
return Rendering{
|
|
Generators: map[string]Generator{"overlay": hub{}},
|
|
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
|
|
Settings: SettingsBy{"distribution": {{From: "node anchor",
|
|
Values: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
|
|
Mesh: []string{"10.42.0.1"},
|
|
Foundation: []int{5671},
|
|
Adopted: adopted,
|
|
// Genesis takes the foundation's modules.
|
|
Taken: map[string]bool{"postgres": true, "lavinmq": true},
|
|
}
|
|
}
|
|
|
|
func byID(resources []map[string]any) map[string]map[string]any {
|
|
out := map[string]map[string]any{}
|
|
for _, r := range resources {
|
|
out[r["id"].(string)] = r
|
|
}
|
|
return out
|
|
}
|
|
|
|
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
|
|
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
want := map[string]map[string]any{
|
|
// The hub's port, from anywhere, received.
|
|
"adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
|
|
"from": "everywhere", "path": "incoming"},
|
|
// The store's port from the private network only, and forwarded: a container publishes it.
|
|
"adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
|
|
"path": "forwarded", "to": 5432},
|
|
// The bus from anywhere: a node enrols over it before it has a private address.
|
|
"adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
|
|
"from": "everywhere", "path": "forwarded", "to": 5671},
|
|
"adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
|
|
"path": "forwarded", "to": 5672},
|
|
// The registry from anywhere, by its node's exposure setting.
|
|
"adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
|
|
"from": "everywhere", "path": "forwarded", "to": 5000},
|
|
// A published port names the machine port and the container port it is forwarded to.
|
|
"adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
|
|
"from": "everywhere", "path": "forwarded", "to": 8080},
|
|
}
|
|
for id, fields := range want {
|
|
opening, ok := got[id]
|
|
if !ok {
|
|
t.Errorf("no %s among %v", id, keys(got))
|
|
continue
|
|
}
|
|
if opening["type"] != "opening" {
|
|
t.Errorf("%s is a %v", id, opening["type"])
|
|
}
|
|
for k, v := range fields {
|
|
if opening[k] != v {
|
|
t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
|
|
}
|
|
}
|
|
}
|
|
for id := range got {
|
|
if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
|
|
t.Errorf("an opening nothing asked for: %s", id)
|
|
}
|
|
}
|
|
// A port for this machine only opens nothing, and the management port is not opened at all.
|
|
for id := range got {
|
|
if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
|
|
t.Errorf("%s is opened", id)
|
|
}
|
|
}
|
|
|
|
// And openings come first, in the order the machine applies them.
|
|
if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
|
|
t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
|
|
}
|
|
}
|
|
|
|
func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
|
|
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, r := range composed.Resources {
|
|
if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
|
|
t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
|
|
}
|
|
if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
|
|
t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
|
|
}
|
|
// Nothing but refusals: the only accept in the guard is its policy.
|
|
if content, _ := r["content"].(string); r["id"] == GuardID() &&
|
|
strings.Count(content, "accept") != 1 {
|
|
t.Fatalf("the guard holds an accept:\n%s", content)
|
|
}
|
|
}
|
|
got := byID(composed.Resources)
|
|
guard := got[GuardID()]
|
|
if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
|
|
t.Fatalf("no guard: %v", keys(got))
|
|
}
|
|
if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
|
|
t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
|
|
guard["content"])
|
|
}
|
|
// The tool that loads it comes first, and the table after it: a node joining adopted has no
|
|
// filter module and may have no nft.
|
|
pkg, table := -1, -1
|
|
for i, r := range composed.Resources {
|
|
switch r["id"] {
|
|
case GuardPackageID():
|
|
pkg = i
|
|
if r["type"] != "package" || r["package"] != "nftables" {
|
|
t.Fatalf("the guard's package is %v", r)
|
|
}
|
|
case GuardID():
|
|
table = i
|
|
}
|
|
}
|
|
if pkg < 0 || pkg > table {
|
|
t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
|
|
}
|
|
if !reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardID(), GuardUnitID()}) {
|
|
t.Fatalf("the guard is not reloaded when its table changes: %v", got[GuardRunningID()])
|
|
}
|
|
// Nothing of the mesh's own is anybody's to hold.
|
|
for id, module := range composed.Owner {
|
|
if strings.HasPrefix(id, AdoptionPrefix) {
|
|
t.Fatalf("%s is owned by %s", id, module)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
|
|
composed, err := anAdoptedAnchor().Compose(anchorRendering(false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
|
|
t.Fatalf("a converged node lost its filter: %v", keys(got))
|
|
}
|
|
for id := range got {
|
|
if strings.HasPrefix(id, AdoptionPrefix) {
|
|
t.Fatalf("a converged node is declared %s", id)
|
|
}
|
|
}
|
|
plain, err := anAdoptedAnchor().Declaration(anchorRendering(false))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := json.Marshal(plain)
|
|
b, _ := json.Marshal(composed.Resources)
|
|
if string(a) != string(b) {
|
|
t.Fatal("Compose and Declaration disagree on a converged node")
|
|
}
|
|
}
|
|
|
|
// The table the installer raises and the controller declares, character for character.
|
|
func TestTheGuardIsExactlyThisTable(t *testing.T) {
|
|
const golden = `table inet mesh_guard {}
|
|
delete table inet mesh_guard
|
|
table inet mesh_guard {
|
|
chain prerouting {
|
|
type filter hook prerouting priority raw; policy accept;
|
|
fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
|
|
}
|
|
}
|
|
`
|
|
if got := AsGuard([]int{15672, 5432}); got != golden {
|
|
t.Fatalf("the guard changed:\n%s", got)
|
|
}
|
|
const unit = `[Unit]
|
|
Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
|
|
DefaultDependencies=no
|
|
Wants=network-pre.target
|
|
Before=network-pre.target shutdown.target
|
|
Conflicts=shutdown.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
RemainAfterExit=yes
|
|
ExecStart=nft -f /etc/mesh/guard.nft
|
|
ExecReload=nft -f /etc/mesh/guard.nft
|
|
ExecStop=nft delete table inet mesh_guard
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`
|
|
if got := GuardUnitText(); got != unit {
|
|
t.Fatalf("the guard's unit changed:\n%s", got)
|
|
}
|
|
if GuardResources(nil) != nil {
|
|
t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
|
|
}
|
|
}
|
|
|
|
func TestAGuardedPortMustBeAPort(t *testing.T) {
|
|
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil {
|
|
t.Fatalf("guards is refused: %v", err)
|
|
}
|
|
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil {
|
|
t.Fatal("guarding port 0 was accepted")
|
|
}
|
|
}
|
|
|
|
func keys[V any](m map[string]V) []string {
|
|
return sortedKeys(m)
|
|
}
|
|
|
|
// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
|
|
// that uses the port reads it from there: the container, the filter, the openings, the guard.
|
|
func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
|
|
given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
|
|
for _, adopted := range []bool{true, false} {
|
|
with := anchorRendering(adopted)
|
|
with.Given = given
|
|
with.Ports["postgres"] = map[int]int{5432: 5433}
|
|
composed, err := anAdoptedAnchor().Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := byID(composed.Resources)
|
|
if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
|
|
t.Fatalf("the store's container publishes %v", ports)
|
|
}
|
|
if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
|
|
[]any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
|
|
t.Fatalf("the broker's container publishes %v", ports)
|
|
}
|
|
if !adopted {
|
|
filter, _ := got["nftables.filtering"]["content"].(string)
|
|
if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
|
|
t.Fatalf("the filter does not use the given port:\n%s", filter)
|
|
}
|
|
continue
|
|
}
|
|
if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
|
|
t.Fatalf("no opening for the given port: %v", keys(got))
|
|
}
|
|
if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
|
|
t.Fatalf("the guard does not guard the given ports:\n%s", guard)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
|
|
store := anAdoptedAnchor().Modules[1]
|
|
node := func(v any) []Layer {
|
|
return []Layer{{From: "anchor", Values: map[string]any{PortsSetting: v}}}
|
|
}
|
|
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
|
|
got[5432] != 5433 {
|
|
t.Fatalf("a node's given port was not read: %v %v", got, err)
|
|
}
|
|
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
|
|
Values: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
|
|
t.Fatal("a port given for the whole mesh was accepted")
|
|
}
|
|
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
|
|
t.Fatal("a port the module neither listens on, publishes nor guards was given")
|
|
}
|
|
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(70000)})); err == nil {
|
|
t.Fatal("a machine port that is not a port was given")
|
|
}
|
|
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(22)})); err == nil {
|
|
t.Fatal("ssh's port was given")
|
|
}
|
|
broker := anAdoptedAnchor().Modules[2]
|
|
if _, err := GivenPorts(broker, node(map[string]any{"5671": float64(5700),
|
|
"5672": float64(5700)})); err == nil {
|
|
t.Fatal("one machine port was given for two of the module's ports")
|
|
}
|
|
if stray := UnusedSettings(store, node(map[string]any{"5432": float64(5433)})); len(stray) != 0 {
|
|
t.Fatalf("a given port is called stray: %v", stray)
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
|
|
// module publishes that the filter admits from the private network only, and the ports its
|
|
// manifest guards. A module assigned but not taken is not guarded: its port may still be the
|
|
// predecessor's.
|
|
func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
|
|
guardOf := func(with Rendering) string {
|
|
t.Helper()
|
|
composed, err := anAdoptedAnchor().Compose(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
|
|
return content
|
|
}
|
|
|
|
// The broker taken, the store not: the broker's plain port follows from its listens (from
|
|
// the mesh, published), its management port from its manifest; the store is not guarded, and
|
|
// neither is the bus, which the mesh needs from everywhere.
|
|
with := anchorRendering(true)
|
|
with.Taken = map[string]bool{"lavinmq": true}
|
|
if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
|
|
t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
|
|
}
|
|
|
|
// A taken module publishing a port admitted from everywhere is not guarded; one admitted from
|
|
// the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
|
|
// everywhere.
|
|
with.Taken = map[string]bool{"distribution": true, "hello-web": true}
|
|
if got := guardOf(with); got != "" {
|
|
t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
|
|
}
|
|
with.Settings = nil
|
|
if got := guardOf(with); got != AsGuard([]int{5000}) {
|
|
t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
|
|
}
|
|
|
|
// Nothing taken, nothing guarded — and no guard at all rather than an empty set.
|
|
with = anchorRendering(true)
|
|
with.Taken = nil
|
|
if got := guardOf(with); got != "" {
|
|
t.Fatalf("an untaken store is guarded:\n%s", got)
|
|
}
|
|
|
|
// A given port is followed: where the machine put it is what is refused.
|
|
with = anchorRendering(true)
|
|
with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
|
|
with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
|
|
if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
|
|
t.Fatalf("the guard does not follow the given ports:\n%s", got)
|
|
}
|
|
}
|
|
|
|
// A mapping bound to loopback is not published to anything off the machine — in either address
|
|
// family — and an address's own colons never shift the ports.
|
|
func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
|
|
got := Published([]map[string]any{{"type": "container", "ports": []any{
|
|
"127.0.0.1:15672:15672", "[::1]:8080:80", "localhost:9090:90",
|
|
"[::]:8443:443", "0.0.0.0:5000:5000", "5353:53/udp"}}})
|
|
want := map[string]map[int]int{"tcp": {8443: 443, 5000: 5000}, "udp": {5353: 53}}
|
|
if !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("published is %v, want %v", got, want)
|
|
}
|
|
}
|