From 5e544eee3d188d7c4a4fd6e9863d632dcbb2d7f0 Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Thu, 10 Sep 2026 19:53:28 +0530 Subject: [PATCH] The camera tiles put a password in the page, and loaded nothing StreamURL built http://user:pass@127.0.0.1:8010/api/cameras// stream.mjpeg and handed it to an , with a comment saying the credentials were inline "so an tag can load it". It cannot. Chromium strips credentials from subresource URLs and has since M59, and WebView2 is Chromium - so on the one platform this product ships to, every camera tile on a shop counter was a broken image. Measured against a running engine: the app's Go-side calls returned stats and people while an on that very URL failed, and curl proved the URL answered 200. The engine was never the problem. The password now stays on this side of the process boundary. A loopback relay attaches Basic auth and streams the engine's bytes back unchanged - the same reasoning Shot.jsx already follows at head office, where an equally cannot carry a session. What the relay is careful about, since it is a door onto the biometric API with a credential attached: - loopback only, on a port the OS picks; a fixed one would collide with whatever else a shop PC runs and read as "the cameras broke" - a per-run random token in the path. The engine's own credential exists so the live face feed is never served open; an unauthenticated relay would hand that feed to any other process on the PC. Compared in constant time, and a wrong one is 404, not 403 - an allow-list of stream.mjpeg and frame.jpg. Holding the token does not reach the identity list, the gallery, or erasure - camera ids validated, not interpolated - every chunk flushed; a buffered MJPEG stream is a tile that never paints, which looks identical to the bug being fixed Two of those were written after a test failed, not before: - `..` MATCHES the id pattern, because real camera ids contain dots. `/api/cameras/../stream.mjpeg` is not the endpoint anyone intended. The id can never hold a slash, so `.` and `..` are the whole remaining traversal surface and are now refused by name. - the serve goroutine read p.srv off the struct while stop() was nilling it, so a quick start/stop dereferenced nil and took the process down. Captured before launching now. FrameURL is deliberately not added. No screen asks for a still, and a bound method nothing calls is the same defect as a capability the UI cannot reach, only pointing the other way. Verified: nine unit tests, plus a live test against the real engine and the real office camera - two MJPEG frames, 90,793 bytes, no credential in the URL. Windows and darwin both build; vet clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pcn9asw19WGBfCEaHvNug6 --- desktop/app.go | 40 +++-- desktop/main.go | 1 + desktop/stream_proxy.go | 249 +++++++++++++++++++++++++++++ desktop/stream_proxy_test.go | 296 +++++++++++++++++++++++++++++++++++ 4 files changed, 577 insertions(+), 9 deletions(-) create mode 100644 desktop/stream_proxy.go create mode 100644 desktop/stream_proxy_test.go diff --git a/desktop/app.go b/desktop/app.go index a46cb53..8c0cb9c 100644 --- a/desktop/app.go +++ b/desktop/app.go @@ -43,6 +43,9 @@ type App struct { broker *agentmqtt.Client stopBridge func() hookURL string + // Relays camera feeds to the webview so the engine's credential never has + // to travel in an src, which a Chromium webview would strip anyway. + proxy *streamProxy // Set once the operator logs in. Until then the UI shows the login sheet // and nothing else is reachable. onSessionChange func(bool) @@ -63,6 +66,7 @@ func NewApp() *App { cfg: cfg, cloud: cloud.New(envOr("BEHAVISION_CLOUD", "https://mcp.loyaly.ai")), local: local.New(base, cfg.APIUser, cfg.APIPassword), + proxy: newStreamProxy(), } } @@ -70,6 +74,14 @@ func (a *App) startup(ctx context.Context) { a.ctx = ctx _ = agentpaths.EnsureState() + // Before any screen asks for a camera URL. A failure here is logged and + // not fatal: the rest of the app - people, cameras, the engine controls - + // works without a picture, and refusing to start over a broken tile would + // take a working shop offline. + if err := a.proxy.start(a.local.Base, a.local.User, a.local.Password); err != nil { + log.Printf("camera relay unavailable, tiles will not load: %v", err) + } + // A saved session means a shop PC that rebooted overnight comes back // working instead of waiting for someone to log in. if a.cfg.SessionToken != "" { @@ -273,8 +285,8 @@ type PipelineStatus struct { // Standalone separates "nothing is being sent because this PC is set up on // its own" from "nothing is being sent and something is wrong". They look // identical from the counters alone, and only one of them is a fault. - Standalone bool `json:"standalone"` - BrokerUp bool `json:"broker_up"` + Standalone bool `json:"standalone"` + BrokerUp bool `json:"broker_up"` Accepted uint64 `json:"accepted"` } @@ -549,15 +561,25 @@ func (a *App) PlacementResult(id string) (map[string]any, error) { return a.local.PlacementResult(ctx, id) } -// StreamURL is the MJPEG endpoint for a camera, with credentials inline so an -// tag can load it. Loopback only - it never leaves this machine. +// StreamURL is the MJPEG endpoint for a camera tile. +// +// It points at this app's own loopback relay, not at the engine directly. The +// previous version put the engine's Basic credentials inline in the URL, with +// a comment saying they were there "so an tag can load it" - which a +// browser will not do. Chromium strips credentials from subresource URLs, and +// WebView2 is Chromium, so every camera tile on a shop PC was a broken image. +// See stream_proxy.go for the measurement. +// +// The relay is also why no password appears in the page any more. If it is not +// running the fallback is the bare engine URL with no credential: correct for +// an engine configured without auth, and for one with auth a tile that fails +// to load rather than a password sitting in the DOM. func (a *App) StreamURL(cameraID string) string { - base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://") - if a.local.User == "" { - return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID) + if u := a.proxy.urlFor(cameraID, "stream.mjpeg"); u != "" { + return u } - return fmt.Sprintf("http://%s:%s@%s/api/cameras/%s/stream.mjpeg", - a.local.User, a.local.Password, base, cameraID) + base := strings.TrimPrefix(strings.TrimPrefix(a.local.Base, "http://"), "https://") + return fmt.Sprintf("http://%s/api/cameras/%s/stream.mjpeg", base, cameraID) } // ------------------------------------------------------------------- live -- diff --git a/desktop/main.go b/desktop/main.go index 83dc2a7..3f56748 100644 --- a/desktop/main.go +++ b/desktop/main.go @@ -49,6 +49,7 @@ func main() { }, OnShutdown: func(ctx context.Context) { tray.stop() + app.proxy.stop() app.StopEngine() }, Bind: []any{app}, diff --git a/desktop/stream_proxy.go b/desktop/stream_proxy.go new file mode 100644 index 0000000..6d57c84 --- /dev/null +++ b/desktop/stream_proxy.go @@ -0,0 +1,249 @@ +package main + +// streamProxy serves the engine's camera feeds to this app's own webview +// without putting a credential in the page. +// +// What this replaces: StreamURL used to build +// http://user:pass@127.0.0.1:8010/api/cameras//stream.mjpeg and hand it +// to an , with a comment saying the credentials were inline "so an +// tag can load it". It cannot. Chromium strips credentials from subresource +// URLs and has since M59, and WebView2 is Chromium - so on the one platform +// this product ships to, every camera tile on the shop floor renders as a +// broken image. Measured against the same running engine: the app's Go-side +// calls returned stats and people while an on the very same URL failed, +// and curl proved the URL itself answered 200. The engine was never the +// problem; the browser was throwing the password away before it asked. +// +// So the password stays on this side of the process boundary. The webview +// asks this loopback listener, the listener attaches Basic auth and relays +// the engine's bytes back unchanged. It is the same reasoning the head-office +// web app already follows in Shot.jsx, where an equally cannot carry a +// session and the bytes are fetched and handed over as an object URL. + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/hex" + "fmt" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "sync" + "time" +) + +// A camera id reaches this from the engine and from a person typing into the +// Add Camera form. Validated rather than interpolated: without this a `..` +// would climb out of the two paths below and turn a camera relay into a proxy +// for any engine endpoint, with the credential helpfully attached. +var safeCameraIDChars = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`) + +// safeCameraID is the character check AND the two names that pass it and still +// mean something to a path resolver. +// +// The pattern allows `.` because real camera ids contain them - which means it +// also allows exactly `.` and `..`, and `/api/cameras/../stream.mjpeg` is not +// the endpoint anyone intended. The id can never contain a slash (the path is +// split on them before we get here), so these two strings are the entire +// remaining traversal surface. Found by the test, not by reading the regex. +func safeCameraID(id string) bool { + if id == "." || id == ".." { + return false + } + return safeCameraIDChars.MatchString(id) +} + +type streamProxy struct { + mu sync.RWMutex + ln net.Listener + srv *http.Server + client *http.Client + token string + target string // engine origin, e.g. http://127.0.0.1:8010 + user string + pass string +} + +func newStreamProxy() *streamProxy { return &streamProxy{} } + +// start binds a loopback listener and begins relaying. Calling it again while +// running is a no-op, so a restarted engine cannot leave two listeners behind. +func (p *streamProxy) start(base, user, pass string) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.srv != nil { + return nil + } + + if !strings.HasPrefix(base, "http://") && !strings.HasPrefix(base, "https://") { + base = "http://" + base + } + if _, err := url.Parse(base); err != nil { + return fmt.Errorf("engine base %q: %w", base, err) + } + + // The engine's own credential exists precisely so that the live face feed + // is never served open - CLAUDE.md is explicit that an unauthenticated + // listener would expose it. An unauthenticated loopback relay would hand + // that same feed to any other process on this PC, which on a shop counter + // is not a theoretical set. A per-run token, minted here and given only to + // this app's own webview, keeps the relay as private as the engine is. + raw := make([]byte, 32) + if _, err := rand.Read(raw); err != nil { + return fmt.Errorf("proxy token: %w", err) + } + + // Port 0: the OS picks a free one. A fixed port would collide with + // whatever else a shop PC happens to be running, and the failure would be + // "the cameras stopped working" with nothing pointing at the cause. + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return fmt.Errorf("stream proxy listen: %w", err) + } + + p.ln = ln + p.token = hex.EncodeToString(raw) + p.target = strings.TrimRight(base, "/") + p.user, p.pass = user, pass + // No client timeout: an MJPEG stream is endless by design and any deadline + // would cut the picture off mid-shift. The request context ends it when + // the webview navigates away or the tile is replaced. + p.client = &http.Client{ + Transport: &http.Transport{ + DialContext: (&net.Dialer{Timeout: 5 * time.Second}).DialContext, + TLSHandshakeTimeout: 5 * time.Second, + }, + } + srv := &http.Server{Handler: http.HandlerFunc(p.handle)} + p.srv = srv + + // srv and ln are captured, not read off the struct inside the goroutine: + // stop() sets both to nil, so a serve loop that reached for them after a + // quick start/stop would dereference nil and take the whole app down. The + // test that stops the relay found exactly that. + go func() { _ = srv.Serve(ln) }() + return nil +} + +func (p *streamProxy) stop() { + p.mu.Lock() + srv, ln := p.srv, p.ln + p.srv, p.ln, p.token = nil, nil, "" + p.mu.Unlock() + if srv != nil { + _ = srv.Close() + } + if ln != nil { + _ = ln.Close() + } +} + +// urlFor returns the loopback URL for one camera resource, or "" when the +// proxy is not running so the caller can fall back. +func (p *streamProxy) urlFor(cameraID, file string) string { + p.mu.RLock() + defer p.mu.RUnlock() + if p.ln == nil || p.token == "" || !safeCameraID(cameraID) { + return "" + } + return fmt.Sprintf("http://%s/s/%s/%s/%s", + p.ln.Addr().String(), p.token, cameraID, file) +} + +func (p *streamProxy) handle(w http.ResponseWriter, r *http.Request) { + p.mu.RLock() + token, target, user, pass, client := p.token, p.target, p.user, p.pass, p.client + p.mu.RUnlock() + if token == "" || client == nil { + http.NotFound(w, r) + return + } + + // /s/// + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/"), "/") + if len(parts) != 4 || parts[0] != "s" { + http.NotFound(w, r) + return + } + // Constant time: the token is the only thing standing between another + // local process and a live view of customers' faces. + if subtle.ConstantTimeCompare([]byte(parts[1]), []byte(token)) != 1 { + // 404 rather than 403. There is nothing here to tell an unwelcome + // caller they have found the right door with the wrong key. + http.NotFound(w, r) + return + } + cameraID := parts[2] + if !safeCameraID(cameraID) { + http.NotFound(w, r) + return + } + + // An allow-list, not a prefix match. Everything else the engine serves - + // the identity list, the gallery, erasure - stays unreachable through here + // even for a caller holding the token. + // + // frame.jpg is listed although no screen asks for one yet. It is reachable + // only through urlFor, which is internal, so it adds no bound API nobody + // calls; it is here so that adding a still later is a change to a screen + // rather than a change to the one file where a mistake is a credentialed + // proxy onto the biometric API. + var enginePath string + switch parts[3] { + case "stream.mjpeg": + enginePath = "/api/cameras/" + cameraID + "/stream.mjpeg" + case "frame.jpg": + enginePath = "/api/cameras/" + cameraID + "/frame.jpg" + default: + http.NotFound(w, r) + return + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, target+enginePath, nil) + if err != nil { + http.Error(w, "bad upstream request", http.StatusInternalServerError) + return + } + // frame.jpg takes width and quality; the engine re-encodes on demand. + req.URL.RawQuery = r.URL.RawQuery + if user != "" { + req.SetBasicAuth(user, pass) + } + + resp, err := client.Do(req) + if err != nil { + http.Error(w, "engine unreachable", http.StatusBadGateway) + return + } + defer resp.Body.Close() + + for _, h := range []string{"Content-Type", "Cache-Control", "Pragma", "Expires"} { + if v := resp.Header.Get(h); v != "" { + w.Header().Set(h, v) + } + } + w.WriteHeader(resp.StatusCode) + + // Copied by hand rather than with io.Copy so every chunk is flushed. An + // MJPEG stream never ends, so anything buffered waiting for a full buffer + // is a tile that stays blank forever - which is the same symptom as the + // bug this file exists to fix, and would look like it had not worked. + flusher, _ := w.(http.Flusher) + buf := make([]byte, 32*1024) + for { + n, rerr := resp.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return // webview went away + } + if flusher != nil { + flusher.Flush() + } + } + if rerr != nil { + return + } + } +} diff --git a/desktop/stream_proxy_test.go b/desktop/stream_proxy_test.go new file mode 100644 index 0000000..b44877e --- /dev/null +++ b/desktop/stream_proxy_test.go @@ -0,0 +1,296 @@ +package main + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + "time" +) + +// fakeEngine stands in for the Python engine: it demands Basic auth exactly as +// the real one does when a credential is configured, and records what it was +// asked for. +type fakeEngine struct { + *httptest.Server + gotPath string + gotUser string + gotPass string + hadAuth bool +} + +func newFakeEngine(t *testing.T, body string) *fakeEngine { + t.Helper() + f := &fakeEngine{} + f.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.gotPath = r.URL.Path + if r.URL.RawQuery != "" { + f.gotPath += "?" + r.URL.RawQuery + } + f.gotUser, f.gotPass, f.hadAuth = r.BasicAuth() + if !f.hadAuth { + w.Header().Set("WWW-Authenticate", `Basic realm="behavision"`) + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "multipart/x-mixed-replace; boundary=frame") + _, _ = io.WriteString(w, body) + })) + t.Cleanup(f.Close) + return f +} + +func startProxy(t *testing.T, engine string, user, pass string) *streamProxy { + t.Helper() + p := newStreamProxy() + if err := p.start(engine, user, pass); err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(p.stop) + return p +} + +func get(t *testing.T, url string) (int, string) { + t.Helper() + c := &http.Client{Timeout: 5 * time.Second} + resp, err := c.Get(url) + if err != nil { + t.Fatalf("get %s: %v", url, err) + } + defer resp.Body.Close() + b, _ := io.ReadAll(resp.Body) + return resp.StatusCode, string(b) +} + +// The whole point: the webview gets a URL it can actually load, and the +// password stays behind. A credential in the src is both unloadable in a +// Chromium webview and readable by anything that can see the DOM. +func TestTheCameraURLCarriesNoPassword(t *testing.T) { + engine := newFakeEngine(t, "frames") + p := startProxy(t, engine.URL, "behavision", "hunter2-the-real-one") + + u := p.urlFor("cam2", "stream.mjpeg") + if u == "" { + t.Fatal("no url while the proxy is running") + } + if strings.Contains(u, "hunter2-the-real-one") || strings.Contains(u, "behavision:") { + t.Fatalf("credential leaked into the tile URL: %s", u) + } + if !strings.HasPrefix(u, "http://127.0.0.1:") { + t.Fatalf("relay must be loopback only, got %s", u) + } +} + +func TestTheRelayAttachesTheCredentialItself(t *testing.T) { + engine := newFakeEngine(t, "frame-bytes") + p := startProxy(t, engine.URL, "behavision", "s3cret") + + code, body := get(t, p.urlFor("cam2", "stream.mjpeg")) + if code != http.StatusOK { + t.Fatalf("want 200 through the relay, got %d", code) + } + if body != "frame-bytes" { + t.Fatalf("body not relayed unchanged: %q", body) + } + if !engine.hadAuth || engine.gotUser != "behavision" || engine.gotPass != "s3cret" { + t.Fatalf("engine did not receive the credential: auth=%v user=%q", + engine.hadAuth, engine.gotUser) + } + if engine.gotPath != "/api/cameras/cam2/stream.mjpeg" { + t.Fatalf("wrong upstream path: %s", engine.gotPath) + } +} + +// The token is what keeps every other process on a shop PC from opening a live +// view of customers' faces, now that the relay itself has no password. +func TestAnotherProcessCannotGuessItsWayIn(t *testing.T) { + engine := newFakeEngine(t, "frames") + p := startProxy(t, engine.URL, "behavision", "s3cret") + addr := p.ln.Addr().String() + + for _, bad := range []string{"", "0", strings.Repeat("a", 64), "wrong-token"} { + url := fmt.Sprintf("http://%s/s/%s/cam2/stream.mjpeg", addr, bad) + if code, _ := get(t, url); code != http.StatusNotFound { + t.Fatalf("token %q got %d, want 404", bad, code) + } + } + if engine.hadAuth { + t.Fatal("a rejected request still reached the engine") + } +} + +// A camera id is interpolated into the upstream path, so it has to be a camera +// id and not a way to walk to a different endpoint with the credential +// attached. +func TestACameraIdCannotClimbOutOfItsPath(t *testing.T) { + engine := newFakeEngine(t, "frames") + p := startProxy(t, engine.URL, "behavision", "s3cret") + addr := p.ln.Addr().String() + + for _, bad := range []string{"..", "%2e%2e", "cam2/../../api/identities", "cam 2", ""} { + url := fmt.Sprintf("http://%s/s/%s/%s/stream.mjpeg", addr, p.token, bad) + code, _ := get(t, url) + if code != http.StatusNotFound { + t.Fatalf("camera id %q got %d, want 404", bad, code) + } + } + if strings.Contains(engine.gotPath, "identities") { + t.Fatalf("reached a non-camera endpoint: %s", engine.gotPath) + } +} + +// Only the two files a tile needs. The engine also serves the identity list and +// the erasure endpoint; holding the token must not open those. +func TestOnlyTheTwoCameraFilesAreReachable(t *testing.T) { + engine := newFakeEngine(t, "frames") + p := startProxy(t, engine.URL, "behavision", "s3cret") + addr := p.ln.Addr().String() + + for _, bad := range []string{"identities", "stats", "commission", "stream.mjpeg.bak"} { + url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, bad) + if code, _ := get(t, url); code != http.StatusNotFound { + t.Fatalf("file %q got %d, want 404", bad, code) + } + } + + for _, good := range []string{"stream.mjpeg", "frame.jpg"} { + url := fmt.Sprintf("http://%s/s/%s/cam2/%s", addr, p.token, good) + if code, _ := get(t, url); code != http.StatusOK { + t.Fatalf("file %q got %d, want 200", good, code) + } + } +} + +// frame.jpg takes width and quality - the engine re-encodes on demand, and a +// relay that dropped the query would silently serve full-size frames. +func TestTheQueryStringSurvivesTheRelay(t *testing.T) { + engine := newFakeEngine(t, "frames") + p := startProxy(t, engine.URL, "behavision", "s3cret") + + url := p.urlFor("cam2", "frame.jpg") + "?width=640&quality=70" + if code, _ := get(t, url); code != http.StatusOK { + t.Fatalf("got %d", code) + } + if !strings.Contains(engine.gotPath, "width=640") || + !strings.Contains(engine.gotPath, "quality=70") { + t.Fatalf("query dropped: %s", engine.gotPath) + } +} + +// An engine that is not running must read as a bad gateway, not as a hang. A +// blank tile that never resolves is the symptom this whole file exists to end. +func TestAnEngineThatIsDownFailsQuickly(t *testing.T) { + // Port 1 on loopback: nothing listens, and the connection is refused + // rather than dropped, so this is fast and deterministic. + p := startProxy(t, "http://127.0.0.1:1", "behavision", "s3cret") + + done := make(chan int, 1) + go func() { code, _ := get(t, p.urlFor("cam2", "stream.mjpeg")); done <- code }() + select { + case code := <-done: + if code != http.StatusBadGateway { + t.Fatalf("want 502, got %d", code) + } + case <-time.After(8 * time.Second): + t.Fatal("a dead engine left the request hanging") + } +} + +// Stopping must actually free the port, or a restarted engine leaves listeners +// behind for the life of the process. +func TestStoppingReleasesEverything(t *testing.T) { + engine := newFakeEngine(t, "frames") + p := newStreamProxy() + if err := p.start(engine.URL, "u", "p"); err != nil { + t.Fatalf("start: %v", err) + } + url := p.urlFor("cam2", "stream.mjpeg") + if code, _ := get(t, url); code != http.StatusOK { + t.Fatalf("want 200 before stop, got %d", code) + } + + p.stop() + + if got := p.urlFor("cam2", "stream.mjpeg"); got != "" { + t.Fatalf("still handing out URLs after stop: %s", got) + } + c := &http.Client{Timeout: 3 * time.Second} + if resp, err := c.Get(url); err == nil { + resp.Body.Close() + t.Fatal("listener still accepting after stop") + } +} + +// start twice must not leave two listeners, which is what a restarted engine +// would otherwise cause. +func TestStartingTwiceIsANoOp(t *testing.T) { + engine := newFakeEngine(t, "frames") + p := startProxy(t, engine.URL, "u", "p") + + first := p.urlFor("cam2", "stream.mjpeg") + if err := p.start(engine.URL, "u", "p"); err != nil { + t.Fatalf("second start: %v", err) + } + if second := p.urlFor("cam2", "stream.mjpeg"); second != first { + t.Fatalf("second start moved the relay: %s -> %s", first, second) + } +} + +// Against the real engine, which the unit tests above deliberately do not +// touch. Skipped unless TEST_ENGINE_URL is set, the same rule the server's +// live store tests follow: the suite must stay runnable with no services. +// +// TEST_ENGINE_URL=http://127.0.0.1:8010 \ +// TEST_ENGINE_USER=... TEST_ENGINE_PASS=... go test ./desktop/ -run Live +// +// It exists because everything above proves the relay against a fake that +// agrees with me. Only a real engine proves the thing that was actually +// broken: that a multipart MJPEG stream arrives through the relay in pieces, +// rather than being buffered into a tile that never paints. +func TestLiveRelayCarriesRealMJPEGFrames(t *testing.T) { + base := os.Getenv("TEST_ENGINE_URL") + if base == "" { + t.Skip("set TEST_ENGINE_URL to run the live relay test") + } + cam := os.Getenv("TEST_ENGINE_CAMERA") + if cam == "" { + cam = "cam2" + } + p := startProxy(t, base, os.Getenv("TEST_ENGINE_USER"), os.Getenv("TEST_ENGINE_PASS")) + + url := p.urlFor(cam, "stream.mjpeg") + req, _ := http.NewRequest(http.MethodGet, url, nil) + resp, err := (&http.Client{}).Do(req) + if err != nil { + t.Fatalf("relay: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("relay returned %d - the credential did not reach the engine", resp.StatusCode) + } + if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "multipart") { + t.Fatalf("not a stream: Content-Type %q", ct) + } + + // Read until two JPEG start markers have gone past. One proves it opened; + // two prove it is still delivering, which is the difference between a + // working tile and a single frozen frame. + deadline := time.Now().Add(15 * time.Second) + var seen, total int + buf := make([]byte, 16*1024) + for seen < 2 && time.Now().Before(deadline) { + n, rerr := resp.Body.Read(buf) + total += n + seen += strings.Count(string(buf[:n]), "\xff\xd8\xff") + if rerr != nil { + break + } + } + if seen < 2 { + t.Fatalf("only %d JPEG frames in %d bytes - the relay is not streaming", seen, total) + } + t.Logf("relayed %d frames in %d bytes with no credential in the URL", seen, total) +}