사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
566 lines
17 KiB
Go
566 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"go.uber.org/zap"
|
|
"go.uber.org/zap/zaptest"
|
|
"go.uber.org/zap/zaptest/observer"
|
|
|
|
"iop/apps/control-plane/internal/credentialseal"
|
|
)
|
|
|
|
// writeKeyManifest writes a valid 0600 key manifest with one 32-byte key
|
|
// (id "primary", version 1) and returns its path. The material is deterministic
|
|
// so a reopened keyring holds identical bytes.
|
|
func writeKeyManifest(t *testing.T, fill byte) string {
|
|
t.Helper()
|
|
path := filepath.Join(t.TempDir(), "keyring.yaml")
|
|
material := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{fill}, 32))
|
|
body := "keys:\n - id: \"primary\"\n version: 1\n material: \"" + material + "\"\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
return path
|
|
}
|
|
|
|
func encryptionConfig(keyFile string) credentialseal.FileConfig {
|
|
return credentialseal.FileConfig{KeyFile: keyFile, ActiveKeyID: "primary", ActiveKeyVersion: 1}
|
|
}
|
|
|
|
func TestComposeCredentialRuntimeDatabaseOnly(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = ""
|
|
cred, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
|
|
if err != nil {
|
|
t.Fatalf("compose: %v", err)
|
|
}
|
|
if cred.store != nil {
|
|
t.Fatal("empty database URL must leave the store unopened")
|
|
}
|
|
if cred.service != nil {
|
|
t.Fatal("no service without encryption")
|
|
}
|
|
}
|
|
|
|
func TestComposeCredentialRuntimeSecretDisabledMode(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "disabled.db")
|
|
cred, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
|
|
if err != nil {
|
|
t.Fatalf("compose: %v", err)
|
|
}
|
|
if cred.store == nil {
|
|
t.Fatal("store must open for a configured database")
|
|
}
|
|
t.Cleanup(func() { _ = cred.store.Close() })
|
|
if cred.service != nil {
|
|
t.Fatal("secret-disabled mode must not inject a credential service")
|
|
}
|
|
}
|
|
|
|
func TestComposeCredentialRuntimeConfiguredInjectsService(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "configured.db")
|
|
cfg.CredentialEncryption = encryptionConfig(writeKeyManifest(t, 0x3C))
|
|
|
|
cred, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
|
|
if err != nil {
|
|
t.Fatalf("compose: %v", err)
|
|
}
|
|
if cred.store == nil {
|
|
t.Fatal("store must open when encryption is configured")
|
|
}
|
|
t.Cleanup(func() { _ = cred.store.Close() })
|
|
if cred.service == nil {
|
|
t.Fatal("configured encryption must inject a credential service")
|
|
}
|
|
}
|
|
|
|
func TestComposeCredentialRuntimePartialConfigFailsBeforeStore(t *testing.T) {
|
|
for name, enc := range map[string]credentialseal.FileConfig{
|
|
"only-file": {KeyFile: "/run/secrets/x"},
|
|
"only-id": {ActiveKeyID: "primary"},
|
|
"only-version": {ActiveKeyVersion: 1},
|
|
"file-id": {KeyFile: "/run/secrets/x", ActiveKeyID: "primary"},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
dbPath := filepath.Join(t.TempDir(), "partial.db")
|
|
cfg.Database.URL = dbPath
|
|
cfg.CredentialEncryption = enc
|
|
|
|
_, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
|
|
if err == nil {
|
|
t.Fatal("expected partial encryption config to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "credential encryption") {
|
|
t.Fatalf("expected credential encryption error, got: %v", err)
|
|
}
|
|
// Fail-closed before opening the store: no database file is created.
|
|
if _, statErr := os.Stat(dbPath); statErr == nil {
|
|
t.Fatal("store must not open when encryption config is partial")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestComposeCredentialRuntimeInvalidManifestVariants(t *testing.T) {
|
|
t.Run("missing file", func(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
|
|
cfg.CredentialEncryption = encryptionConfig(filepath.Join(t.TempDir(), "absent.yaml"))
|
|
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
|
|
t.Fatal("expected missing key file to fail")
|
|
}
|
|
})
|
|
|
|
t.Run("insecure permissions", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "perm.yaml")
|
|
material := base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{0x11}, 32))
|
|
body := "keys:\n - id: \"primary\"\n version: 1\n material: \"" + material + "\"\n"
|
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
|
|
cfg.CredentialEncryption = encryptionConfig(path)
|
|
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
|
|
t.Fatal("expected group-readable key file to fail")
|
|
}
|
|
})
|
|
|
|
t.Run("malformed manifest", func(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "bad.yaml")
|
|
if err := os.WriteFile(path, []byte("keys: [broken"), 0o600); err != nil {
|
|
t.Fatalf("write manifest: %v", err)
|
|
}
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
|
|
cfg.CredentialEncryption = encryptionConfig(path)
|
|
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
|
|
t.Fatal("expected malformed manifest to fail")
|
|
}
|
|
})
|
|
|
|
t.Run("unknown active key", func(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "x.db")
|
|
cfg.CredentialEncryption = credentialseal.FileConfig{KeyFile: writeKeyManifest(t, 0x22), ActiveKeyID: "primary", ActiveKeyVersion: 9}
|
|
if _, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t)); err == nil {
|
|
t.Fatal("expected unknown active key to fail")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestComposeCredentialRuntimeRestartReopen(t *testing.T) {
|
|
dbPath := filepath.Join(t.TempDir(), "restart.db")
|
|
keyFile := writeKeyManifest(t, 0x4D)
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = dbPath
|
|
cfg.CredentialEncryption = encryptionConfig(keyFile)
|
|
|
|
first, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
|
|
if err != nil {
|
|
t.Fatalf("first compose: %v", err)
|
|
}
|
|
if err := first.store.Close(); err != nil {
|
|
t.Fatalf("close first store: %v", err)
|
|
}
|
|
|
|
second, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
|
|
if err != nil {
|
|
t.Fatalf("second compose after restart: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = second.store.Close() })
|
|
if second.store == nil || second.service == nil {
|
|
t.Fatal("restart must reopen store and reinject service")
|
|
}
|
|
}
|
|
|
|
func TestRunWithEncryptionConfigStartsAndLogsNoSecret(t *testing.T) {
|
|
t.Setenv("IOP_DATABASE_URL", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
logger := zap.New(core)
|
|
|
|
keyFile := writeKeyManifest(t, 0x5E)
|
|
material := readManifestMaterial(t, keyFile)
|
|
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "run-enc.db")
|
|
cfg.CredentialEncryption = encryptionConfig(keyFile)
|
|
cfg.Server.Listen = "127.0.0.1:0"
|
|
cfg.Server.WireListen = "127.0.0.1:0"
|
|
cfg.Server.EdgeWireListen = "127.0.0.1:0"
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- run(ctx, cfg, logger) }()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil && ctx.Err() == nil {
|
|
t.Fatalf("run with encryption: %v", err)
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
cancel()
|
|
<-errCh
|
|
}
|
|
|
|
var sawReady, encEnabled bool
|
|
for _, entry := range observed.All() {
|
|
if entry.Message == "control-plane credential store ready" {
|
|
sawReady = true
|
|
for _, f := range entry.Context {
|
|
if f.Key == "secret_encryption" && f.Integer == 1 {
|
|
encEnabled = true
|
|
}
|
|
}
|
|
}
|
|
// No log entry may carry the raw key material.
|
|
full := entry.Message
|
|
for _, f := range entry.Context {
|
|
full += fmt.Sprintf(" %s=%v", f.Key, f.Interface) + f.String
|
|
}
|
|
if strings.Contains(full, material) {
|
|
t.Fatalf("log leaked key material: %q", entry.Message)
|
|
}
|
|
}
|
|
if !sawReady {
|
|
t.Fatal("expected credential store ready log")
|
|
}
|
|
if !encEnabled {
|
|
t.Fatal("expected secret_encryption=true in ready log")
|
|
}
|
|
}
|
|
|
|
func TestRunWithPartialEncryptionConfigDoesNotStartListener(t *testing.T) {
|
|
t.Setenv("IOP_DATABASE_URL", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
logger := zap.New(core)
|
|
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = filepath.Join(t.TempDir(), "partial-run.db")
|
|
cfg.CredentialEncryption = credentialseal.FileConfig{ActiveKeyID: "primary", ActiveKeyVersion: 1} // no key_file
|
|
cfg.Server.Listen = "127.0.0.1:0"
|
|
cfg.Server.WireListen = "127.0.0.1:0"
|
|
cfg.Server.EdgeWireListen = "127.0.0.1:0"
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- run(ctx, cfg, logger) }()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err == nil {
|
|
t.Fatal("expected run to fail on partial encryption config")
|
|
}
|
|
if !strings.Contains(err.Error(), "credential encryption") {
|
|
t.Fatalf("expected credential encryption error, got: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
cancel()
|
|
t.Fatal("run did not fail within timeout")
|
|
}
|
|
|
|
for _, entry := range observed.All() {
|
|
if strings.Contains(entry.Message, "http endpoint listening") {
|
|
t.Fatal("http listener must not start when encryption config is partial")
|
|
}
|
|
}
|
|
}
|
|
|
|
func readManifestMaterial(t *testing.T, path string) string {
|
|
t.Helper()
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
t.Fatalf("read manifest: %v", err)
|
|
}
|
|
for _, line := range strings.Split(string(data), "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if strings.HasPrefix(line, "material:") {
|
|
return strings.Trim(strings.TrimSpace(strings.TrimPrefix(line, "material:")), "\"")
|
|
}
|
|
}
|
|
t.Fatal("manifest material not found")
|
|
return ""
|
|
}
|
|
|
|
func TestRunAllowsUnconfiguredDatabase(t *testing.T) {
|
|
t.Setenv("IOP_DATABASE_URL", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
|
defer cancel()
|
|
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
logger := zap.New(core)
|
|
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = ""
|
|
cfg.Server.Listen = "127.0.0.1:0"
|
|
cfg.Server.WireListen = "127.0.0.1:0"
|
|
cfg.Server.EdgeWireListen = "127.0.0.1:0"
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- run(ctx, cfg, logger)
|
|
}()
|
|
|
|
// Give the server a moment to start (or fail).
|
|
select {
|
|
case err := <-errCh:
|
|
// Server exited; check it did not fail on empty database.
|
|
if err != nil && !strings.Contains(err.Error(), "use of closed") && !strings.Contains(err.Error(), "server closed") {
|
|
// Accept context cancellation or shutdown errors.
|
|
if ctx.Err() == nil {
|
|
t.Fatalf("run with empty database: %v", err)
|
|
}
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
// Server is still running; cancel context to shut it down.
|
|
cancel()
|
|
<-errCh
|
|
}
|
|
|
|
// No log entry should reference a database connection attempt.
|
|
for _, entry := range observed.All() {
|
|
if strings.Contains(entry.Message, "credential-store") || strings.Contains(entry.Message, "credentialstore") {
|
|
t.Fatalf("unexpected credential store log: %q", entry.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunRejectsInvalidDatabaseURL(t *testing.T) {
|
|
t.Setenv("IOP_DATABASE_URL", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
logger := zap.New(core)
|
|
|
|
cfg := defaultConfig()
|
|
// Use an unsupported scheme that dialectFromURL rejects immediately.
|
|
cfg.Database.URL = "memdb://invalid-host:99999/test"
|
|
cfg.Server.Listen = "127.0.0.1:0"
|
|
cfg.Server.WireListen = "127.0.0.1:0"
|
|
cfg.Server.EdgeWireListen = "127.0.0.1:0"
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- run(ctx, cfg, logger)
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err == nil {
|
|
t.Fatal("expected run to fail with invalid database URL")
|
|
}
|
|
if !strings.Contains(err.Error(), "credential store") && !strings.Contains(err.Error(), "credentialstore") {
|
|
t.Fatalf("expected credential store error, got: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
cancel()
|
|
t.Fatal("run did not fail within timeout")
|
|
}
|
|
|
|
// Verify no credential store log contains the raw URL.
|
|
for _, entry := range observed.All() {
|
|
if strings.Contains(entry.Message, "memdb://") {
|
|
t.Fatalf("raw invalid URL leaked into log: %q", entry.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunWithSQLiteFileSucceeds(t *testing.T) {
|
|
t.Setenv("IOP_DATABASE_URL", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
logger := zap.New(core)
|
|
|
|
tmpDir := t.TempDir()
|
|
dbFile := filepath.Join(tmpDir, "test.db")
|
|
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = dbFile
|
|
cfg.Server.Listen = "127.0.0.1:0"
|
|
cfg.Server.WireListen = "127.0.0.1:0"
|
|
cfg.Server.EdgeWireListen = "127.0.0.1:0"
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- run(ctx, cfg, logger)
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err != nil && ctx.Err() == nil {
|
|
t.Fatalf("run with SQLite: %v", err)
|
|
}
|
|
case <-time.After(1 * time.Second):
|
|
cancel()
|
|
<-errCh
|
|
}
|
|
|
|
// Verify DB file was created.
|
|
if _, err := os.Stat(dbFile); os.IsNotExist(err) {
|
|
t.Fatal("SQLite database file was not created")
|
|
}
|
|
|
|
// Verify schema was migrated.
|
|
for _, entry := range observed.All() {
|
|
if strings.Contains(entry.Message, "memdb://") {
|
|
t.Fatalf("unexpected URL in log: %q", entry.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDatabaseLogFieldsDoNotExposeCredentialStoreURL(t *testing.T) {
|
|
// Verify the existing databaseLogFields helper does not leak credentials
|
|
// when called with a credential store URL.
|
|
fields := databaseLogFields("postgres://admin:supersecret@db:5432/iop-creds?sslmode=require")
|
|
for _, field := range fields {
|
|
if strings.Contains(fmt.Sprint(field), "supersecret") {
|
|
t.Fatalf("credential leaked into log field: %v", field)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRunWithUnreachablePostgresFails(t *testing.T) {
|
|
t.Setenv("IOP_DATABASE_URL", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
logger := zap.New(core)
|
|
|
|
cfg := defaultConfig()
|
|
// Use a port that is guaranteed to be unreachable.
|
|
cfg.Database.URL = "postgres://localhost:1/noexist"
|
|
cfg.Server.Listen = "127.0.0.1:0"
|
|
cfg.Server.WireListen = "127.0.0.1:0"
|
|
cfg.Server.EdgeWireListen = "127.0.0.1:0"
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() {
|
|
errCh <- run(ctx, cfg, logger)
|
|
}()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err == nil {
|
|
t.Fatal("expected run to fail with unreachable postgres")
|
|
}
|
|
if !strings.Contains(err.Error(), "credential store") && !strings.Contains(err.Error(), "credentialstore") {
|
|
t.Fatalf("expected credential store error, got: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
cancel()
|
|
t.Fatal("run did not fail within timeout")
|
|
}
|
|
|
|
// Verify no credential store log leaked the URL.
|
|
for _, entry := range observed.All() {
|
|
msg := entry.Message
|
|
for _, f := range entry.Context {
|
|
msg += fmt.Sprintf(" %s=%v", f.Key, f.Interface)
|
|
}
|
|
if strings.Contains(msg, "localhost:1/noexist") {
|
|
t.Fatalf("raw database URL leaked into log: %q", entry.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// helper: find a free port for tests.
|
|
func TestComposeCredentialRuntimeEncryptionRequiresDatabase(t *testing.T) {
|
|
for _, dbURL := range []string{"", " ", " \t "} {
|
|
name := "empty"
|
|
if dbURL == " " {
|
|
name = "whitespace"
|
|
} else if dbURL == " \t " {
|
|
name = "spaces-and-tabs"
|
|
}
|
|
t.Run(name, func(t *testing.T) {
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = dbURL
|
|
cfg.CredentialEncryption = encryptionConfig(writeKeyManifest(t, 0x6F))
|
|
|
|
_, err := composeCredentialRuntime(context.Background(), cfg, zaptest.NewLogger(t))
|
|
if err == nil {
|
|
t.Fatal("expected encryption without database to fail")
|
|
}
|
|
if !strings.Contains(err.Error(), "credential encryption requires database.url") {
|
|
t.Fatalf("expected credential encryption requires database.url error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunWithEncryptionAndNoDatabaseDoesNotStartListener(t *testing.T) {
|
|
t.Setenv("IOP_DATABASE_URL", "")
|
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
|
defer cancel()
|
|
|
|
core, observed := observer.New(zap.InfoLevel)
|
|
logger := zap.New(core)
|
|
|
|
keyFile := writeKeyManifest(t, 0x7A)
|
|
cfg := defaultConfig()
|
|
cfg.Database.URL = ""
|
|
cfg.CredentialEncryption = encryptionConfig(keyFile)
|
|
cfg.Server.Listen = "127.0.0.1:0"
|
|
cfg.Server.WireListen = "127.0.0.1:0"
|
|
cfg.Server.EdgeWireListen = "127.0.0.1:0"
|
|
|
|
errCh := make(chan error, 1)
|
|
go func() { errCh <- run(ctx, cfg, logger) }()
|
|
|
|
select {
|
|
case err := <-errCh:
|
|
if err == nil {
|
|
t.Fatal("expected run to fail with encryption and no database")
|
|
}
|
|
if !strings.Contains(err.Error(), "credential encryption requires database.url") {
|
|
t.Fatalf("expected credential encryption requires database.url error, got: %v", err)
|
|
}
|
|
case <-time.After(2 * time.Second):
|
|
cancel()
|
|
t.Fatal("run did not fail within timeout")
|
|
}
|
|
|
|
// No HTTP, client wire, or edge wire listening log may be emitted.
|
|
for _, entry := range observed.All() {
|
|
if strings.Contains(entry.Message, "http endpoint listening") {
|
|
t.Fatal("http listener must not start when encryption has no database")
|
|
}
|
|
if entry.Message == "starting client wire WS server" {
|
|
t.Fatal("client wire listener must not start when encryption has no database")
|
|
}
|
|
if entry.Message == "starting edge wire TCP server" {
|
|
t.Fatal("edge wire listener must not start when encryption has no database")
|
|
}
|
|
}
|
|
}
|
|
|
|
func freePort(t *testing.T) string {
|
|
t.Helper()
|
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("find free port: %v", err)
|
|
}
|
|
defer l.Close()
|
|
return l.Addr().String()
|
|
}
|