diff --git a/internal/usecase/devices/wsman/message.go b/internal/usecase/devices/wsman/message.go index 5152601c2..ad0f4ddc2 100644 --- a/internal/usecase/devices/wsman/message.go +++ b/internal/usecase/devices/wsman/message.go @@ -76,8 +76,8 @@ var ( connectionsMu sync.RWMutex waitForAuthTickTime = 1 * time.Second queueTickTime = 500 * time.Millisecond - expireAfter = 30 * time.Second // expire the stored connection after 30 seconds - waitForAuth = 3 * time.Second // wait for 3 seconds for the connection to authenticate, prevents multiple api calls trying to auth at the same time + expireAfter = 90 * time.Second // expire the stored connection after 90 seconds + waitForAuth = 30 * time.Second // wait for 30 seconds for the connection to authenticate, prevents multiple api calls trying to auth at the same time requestQueue = make(chan func(), deviceCallBuffer) // Buffered channel to queue requests shutdownSignal = make(chan struct{}) diff --git a/pkg/httpserver/server.go b/pkg/httpserver/server.go index 206e71ed4..09271f928 100644 --- a/pkg/httpserver/server.go +++ b/pkg/httpserver/server.go @@ -22,8 +22,8 @@ import ( ) const ( - _defaultReadTimeout = 15 * time.Second - _defaultWriteTimeout = 15 * time.Second + _defaultReadTimeout = 40 * time.Second + _defaultWriteTimeout = 40 * time.Second _defaultAddr = ":80" _defaultShutdownTimeout = 3 * time.Second diff --git a/pkg/httpserver/server_test.go b/pkg/httpserver/server_test.go index 19cf40246..108950b99 100644 --- a/pkg/httpserver/server_test.go +++ b/pkg/httpserver/server_test.go @@ -4,6 +4,7 @@ import ( "net" "net/http" "testing" + "time" "github.com/stretchr/testify/assert" ) @@ -20,3 +21,29 @@ func TestNew(t *testing.T) { //nolint:paralleltest // httpserver can't be bind t assert.Equal(t, net.JoinHostPort("localhost", "9090"), s.server.Addr, "expected addr to be set correctly") assert.Equal(t, _defaultShutdownTimeout, s.shutdownTimeout, "expected shutdown timeout to be set correctly") } + +// The read/write budget must outlast the wsman client's own 30s per-request +// timeout, so a slow AMT device fails there first and the handler can turn it +// into a clean response instead of the server cutting the connection. +func TestDefaultTimeoutsOutlastWsmanClient(t *testing.T) { + t.Parallel() + + const wsmanClientTimeout = 30 * time.Second + + assert.Greater(t, _defaultReadTimeout, wsmanClientTimeout, "read timeout must outlast the wsman client timeout") + assert.Greater(t, _defaultWriteTimeout, wsmanClientTimeout, "write timeout must outlast the wsman client timeout") +} + +func TestServePlainWithInjectedListener(t *testing.T) { //nolint:paralleltest // server lifecycle + handler := http.NewServeMux() + handler.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("plain")) }) + + l := newTestListener(t) + s := New(handler, Listener(l)) + + defer func() { _ = s.Shutdown() }() + + if body := getOK(t, http.DefaultClient, "http://"+l.Addr().String()+"/"); body != "plain" { + t.Fatalf("unexpected body: %q", body) + } +} diff --git a/pkg/httpserver/server_tls_test.go b/pkg/httpserver/server_tls_test.go index ce8318482..55a3b8a4d 100644 --- a/pkg/httpserver/server_tls_test.go +++ b/pkg/httpserver/server_tls_test.go @@ -1,6 +1,7 @@ package httpserver import ( + "bytes" "context" "crypto/rand" "crypto/rsa" @@ -98,6 +99,69 @@ func writeTempCertPair(t *testing.T) (certPath, keyPath string) { // named resul return certPath, keyPath } +// pinTempDir points os.TempDir() at a per-test directory and returns it. +// generateAndServeSelfSignedTLS caches console_selfsigned.crt/.key in the +// system temp dir, so without pinning it, whether the generate branch or the +// reuse branch runs depends on files left behind by an earlier run -- which +// makes the coverage of this package differ between machines and CI runs. +func pinTempDir(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + + t.Setenv("TMPDIR", dir) // POSIX + t.Setenv("TMP", dir) // Windows + t.Setenv("TEMP", dir) // Windows + + return dir +} + +// getOK polls url until the server goroutine is listening, then asserts 200. +func getOK(t *testing.T, client *http.Client, url string) string { + t.Helper() + + deadline := time.Now().Add(2 * time.Second) + + ctx, cancel := context.WithDeadline(context.Background(), deadline) + defer cancel() + + var ( + resp *http.Response + err error + ) + + for time.Now().Before(deadline) { + req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) + if reqErr != nil { + t.Fatalf("create request: %v", reqErr) + } + + resp, err = client.Do(req) + if err == nil { + break + } + + time.Sleep(50 * time.Millisecond) + } + + if err != nil { + t.Fatalf("GET %s failed: %v", url, err) + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + return string(body) +} + func newTestListener(t *testing.T) net.Listener { t.Helper() @@ -114,6 +178,8 @@ func newTestListener(t *testing.T) net.Listener { } func TestTLS_SelfSigned_GeneratesAndServes(t *testing.T) { //nolint:paralleltest // binds a port + pinTempDir(t) + handler := http.NewServeMux() handler.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok")) }) @@ -317,6 +383,121 @@ func TestTLS_MissingFiles_ReturnsError(t *testing.T) { //nolint:paralleltest // _ = s.Shutdown() } +// The generate branch: an empty temp dir has no cached pair, so one is written. +func TestTLS_SelfSigned_WritesPairWhenAbsent(t *testing.T) { //nolint:paralleltest // binds a port + dir := pinTempDir(t) + + certPath := filepath.Join(dir, "console_selfsigned.crt") + keyPath := filepath.Join(dir, "console_selfsigned.key") + + handler := http.NewServeMux() + handler.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("generated")) }) + + l := newTestListener(t) + s := New(handler, Listener(l), TLS(true, "", ""), Logger(appLogger.New("info"))) + + defer func() { _ = s.Shutdown() }() + + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + + if body := getOK(t, client, "https://"+l.Addr().String()+"/"); body != "generated" { + t.Fatalf("unexpected body: %q", body) + } + + for _, path := range []string{certPath, keyPath} { + info, err := os.Stat(path) + if err != nil { + t.Fatalf("expected %s to be generated: %v", path, err) + } + + if info.Size() == 0 { + t.Fatalf("expected %s to be non-empty", path) + } + } +} + +// The reuse branch: a cached pair in the temp dir is served as-is, not rewritten. +func TestTLS_SelfSigned_ReusesCachedPair(t *testing.T) { //nolint:paralleltest // binds a port + dir := pinTempDir(t) + + cert, key := writeTempCertPair(t) + + certPath := filepath.Join(dir, "console_selfsigned.crt") + keyPath := filepath.Join(dir, "console_selfsigned.key") + + certPEM, err := os.ReadFile(cert) + if err != nil { + t.Fatalf("read cert: %v", err) + } + + keyPEM, err := os.ReadFile(key) + if err != nil { + t.Fatalf("read key: %v", err) + } + + if err := os.WriteFile(certPath, certPEM, 0o600); err != nil { + t.Fatalf("seed cert: %v", err) + } + + if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil { + t.Fatalf("seed key: %v", err) + } + + handler := http.NewServeMux() + handler.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("reused")) }) + + l := newTestListener(t) + s := New(handler, Listener(l), TLS(true, "", ""), Logger(appLogger.New("info"))) + + defer func() { _ = s.Shutdown() }() + + // Trusting only the seeded certificate proves the cached pair was served + // rather than a freshly generated one. + roots := x509.NewCertPool() + if ok := roots.AppendCertsFromPEM(certPEM); !ok { + t.Fatalf("failed to append cert to pool") + } + + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS13}}} + + if body := getOK(t, client, "https://"+l.Addr().String()+"/"); body != "reused" { + t.Fatalf("unexpected body: %q", body) + } + + after, err := os.ReadFile(certPath) + if err != nil { + t.Fatalf("read cached cert: %v", err) + } + + if !bytes.Equal(after, certPEM) { + t.Error("expected the cached certificate to be left untouched") + } +} + +// An unwritable temp dir must surface as an error on Notify, not a panic. +func TestTLS_SelfSigned_UnwritableTempDir_ReturnsError(t *testing.T) { + // A path that does not exist: os.WriteFile fails regardless of the user the + // tests run as, so this stays deterministic on CI and in containers. + t.Setenv("TMPDIR", filepath.Join(t.TempDir(), "absent")) + t.Setenv("TMP", filepath.Join(t.TempDir(), "absent")) + t.Setenv("TEMP", filepath.Join(t.TempDir(), "absent")) + + handler := http.NewServeMux() + l := newTestListener(t) + s := New(handler, Listener(l), TLS(true, "", ""), Logger(appLogger.New("info"))) + + select { + case err := <-s.Notify(): + if err == nil { + t.Fatal("expected an error when the certificate cannot be written, got nil") + } + case <-time.After(5 * time.Second): + t.Fatal("timeout waiting for server error") + } + + _ = s.Shutdown() +} + func TestTLS_Mismatch_ReturnsError(t *testing.T) { //nolint:paralleltest // server lifecycle handler := http.NewServeMux() l := newTestListener(t)