package main import ( "context" "crypto/tls" "encoding/json" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) func fakeDocker(t *testing.T, payload string) string { t.Helper() dir := t.TempDir() sock := filepath.Join(dir, "docker.sock") ln, err := net.Listen("unix", sock) if err != nil { t.Fatal(err) } srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/containers/json" { t.Fatalf("unexpected path %s", r.URL.Path) } w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(payload)) })} go srv.Serve(ln) t.Cleanup(func() { _ = srv.Close(); _ = os.Remove(sock) }) return sock } func TestServicesFromUsesStableComposeIdentity(t *testing.T) { items := []dockerContainer{{ID: strings.Repeat("a", 64), Names: []string{"/web-1"}, Image: "demo", Labels: map[string]string{"com.docker.compose.project": "lab", "com.docker.compose.service": "web", "com.docker.compose.container-number": "1"}, Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {PrivatePort: 53, PublicPort: 1053, Type: "udp"}}}} got := servicesFrom(items) if len(got) != 1 { t.Fatalf("got %d services", len(got)) } if got[0].Key != "compose:lab/web/1:8080/tcp" || got[0].TechnicalName != "web-1" { t.Fatalf("unexpected service %#v", got[0]) } if len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18080 { t.Fatalf("unexpected bindings %#v", got[0].Bindings) } } func TestServicesFromKeepsComposeReplicasAndAllBindings(t *testing.T) { labels := func(number string) map[string]string { return map[string]string{"com.docker.compose.project": "lab", "com.docker.compose.service": "web", "com.docker.compose.container-number": number} } items := []dockerContainer{ {ID: "a", Names: []string{"/lab-web-1"}, Labels: labels("1"), Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "::", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "10.10.0.21", PrivatePort: 8080, PublicPort: 18081, Type: "tcp"}}}, {ID: "b", Names: []string{"/lab-web-2"}, Labels: labels("2"), Ports: []dockerPort{{IP: "0.0.0.0", PrivatePort: 8080, PublicPort: 18082, Type: "tcp"}}}, } got := servicesFrom(items) if len(got) != 2 { t.Fatalf("expected two replicas, got %#v", got) } if got[0].Key != "compose:lab/web/1:8080/tcp" || got[1].Key != "compose:lab/web/2:8080/tcp" { t.Fatalf("unexpected replica keys %#v", got) } if got[0].TechnicalName != "lab-web-1" || got[1].TechnicalName != "lab-web-2" { t.Fatalf("expected container-based hostnames, got %#v", got) } if len(got[0].Bindings) != 3 { t.Fatalf("expected all published bindings, got %#v", got[0].Bindings) } if got[0].Bindings[0].BindIP != "10.10.0.21" || got[0].Bindings[0].PublicPort != 18081 { t.Fatalf("concrete binding should be retained, got %#v", got[0].Bindings) } } func TestServicesFromSkipsLoopbackOnlyBindings(t *testing.T) { items := []dockerContainer{{ID: "abc", Names: []string{"/app"}, Labels: map[string]string{}, Ports: []dockerPort{ {IP: "127.0.0.1", PrivatePort: 8080, PublicPort: 18080, Type: "tcp"}, {IP: "::1", PrivatePort: 8080, PublicPort: 18081, Type: "tcp"}, {IP: "0.0.0.0", PrivatePort: 8082, PublicPort: 18082, Type: "tcp"}, }}} got := servicesFrom(items) if len(got) != 1 || len(got[0].Bindings) != 1 || got[0].Bindings[0].PublicPort != 18082 { t.Fatalf("unexpected public services %#v", got) } } func TestIPDetectorCachesPublicLookup(t *testing.T) { var calls atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) _, _ = w.Write([]byte("203.0.113.44")) })) defer srv.Close() detector := &ipDetector{} for range 2 { ip := detector.public(context.Background(), srv.URL) if ip != "203.0.113.44" { t.Fatalf("unexpected detection %s", ip) } } if calls.Load() != 1 { t.Fatalf("expected one public lookup, got %d", calls.Load()) } } func TestMergeHostAddressKeepsInterfaceAndSources(t *testing.T) { var got []hostAddress index := map[string]int{} mergeHostAddress(&got, index, hostAddress{IP: "10.20.30.40", Source: "interface", Interface: "eno1", Scope: "private"}) mergeHostAddress(&got, index, hostAddress{IP: "10.20.30.40", Source: "route"}) if len(got) != 1 || got[0].Interface != "eno1" || got[0].Source != "interface,route" { t.Fatalf("unexpected merged address %#v", got) } } func TestInventoryRequiresTokenAndReportsMultipleAddressModel(t *testing.T) { sock := fakeDocker(t, `[{"Id":"1234567890abcdef","Names":["/app1"],"Image":"example/app","Labels":{},"Ports":[{"IP":"0.0.0.0","PrivatePort":8080,"PublicPort":18080,"Type":"tcp"}]}]`) cfg := config{Token: strings.Repeat("x", 24), DockerSock: sock, AdvertiseIP: "203.0.113.10", PublicIPURL: "", Hostname: "docker-a"} srv := httptest.NewServer(handler(cfg)) defer srv.Close() res, err := http.Get(srv.URL + "/v1/inventory") if err != nil { t.Fatal(err) } if res.StatusCode != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", res.StatusCode) } _ = res.Body.Close() req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL+"/v1/inventory", nil) req.Header.Set("Authorization", "Bearer "+cfg.Token) client := &http.Client{Timeout: time.Second} res, err = client.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { t.Fatalf("expected 200, got %d", res.StatusCode) } var got inventory if err := json.NewDecoder(res.Body).Decode(&got); err != nil { t.Fatal(err) } if got.PublicIP != "203.0.113.10" || got.PublicIPSource != "override" || len(got.Services) != 1 { t.Fatalf("unexpected inventory %#v", got) } found := false for _, address := range got.Addresses { if address.IP == "203.0.113.10" && strings.Contains(address.Source, "override") { found = true } } if !found { t.Fatalf("advertised address missing from %#v", got.Addresses) } } func TestSkipInterfaceFiltersDockerGeneratedLinksButKeepsHostBridges(t *testing.T) { for _, name := range []string{"docker0", "docker_gwbridge", "veth1234", "br-123456", "virbr0"} { if !skipInterface(name) { t.Fatalf("expected %s to be filtered", name) } } for _, name := range []string{"eno1", "eth0", "br0", "bond0", "wg0"} { if skipInterface(name) { t.Fatalf("expected %s to remain", name) } } } func TestSelfSignedCertificateIsCreatedOnceAndFingerprintStable(t *testing.T) { dir := t.TempDir() certFile := filepath.Join(dir, "agent.crt") keyFile := filepath.Join(dir, "agent.key") first, created, err := ensureSelfSignedCertificate(certFile, keyFile, "docker-a.lab.local") if err != nil { t.Fatal(err) } if !created || len(first) != 64 { t.Fatalf("unexpected initial certificate result created=%v fingerprint=%q", created, first) } second, createdAgain, err := ensureSelfSignedCertificate(certFile, keyFile, "docker-a.lab.local") if err != nil { t.Fatal(err) } if createdAgain || second != first { t.Fatalf("certificate should be stable: first=%q second=%q created=%v", first, second, createdAgain) } if mode := fileMode(t, keyFile); mode.Perm() != 0600 { t.Fatalf("private key mode = %o, want 600", mode.Perm()) } if mode := fileMode(t, certFile); mode.Perm() != 0644 { t.Fatalf("certificate mode = %o, want 644", mode.Perm()) } } func fileMode(t *testing.T, path string) os.FileMode { t.Helper() info, err := os.Stat(path) if err != nil { t.Fatal(err) } return info.Mode() } func TestTLSInventoryReportsServingCertificateFingerprint(t *testing.T) { sock := fakeDocker(t, `[{"Id":"abc","Names":["/app"],"Image":"demo","Labels":{},"Ports":[{"IP":"0.0.0.0","PrivatePort":8080,"PublicPort":18080,"Type":"tcp"}]}]`) dir := t.TempDir() certFile := filepath.Join(dir, "agent.crt") keyFile := filepath.Join(dir, "agent.key") fingerprint, _, err := ensureSelfSignedCertificate(certFile, keyFile, "localhost") if err != nil { t.Fatal(err) } cfg := config{Token: strings.Repeat("z", 24), DockerSock: sock, Hostname: "docker-tls", TLS: true, CertFile: certFile, KeyFile: keyFile, Fingerprint: fingerprint} listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } server := &http.Server{Handler: handler(cfg)} go func() { _ = server.ServeTLS(listener, certFile, keyFile) }() t.Cleanup(func() { _ = server.Close() }) transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}} // test-only: verify the endpoint is actually TLS; RustProxy pins it in production. client := &http.Client{Transport: transport, Timeout: time.Second} req, _ := http.NewRequest(http.MethodGet, "https://"+listener.Addr().String()+"/v1/inventory", nil) req.Header.Set("Authorization", "Bearer "+cfg.Token) res, err := client.Do(req) if err != nil { t.Fatal(err) } defer res.Body.Close() if got := res.Header.Get("X-RustProxy-Agent-Certificate-SHA256"); got != fingerprint { t.Fatalf("certificate header = %q, want %q", got, fingerprint) } var got inventory if err := json.NewDecoder(res.Body).Decode(&got); err != nil { t.Fatal(err) } if got.CertificateSHA256 != fingerprint { t.Fatalf("inventory fingerprint = %q, want %q", got.CertificateSHA256, fingerprint) } } func TestTechnicalNameIgnoresImageAndPort(t *testing.T) { c := dockerContainer{Names: []string{"/My_App"}, Image: "registry.example.com/image:latest"} one := servicesFrom([]dockerContainer{{Names: c.Names, Image: c.Image, Ports: []dockerPort{{PrivatePort: 80, PublicPort: 8080, Type: "tcp"}}}}) two := servicesFrom([]dockerContainer{{Names: c.Names, Image: "changed", Ports: []dockerPort{{PrivatePort: 8080, PublicPort: 9000, Type: "tcp"}}}}) if len(one) != 1 || len(two) != 1 || one[0].TechnicalName != "my-app" || two[0].TechnicalName != "my-app" { t.Fatalf("technical names must use the container name: %#v %#v", one, two) } }