Files
2026-10-22 19:20:57 +02:00

258 lines
9.7 KiB
Go

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)
}
}