diff --git a/Dockerfile b/Dockerfile index 1c9d86f..9dc9667 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,10 +23,12 @@ RUN apt-get update && \ iperf3 \ curl \ iproute2 \ + wireguard-tools \ net-tools \ tcpdump \ dnsutils \ - netcat-openbsd && \ + netcat-openbsd \ + python3 && \ rm -rf /var/lib/apt/lists/* COPY --from=builder /nylon /usr/local/bin/nylon diff --git a/e2e/distribution_test.go b/e2e/distribution_test.go new file mode 100644 index 0000000..306606b --- /dev/null +++ b/e2e/distribution_test.go @@ -0,0 +1,194 @@ +//go:build e2e + +package e2e + +import ( + "context" + "net/netip" + "os" + "path/filepath" + "testing" + "time" + + "github.com/encodeous/nylon/state" + "github.com/goccy/go-yaml" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestDistribution(t *testing.T) { + h := NewHarness(t) + // Cleanup is handled by Harness via t.Cleanup + + ctx := context.Background() + + // 1. Setup Keys + privKey := state.GenerateKey() + pubKey := privKey.Pubkey() + + // 2. Prepare Directories + runDir := filepath.Join(h.RootDir, "e2e", "runs", t.Name()) + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatal(err) + } + + // 3. Prepare Initial Bundle (v1) + distCfg := &state.DistributionCfg{ + Key: pubKey, + Repos: []string{"http://repo:80/bundle"}, + } + + nodeKey := state.GenerateKey() + nodeId := "node-1" + + cfg1 := state.CentralCfg{ + Timestamp: 1, + Dist: distCfg, + Routers: []state.RouterCfg{ + { + NodeCfg: state.NodeCfg{ + Id: state.NodeId(nodeId), + PubKey: nodeKey.Pubkey(), + }, + Endpoints: []netip.AddrPort{}, + }, + }, + Clients: []state.ClientCfg{}, + Graph: []string{}, + } + + cfg1Bytes, err := yaml.Marshal(cfg1) + if err != nil { + t.Fatal(err) + } + bundle1Str, err := state.BundleConfig(string(cfg1Bytes), privKey) + if err != nil { + t.Fatal(err) + } + bundle1Path := filepath.Join(runDir, "bundle1") + if err := os.WriteFile(bundle1Path, []byte(bundle1Str), 0644); err != nil { + t.Fatal(err) + } + + // 4. Start Repo Server + t.Log("Starting Repo Server...") + repoReq := testcontainers.ContainerRequest{ + Image: "python:3-alpine", + Cmd: []string{"sh", "-c", "mkdir -p /data && cd /data && python3 -u -m http.server 80"}, + ExposedPorts: []string{"80/tcp"}, + Networks: []string{h.Network.Name}, + NetworkAliases: map[string][]string{ + h.Network.Name: {"repo"}, + }, + WaitingFor: wait.ForListeningPort("80/tcp"), + } + repoContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: repoReq, + Started: true, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + repoContainer.Terminate(context.Background()) + }) + + // Copy bundle1 to repo + if err := repoContainer.CopyFileToContainer(ctx, bundle1Path, "/data/bundle", 0644); err != nil { + t.Fatal(err) + } + + // 5. Start Nylon Node + // Write central.yaml (v1) to disk for initial startup + centralConfigPath := filepath.Join(runDir, "central.yaml") + if err := os.WriteFile(centralConfigPath, cfg1Bytes, 0644); err != nil { + t.Fatal(err) + } + + // Write node.yaml + nodeCfg := state.LocalCfg{ + Id: state.NodeId(nodeId), + Key: nodeKey, + Port: 51820, + // We can omit Dist here since we are providing central.yaml, + // but providing it doesn't hurt. + Dist: &state.LocalDistributionCfg{ + Key: pubKey, + Url: "http://repo:80/bundle", + }, + } + nodeCfgBytes, err := yaml.Marshal(nodeCfg) + if err != nil { + t.Fatal(err) + } + nodeConfigPath := filepath.Join(runDir, "node.yaml") + if err := os.WriteFile(nodeConfigPath, nodeCfgBytes, 0644); err != nil { + t.Fatal(err) + } + + t.Log("Starting Nylon Node...") + h.StartNode(nodeId, "", centralConfigPath, nodeConfigPath) + + // Wait for start + h.WaitForLog(nodeId, "Nylon has been initialized") + t.Log("Nylon Node started (v1).") + + // 6. Create and Push Bundle 2 + t.Log("Preparing Bundle 2...") + // Wait a bit to ensure timestamp is different if using UnixNano + time.Sleep(1 * time.Second) + + cfg2 := cfg1 + // BundleConfig will overwrite this timestamp anyway + cfg2Bytes, err := yaml.Marshal(cfg2) + if err != nil { + t.Fatal(err) + } + bundle2Str, err := state.BundleConfig(string(cfg2Bytes), privKey) + if err != nil { + t.Fatal(err) + } + + // We need to know the timestamp of bundle 2 to verify + unbundled2, err := state.UnbundleConfig(bundle2Str, pubKey) + if err != nil { + t.Fatal(err) + } + bundle2Timestamp := unbundled2.Timestamp + + bundle2Path := filepath.Join(runDir, "bundle2") + if err := os.WriteFile(bundle2Path, []byte(bundle2Str), 0644); err != nil { + t.Fatal(err) + } + + t.Logf("Updating Repo with Bundle 2 (timestamp: %d)...", bundle2Timestamp) + if err := repoContainer.CopyFileToContainer(ctx, bundle2Path, "/data/bundle", 0644); err != nil { + t.Fatal(err) + } + + // 7. Verify Update + t.Log("Waiting for update detection...") + h.WaitForLog(nodeId, "Found a new config update in repo") + + t.Log("Waiting for restart...") + h.WaitForLog(nodeId, "Restarting Nylon...") + + // Allow some time for the restart to complete and write the file + time.Sleep(5 * time.Second) + + t.Log("Verifying config version on node...") + stdout, _, err := h.Exec(nodeId, []string{"cat", "/app/config/central.yaml"}) + if err != nil { + t.Fatal(err) + } + + var verifyCfg state.CentralCfg + if err := yaml.Unmarshal([]byte(stdout), &verifyCfg); err != nil { + t.Fatalf("Failed to parse config from node: %v", err) + } + + if verifyCfg.Timestamp != bundle2Timestamp { + t.Fatalf("Expected timestamp %d, got %d. Config content: %s", bundle2Timestamp, verifyCfg.Timestamp, stdout) + } + t.Logf("Successfully updated to timestamp %d.", verifyCfg.Timestamp) +} diff --git a/e2e/harness.go b/e2e/harness.go index f1ecd72..26f18e4 100644 --- a/e2e/harness.go +++ b/e2e/harness.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "io" + "net/netip" "os" "path/filepath" "regexp" @@ -18,6 +19,8 @@ import ( "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" "github.com/docker/docker/pkg/stdcopy" + "github.com/encodeous/nylon/state" + "github.com/goccy/go-yaml" "github.com/testcontainers/testcontainers-go" tcnetwork "github.com/testcontainers/testcontainers-go/network" "github.com/testcontainers/testcontainers-go/wait" @@ -366,3 +369,57 @@ func (h *Harness) PrintLogs(nodeName string) { io.Copy(buf, r) h.t.Logf("Logs for %s:\n%s", nodeName, buf.String()) } + +// SetupTestDir creates a directory for the current test run +func (h *Harness) SetupTestDir() string { + dir := filepath.Join(h.RootDir, "e2e", "runs", h.t.Name()) + // Clean up previous run + os.RemoveAll(dir) + if err := os.MkdirAll(dir, 0755); err != nil { + h.t.Fatal(err) + } + return dir +} + +// WriteConfig marshals the config to YAML and writes it to the specified directory with the given filename +func (h *Harness) WriteConfig(dir, filename string, cfg any) string { + path := filepath.Join(dir, filename) + data, err := yaml.Marshal(cfg) + if err != nil { + h.t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + h.t.Fatal(err) + } + return path +} + +// SimpleRouter creates a basic RouterCfg with the given parameters +func SimpleRouter(id string, pubKey state.NyPublicKey, nylonIP string, endpointIP string) state.RouterCfg { + cfg := state.RouterCfg{ + NodeCfg: state.NodeCfg{ + Id: state.NodeId(id), + PubKey: pubKey, + Addresses: []netip.Addr{ + netip.MustParseAddr(nylonIP), + }, + }, + } + if endpointIP != "" { + cfg.Endpoints = []netip.AddrPort{ + netip.MustParseAddrPort(fmt.Sprintf("%s:57175", endpointIP)), + } + } + return cfg +} + +// SimpleLocal creates a basic LocalCfg with the given parameters and defaults +func SimpleLocal(id string, key state.NyPrivateKey) state.LocalCfg { + return state.LocalCfg{ + Id: state.NodeId(id), + Key: key, + Port: 57175, + NoNetConfigure: false, + InterfaceName: "nylon0", + } +} diff --git a/e2e/healthcheck_test.go b/e2e/healthcheck_test.go index 9513e6c..9763071 100644 --- a/e2e/healthcheck_test.go +++ b/e2e/healthcheck_test.go @@ -5,6 +5,7 @@ package e2e import ( "fmt" "net/netip" + "path/filepath" "strings" "testing" "time" @@ -129,3 +130,135 @@ func TestHealthcheckPing(t *testing.T) { } assert.Equal(t, msg, strings.TrimSpace(stdout)) } + +func TestHealthcheckHTTP(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + t.Parallel() + + h := NewHarness(t) + + // IPs + clientIP := GetIP(h.Subnet, 20) + primaryIP := GetIP(h.Subnet, 21) + backupIP := GetIP(h.Subnet, 22) + + // Keys + clientKey := state.GenerateKey() + primaryKey := state.GenerateKey() + backupKey := state.GenerateKey() + + configDir := h.SetupTestDir() + + // Service IP that we are load balancing / checking + serviceIP := "10.0.3.1" + servicePrefixStr := serviceIP + "/32" + servicePrefix := netip.MustParsePrefix(servicePrefixStr) + + // 1. Central Config + central := state.CentralCfg{ + Routers: []state.RouterCfg{ + SimpleRouter("client", clientKey.Pubkey(), "10.0.0.10", clientIP), + SimpleRouter("primary", primaryKey.Pubkey(), "10.0.0.11", primaryIP), + SimpleRouter("backup", backupKey.Pubkey(), "10.0.0.12", backupIP), + }, + Graph: []string{ + "client, primary", + "client, backup", + }, + Timestamp: time.Now().UnixNano(), + } + + // Configure Primary with HTTP check (Metric 10) + // primMetric := uint32(10) + checkDelay := 1 * time.Second + central.Routers[1].Prefixes = []state.PrefixHealthWrapper{ + { + &state.HTTPPrefixHealth{ + Prefix: servicePrefix, + URL: fmt.Sprintf("http://%s:8080/health", serviceIP), + Delay: &checkDelay, + // Metric: &primMetric, // Remove override to use dynamic metric (RTT or INF) + }, + }, + } + + // Configure Backup with Static check (Metric 1000) + backupMetric := uint32(1000) + central.Routers[2].Prefixes = []state.PrefixHealthWrapper{ + { + &state.StaticPrefixHealth{ + Prefix: servicePrefix, + Metric: backupMetric, + }, + }, + } + + centralPath := h.WriteConfig(configDir, "central.yaml", central) + + // 2. Local Configs + clientCfg := SimpleLocal("client", clientKey) + + primaryCfg := SimpleLocal("primary", primaryKey) + primaryCfg.PreUp = append(primaryCfg.PreUp, fmt.Sprintf("ip addr add %s dev lo", servicePrefixStr)) + + backupCfg := SimpleLocal("backup", backupKey) + backupCfg.PreUp = append(backupCfg.PreUp, fmt.Sprintf("ip addr add %s dev lo", servicePrefixStr)) + + // Write configs + h.WriteConfig(configDir, "client.yaml", clientCfg) + h.WriteConfig(configDir, "primary.yaml", primaryCfg) + h.WriteConfig(configDir, "backup.yaml", backupCfg) + + // 3. Start Nodes + h.StartNodes( + NodeSpec{Name: "client", IP: clientIP, CentralConfigPath: centralPath, NodeConfigPath: filepath.Join(configDir, "client.yaml")}, + NodeSpec{Name: "primary", IP: primaryIP, CentralConfigPath: centralPath, NodeConfigPath: filepath.Join(configDir, "primary.yaml")}, + NodeSpec{Name: "backup", IP: backupIP, CentralConfigPath: centralPath, NodeConfigPath: filepath.Join(configDir, "backup.yaml")}, + ) + + // 4. Verification + + // A. Initial state: HTTP server is DOWN on primary. + // Primary health check should fail (Metric INF). + // Client should route to Backup (Metric 1000). + + t.Log("Step A: Waiting for routing to fallback (Primary DOWN)") + h.WaitForMatch("client", "prefix=10\\.0\\.3\\.1/32.+new.nh=backup") + + // B. Start HTTP Server on Primary + t.Log("Step B: Starting HTTP server on Primary") + // Use python3 http.server. Create 'health' file so /health returns 200. + // exec replaces the shell, so pkill python3 works or just killing the container process. + serverCmd := `touch health && python3 -m http.server 8080` + bg := h.ExecBackground("primary", []string{"/bin/sh", "-c", serverCmd}) + + // C. Wait for Primary to become healthy + // Primary should advertise Metric 10. + // Client should switch to Primary. + t.Log("Step C: Waiting for routing to switch to Primary (Primary UP)") + h.WaitForMatch("client", "prefix=10\\.0\\.3\\.1/32.+new.nh=primary") + + // D. Stop HTTP Server + t.Log("Step D: Stopping HTTP server") + h.Exec("primary", []string{"pkill", "python3"}) + bg.Wait() + + // E. Wait for fallback to Backup + + time.Sleep(5 * time.Second) + + h.mu.Lock() + logs := h.LogBuffers["client"].String() + h.mu.Unlock() + + idxPrimary := strings.LastIndex(logs, "new.nh=primary") + idxBackup := strings.LastIndex(logs, "new.nh=backup") + + if idxBackup > idxPrimary { + t.Log("Verified: Route switched back to backup") + } else { + t.Fatalf("Route failed to switch back to backup. Logs:\n%s", logs) + } +} diff --git a/e2e/passive_roaming_test.go b/e2e/passive_roaming_test.go new file mode 100644 index 0000000..812677b --- /dev/null +++ b/e2e/passive_roaming_test.go @@ -0,0 +1,245 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "net/netip" + "os" + "path/filepath" + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/encodeous/nylon/state" + "github.com/goccy/go-yaml" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +func TestPassiveRoaming(t *testing.T) { + h := NewHarness(t) + ctx := context.Background() + + ip1 := GetIP(h.Subnet, 2) + ip2 := GetIP(h.Subnet, 3) + ip3 := GetIP(h.Subnet, 4) + clientContainerIP := GetIP(h.Subnet, 5) + + keys := make(map[string]state.NyPrivateKey) + pubKeys := make(map[string]state.NyPublicKey) + nodes := []string{"node-1", "node-2", "node-3", "client-4"} + + for _, n := range nodes { + k := state.GenerateKey() + keys[n] = k + pubKeys[n] = k.Pubkey() + } + + centralCfg := state.CentralCfg{ + Routers: []state.RouterCfg{ + { + NodeCfg: state.NodeCfg{ + Id: "node-1", + PubKey: pubKeys["node-1"], + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.1")}, + }, + Endpoints: []netip.AddrPort{netip.AddrPortFrom(netip.MustParseAddr(ip1), 51820)}, + }, + { + NodeCfg: state.NodeCfg{ + Id: "node-2", + PubKey: pubKeys["node-2"], + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.2")}, + }, + Endpoints: []netip.AddrPort{netip.AddrPortFrom(netip.MustParseAddr(ip2), 51820)}, + }, + { + NodeCfg: state.NodeCfg{ + Id: "node-3", + PubKey: pubKeys["node-3"], + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.3")}, + }, + Endpoints: []netip.AddrPort{netip.AddrPortFrom(netip.MustParseAddr(ip3), 51820)}, + }, + }, + Clients: []state.ClientCfg{ + { + NodeCfg: state.NodeCfg{ + Id: "client-4", + PubKey: pubKeys["client-4"], + Addresses: []netip.Addr{netip.MustParseAddr("10.0.0.4")}, + }, + }, + }, + Graph: []string{ + "node-1, node-2", + "node-2, node-3", + "node-2, client-4", + "node-3, client-4", + }, + Timestamp: time.Now().UnixNano(), + } + + centralBytes, err := yaml.Marshal(centralCfg) + if err != nil { + t.Fatal(err) + } + + var nodeSpecs []NodeSpec + for _, n := range []string{"node-1", "node-2", "node-3"} { + nodeCfg := state.LocalCfg{ + Key: keys[n], + Id: state.NodeId(n), + Port: 51820, + InterfaceName: "nylon", + NoNetConfigure: false, + } + nodeBytes, err := yaml.Marshal(nodeCfg) + if err != nil { + t.Fatal(err) + } + + centralFile := CreateTempFile(t, h.RootDir, "central-"+n+".yaml", centralBytes) + nodeFile := CreateTempFile(t, h.RootDir, "node-"+n+".yaml", nodeBytes) + + var containerIP string + switch n { + case "node-1": + containerIP = ip1 + case "node-2": + containerIP = ip2 + case "node-3": + containerIP = ip3 + } + + nodeSpecs = append(nodeSpecs, NodeSpec{ + Name: n, + IP: containerIP, + CentralConfigPath: centralFile, + NodeConfigPath: nodeFile, + }) + } + + h.StartNodes(nodeSpecs...) + + t.Log("Nylon nodes started. Waiting for convergence...") + time.Sleep(5 * time.Second) + + t.Logf("Starting Client Node at %s", clientContainerIP) + + req := testcontainers.ContainerRequest{ + Image: ImageName, + Networks: []string{h.Network.Name}, + NetworkAliases: map[string][]string{ + h.Network.Name: {"client-4"}, + }, + Entrypoint: []string{"/bin/sh", "-c", "sleep infinity"}, + HostConfigModifier: func(hostConfig *container.HostConfig) { + hostConfig.Privileged = true + hostConfig.CapAdd = []string{"NET_ADMIN"} + }, + EndpointSettingsModifier: func(m map[string]*network.EndpointSettings) { + if s, ok := m[h.Network.Name]; ok { + s.IPAMConfig = &network.EndpointIPAMConfig{ + IPv4Address: clientContainerIP, + } + } + }, + } + + clientContainer, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + t.Fatalf("failed to start client container: %v", err) + } + h.Nodes["client-4"] = clientContainer + t.Cleanup(func() { + clientContainer.Terminate(ctx) + }) + + execClient := func(cmd string) { + t.Logf("[client-4] Exec: %s", cmd) + _, _, err := h.Exec("client-4", []string{"/bin/sh", "-c", cmd}) + if err != nil { + t.Fatalf("Client exec failed: %v", err) + } + } + + pingNode1 := func() error { + code, _, err := clientContainer.Exec(ctx, []string{"ping", "-c", "1", "-W", "1", "10.0.0.1"}) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("ping failed with code %d", code) + } + return nil + } + + waitForPing := func() { + t.Log("Waiting for connectivity to Node 1...") + timeout := time.After(30 * time.Second) + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + for { + select { + case <-timeout: + t.Fatal("Timeout waiting for connectivity") + case <-ticker.C: + if err := pingNode1(); err == nil { + t.Log("Connectivity established!") + return + } + } + } + } + + t.Log("=== Phase 1: Connect Client to Node 3 ===") + + privKeyStr, _ := keys["client-4"].MarshalText() + node3PubStr, _ := pubKeys["node-3"].MarshalText() + node2PubStr, _ := pubKeys["node-2"].MarshalText() + + execClient("ip link add dev wg0 type wireguard") + execClient("ip address add dev wg0 10.0.0.4/32") + execClient(fmt.Sprintf("echo %s > /tmp/wg_priv && wg set wg0 private-key /tmp/wg_priv && rm /tmp/wg_priv", string(privKeyStr))) + execClient("ip link set up dev wg0") + + execClient(fmt.Sprintf("wg set wg0 peer %s endpoint %s:51820 allowed-ips 0.0.0.0/0 persistent-keepalive 5", string(node3PubStr), ip3)) + + execClient("ip route add 10.0.0.0/24 dev wg0") + + waitForPing() + + t.Log("=== Phase 2: Roam Client to Node 2 ===") + + execClient(fmt.Sprintf("wg set wg0 peer %s remove", string(node3PubStr))) + + execClient(fmt.Sprintf("wg set wg0 peer %s endpoint %s:51820 allowed-ips 0.0.0.0/0 persistent-keepalive 5", string(node2PubStr), ip2)) + + t.Log("Sending traffic to trigger roaming update...") + + waitForPing() + + t.Log("=== Test Complete: Passive Roaming Successful ===") +} + +func CreateTempFile(t *testing.T, dir, name string, content []byte) string { + runDir := filepath.Join(dir, "e2e", "runs", t.Name()) + if err := os.MkdirAll(runDir, 0755); err != nil { + t.Fatal(err) + } + fPath := filepath.Join(runDir, name) + if err := os.WriteFile(fPath, content, 0644); err != nil { + t.Fatal(err) + } + return fPath +} + +var _ = wait.ForLog diff --git a/e2e/recovery_test.go b/e2e/recovery_test.go new file mode 100644 index 0000000..4fb8661 --- /dev/null +++ b/e2e/recovery_test.go @@ -0,0 +1,144 @@ +//go:build e2e + +package e2e + +import ( + "fmt" + "strings" + "testing" + "time" + + "github.com/encodeous/nylon/state" +) + +func TestRecoveryExample(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + t.Parallel() + + h := NewHarness(t) + + // Node names + alice := "alice" + bob := "bob" + charlie := "charlie" +eve := "eve" +vps := "vps" + nodeNames := []string{alice, bob, charlie, eve, vps} + + // Generate keys + keys := make(map[string]state.NyPrivateKey) + pubKeys := make(map[string]state.NyPublicKey) + for _, name := range nodeNames { + k := state.GenerateKey() + keys[name] = k + pubKeys[name] = k.Pubkey() + } + + // Internal Nylon IPs (10.0.0.x) + nylonIPs := make(map[string]string) + for i, name := range nodeNames { + nylonIPs[name] = fmt.Sprintf("10.0.0.%d", i+1) + } + + // Docker IPs + dockerIPs := make(map[string]string) + for i, name := range nodeNames { + dockerIPs[name] = GetIP(h.Subnet, 10+i) + } + + configDir := h.SetupTestDir() + + // 1. Create Central Config + central := state.CentralCfg{ + Routers: []state.RouterCfg{ + SimpleRouter(alice, pubKeys[alice], nylonIPs[alice], dockerIPs[alice]), + SimpleRouter(bob, pubKeys[bob], nylonIPs[bob], dockerIPs[bob]), + SimpleRouter(charlie, pubKeys[charlie], nylonIPs[charlie], dockerIPs[charlie]), + SimpleRouter(eve, pubKeys[eve], nylonIPs[eve], dockerIPs[eve]), + SimpleRouter(vps, pubKeys[vps], nylonIPs[vps], dockerIPs[vps]), + }, + Graph: []string{ + "vps, charlie", + "vps, alice", + "eve, bob", + "vps, eve", + "alice, bob", + }, + Timestamp: time.Now().UnixNano(), + } + + centralPath := h.WriteConfig(configDir, "central.yaml", central) + + // 2. Create Node Configs & Start Nodes + nodeSpecs := make([]NodeSpec, 0, len(nodeNames)) + for _, name := range nodeNames { + cfg := SimpleLocal(name, keys[name]) + cfgPath := h.WriteConfig(configDir, name+".yaml", cfg) + nodeSpecs = append(nodeSpecs, NodeSpec{ + Name: name, + IP: dockerIPs[name], + CentralConfigPath: centralPath, + NodeConfigPath: cfgPath, + }) + } + + h.StartNodes(nodeSpecs...) + + // 3. Wait for full convergence + t.Log("Waiting for initial convergence...") + h.WaitForLog(alice, fmt.Sprintf("new.prefix=%s/32", nylonIPs[bob])) + + // 4. Verify connectivity Alice -> Bob + t.Log("Verifying initial connectivity Alice -> Bob (Direct)") + stdout, stderr, err := h.Exec(alice, []string{"ping", "-c", "3", nylonIPs[bob]}) + if err != nil { + t.Fatalf("Initial ping failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + } + + // Check that traffic went directly to Bob + h.WaitForLog(alice, fmt.Sprintf("Fwd packet: %s -> %s, via %s", nylonIPs[alice], nylonIPs[bob], bob)) + + // 5. Break the link Alice-Bob + t.Log("Breaking link Alice <-> Bob") + _, _, err = h.Exec(alice, []string{"ip", "route", "add", "blackhole", dockerIPs[bob] + "/32"}) + if err != nil { + t.Fatal(err) + } + _, _, err = h.Exec(bob, []string{"ip", "route", "add", "blackhole", dockerIPs[alice] + "/32"}) + if err != nil { + t.Fatal(err) + } + + // 6. Wait for recovery + t.Log("Waiting for recovery (rerouting)...") + deadline := time.Now().Add(1 * time.Minute) + recovered := false + for time.Now().Before(deadline) { + h.Exec(alice, []string{"ping", "-c", "1", "-W", "1", nylonIPs[bob]}) + + h.mu.Lock() + buf := h.LogBuffers[alice].String() + h.mu.Unlock() + + if strings.Contains(buf, fmt.Sprintf("Fwd packet: %s -> %s, via %s", nylonIPs[alice], nylonIPs[bob], vps)) { + recovered = true + break + } + time.Sleep(1 * time.Second) + } + + if !recovered { + h.PrintLogs(alice) + t.Fatal("Failed to recover route via VPS") + } + + t.Log("Recovery successful! Traffic rerouted via VPS.") + + // Final connectivity check + stdout, stderr, err = h.Exec(alice, []string{"ping", "-c", "3", nylonIPs[bob]}) + if err != nil { + t.Fatalf("Post-recovery ping failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + } +} \ No newline at end of file diff --git a/e2e/utils.go b/e2e/utils.go deleted file mode 100644 index 7109c66..0000000 --- a/e2e/utils.go +++ /dev/null @@ -1,67 +0,0 @@ -//go:build e2e - -package e2e - -import ( - "fmt" - "net/netip" - "os" - "path/filepath" - - "github.com/encodeous/nylon/state" - "github.com/goccy/go-yaml" -) - -// SetupTestDir creates a directory for the current test run -func (h *Harness) SetupTestDir() string { - dir := filepath.Join(h.RootDir, "e2e", "runs", h.t.Name()) - // Clean up previous run - os.RemoveAll(dir) - if err := os.MkdirAll(dir, 0755); err != nil { - h.t.Fatal(err) - } - return dir -} - -// WriteConfig marshals the config to YAML and writes it to the specified directory with the given filename -func (h *Harness) WriteConfig(dir, filename string, cfg any) string { - path := filepath.Join(dir, filename) - data, err := yaml.Marshal(cfg) - if err != nil { - h.t.Fatal(err) - } - if err := os.WriteFile(path, data, 0644); err != nil { - h.t.Fatal(err) - } - return path -} - -// SimpleRouter creates a basic RouterCfg with the given parameters -func SimpleRouter(id string, pubKey state.NyPublicKey, nylonIP string, endpointIP string) state.RouterCfg { - cfg := state.RouterCfg{ - NodeCfg: state.NodeCfg{ - Id: state.NodeId(id), - PubKey: pubKey, - Addresses: []netip.Addr{ - netip.MustParseAddr(nylonIP), - }, - }, - } - if endpointIP != "" { - cfg.Endpoints = []netip.AddrPort{ - netip.MustParseAddrPort(fmt.Sprintf("%s:57175", endpointIP)), - } - } - return cfg -} - -// SimpleLocal creates a basic LocalCfg with the given parameters and defaults -func SimpleLocal(id string, key state.NyPrivateKey) state.LocalCfg { - return state.LocalCfg{ - Id: state.NodeId(id), - Key: key, - Port: 57175, - NoNetConfigure: false, - InterfaceName: "nylon0", - } -}