258 lines
9.7 KiB
Go
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)
|
|
}
|
|
}
|