Files
Behavision/desktop/stream_proxy.go
Suriyakumarvijayanayagam 48a30d97db Live camera view in the app, and the green light that was lying about it
Two changes, and the second was found by verifying the first.

## Watching a camera from the app, in another building

Snapshots answer "is that camera working". They do not answer "what is
happening in my shop right now", which is what somebody who opens the app away
from the counter is asking. Head office's browser already had that answer -
LiveHub plus cameras.Live, where the shop PC asks outbound whether anybody is
watching and pushes JPEG frames for as long as somebody is - and the app could
not reach it.

cloud.CameraLive opens that feed and the app's own loopback relay re-emits it
as multipart MJPEG. That is the trick: frames arrive base64 over SSE, an <img>
cannot render that, and an <img> renders MJPEG natively - so a tile is an
ordinary <img> pointed at loopback whether the camera is in this room or
another city.

- Reconnecting happens in the relay, not the page. The server caps one push at
  five minutes, so doing it here means the <img> never sees the stream end.
- The headers are flushed before the first frame. Go writes them on the first
  body write, so without that the whole response waits for the shop PC to
  start pushing. Measured against production: 30 seconds and not even a
  Content-Type, which surfaces as the request timing out.
- One camera at a time. Watching makes a shop PC upload, so a grid that went
  live at once would put an estate's worth of cameras on the wire because
  somebody opened a page.
- live.mjpeg is behind the same per-run token as the engine routes, and a
  wrong token is a 404 that never reaches head office at all.
- CameraLive uses its own HTTP client: the shared one's 30s timeout covers the
  whole response and would sever a working view every thirty seconds - the
  trap that made the server set WriteTimeout to zero for its own SSE endpoint.

## A camera read "Connected" for 34 minutes after the shop PC went blind

Which is why the verification above looked like a failure: head office
registered the viewer and no frame ever came.

reportWith returns early when the engine is unreachable - correctly, it has
nothing to say - so the last state it sent stays in the database looking
current. Measured live: cam2 and entrance both reading Connected, in green,
with last_seen_at 34 minutes old, while the heartbeat from the same PC said
cameras_up 0 of 0. Two surfaces reading two stored fields and disagreeing.

false could not be the answer. It means "this camera is not connecting", which
sends an installer to check cabling on a camera that was working perfectly the
last time anybody could ask it. So there are four states and one function:

  connected       reported recently, and working
  not_connecting  reported recently, and the stream will not open
  waiting         no shop PC has ever reported this camera
  stale           reported once, and not lately

- Connected is CLEARED when stale or waiting. A stale true left in place stays
  available to every client reading the field directly, and leaves two fields
  on one object disagreeing - how the shops screen once came out labelled
  Working, in green, above "2 of 3 cameras not connecting".
- Computed in scanCamera, so every camera anybody reads passes through it. A
  state computed per handler is one a handler forgets, and this had already
  reached three screens.
- CameraStaleAfter is 5 minutes: five missed reports, not one. Same reasoning
  as three missed heartbeats - an indicator that cries wolf gets ignored.
- An unparseable last_seen_at is stale. It should be impossible, which is why
  it must not fall through to the state that says everything is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-30 16:48:43 +05:30

298 lines
10 KiB
Go

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/<id>/stream.mjpeg and hand it
// to an <img>, with a comment saying the credentials were inline "so an <img>
// 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 <img> 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 <img> equally cannot carry a
// session and the bytes are fetched and handed over as an object URL.
import (
"context"
"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
// Opens head office's live relay for one camera. Set on a computer that
// is signed in, whether or not an engine runs here - which is the whole
// point: watching a camera in another building is precisely the case
// where there is no engine on this machine to ask.
live func(ctx context.Context, cameraID string) (*http.Response, error)
}
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 {
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)
}
if err := p.bind(); err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
p.target = strings.TrimRight(base, "/")
p.user, p.pass = user, pass
return nil
}
// watchRemote makes the relay able to serve head office's live view, and
// binds it if nothing else has.
//
// Separate from start() because the two are independent: a shop PC has both
// an engine and a session, an owner's laptop has only a session, and a PC
// still being set up has only an engine. Folding them together would mean a
// computer with no engine could not watch a camera at all - which is the one
// computer most likely to be trying to.
func (p *streamProxy) watchRemote(fn func(context.Context, string) (*http.Response, error)) error {
if err := p.bind(); err != nil {
return err
}
p.mu.Lock()
defer p.mu.Unlock()
p.live = fn
return nil
}
// bind starts the loopback listener once. Calling it again while running is a
// no-op, so neither a restarted engine nor a second sign-in can leave two
// listeners behind.
func (p *streamProxy) bind() error {
p.mu.Lock()
defer p.mu.Unlock()
if p.srv != nil {
return nil
}
// 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)
// 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, p.live = nil, 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
liveFn := p.live
p.mu.RUnlock()
if token == "" || client == nil {
http.NotFound(w, r)
return
}
// /s/<token>/<camera>/<file>
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.
// Head office's relay, not the engine. The two are different machines and
// different credentials, so this returns rather than falling through.
if parts[3] == "live.mjpeg" {
if liveFn == nil {
http.Error(w, "not signed in to head office", http.StatusBadGateway)
return
}
p.relayRemote(w, r, cameraID, liveFn)
return
}
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
}
}
}