av-test: generic page and bounded numeric-only receipts
This commit is contained in:
parent
1e06701d69
commit
dd394b3eb3
@ -1,4 +1,4 @@
|
|||||||
# Static assets only; no API, uploads, credentials or outbound connections.
|
# Static page plus numeric-only receipts; no media, installed credentials or egress.
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: Deployment
|
kind: Deployment
|
||||||
metadata:
|
metadata:
|
||||||
@ -36,14 +36,30 @@ spec:
|
|||||||
requests: {cpu: 10m, memory: 16Mi}
|
requests: {cpu: 10m, memory: 16Mi}
|
||||||
limits: {cpu: 100m, memory: 64Mi}
|
limits: {cpu: 100m, memory: 64Mi}
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet: {path: /lesavka-av-test/, port: http}
|
httpGet: {path: /av-test/, port: http}
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet: {path: /lesavka-av-test/, port: http}
|
httpGet: {path: /av-test/, port: http}
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 10
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- {name: config, mountPath: /etc/nginx/observer, readOnly: true}
|
- {name: config, mountPath: /etc/nginx/observer, readOnly: true}
|
||||||
- {name: assets, mountPath: /usr/share/nginx/html/lesavka-av-test, readOnly: true}
|
- {name: assets, mountPath: /usr/share/nginx/html/av-test, readOnly: true}
|
||||||
- {name: tmp, mountPath: /tmp}
|
- {name: tmp, mountPath: /tmp}
|
||||||
|
- name: receipts
|
||||||
|
image: python:3.13-slim@sha256:9662417aace5ae7b8e2609cce472b72a8958e134ba372808abe9cc1a0c0125e6
|
||||||
|
command: [python, -u, -B, /app/server.py]
|
||||||
|
securityContext:
|
||||||
|
allowPrivilegeEscalation: false
|
||||||
|
readOnlyRootFilesystem: true
|
||||||
|
capabilities: {drop: [ALL]}
|
||||||
|
resources:
|
||||||
|
requests: {cpu: 10m, memory: 16Mi}
|
||||||
|
limits: {cpu: 100m, memory: 64Mi}
|
||||||
|
readinessProbe:
|
||||||
|
exec:
|
||||||
|
command: [python, -c, "import socket; socket.create_connection(('127.0.0.1', 8081), 2).close()"]
|
||||||
|
periodSeconds: 10
|
||||||
|
volumeMounts:
|
||||||
|
- {name: receipts, mountPath: /app, readOnly: true}
|
||||||
volumes:
|
volumes:
|
||||||
- name: config
|
- name: config
|
||||||
configMap: {name: lesavka-av-observer-nginx}
|
configMap: {name: lesavka-av-observer-nginx}
|
||||||
@ -51,3 +67,5 @@ spec:
|
|||||||
configMap: {name: lesavka-av-observer-assets}
|
configMap: {name: lesavka-av-observer-assets}
|
||||||
- name: tmp
|
- name: tmp
|
||||||
emptyDir: {medium: Memory, sizeLimit: 8Mi}
|
emptyDir: {medium: Memory, sizeLimit: 8Mi}
|
||||||
|
- name: receipts
|
||||||
|
configMap: {name: lesavka-av-observer-receipts}
|
||||||
|
|||||||
@ -1,18 +1,19 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
<html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width">
|
||||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'none'; media-src 'self' blob:; style-src 'self'; object-src 'none'; base-uri 'none'">
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; connect-src 'self'; media-src 'self' blob:; style-src 'self'; object-src 'none'; base-uri 'none'">
|
||||||
<title>Lesavka — bundled A/V observer</title>
|
<title>A/V Test</title>
|
||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
<main><h1>Bundled A/V observer</h1>
|
<main><h1>A/V Test</h1>
|
||||||
<p>No recording, uploads or saved camera/microphone content. Select the RCT's Lesavka video and microphone devices.</p>
|
<p>Select a camera and microphone. No recording or media uploads. Numeric timing/activity metrics and test status are reported automatically.</p>
|
||||||
<div><button id="devices">Choose devices…</button>
|
<div><button id="devices">Choose devices…</button>
|
||||||
<label>Video <select id="video-device"></select></label>
|
<label>Video <select id="video-device"></select></label>
|
||||||
<label>Microphone <select id="audio-device"></select></label></div>
|
<label>Microphone <select id="audio-device"></select></label></div>
|
||||||
<div><button id="start" disabled>Start 2-minute check</button><button id="stop" disabled>Stop</button><button id="export" disabled>Save metrics</button></div>
|
<div><button id="start" disabled>Start 2-minute check</button><button id="stop" disabled>Stop</button><button id="export" disabled>Save metrics</button></div>
|
||||||
<p id="status" role="status">Stopped</p>
|
<p id="status" role="status">Stopped</p>
|
||||||
|
<p id="report-status" role="status">Reporting starts with the test.</p>
|
||||||
<video id="preview" autoplay muted playsinline></video>
|
<video id="preview" autoplay muted playsinline></video>
|
||||||
<pre id="metrics">No observations.</pre>
|
<pre id="metrics">No observations.</pre>
|
||||||
<p>Sync and capture-to-RCT freshness: <strong>not measured by this observer</strong>.
|
<p>Audio/video sync and end-to-end delay: <strong>not measured by this check</strong>.
|
||||||
Video callback gaps can include browser scheduling. Silence is not proof of audio loss.
|
Video callback gaps can include browser scheduling. Silence is not proof of audio loss.
|
||||||
Use the qualified paired-marker/clock-bounded probe for release acceptance.</p>
|
A separate paired-signal measurement is needed to verify synchronization.</p>
|
||||||
</main><script type="module" src="observer.js"></script></html>
|
</main><script type="module" src="observer.js"></script></html>
|
||||||
|
|||||||
@ -15,6 +15,12 @@ spec:
|
|||||||
- host: bstein.dev
|
- host: bstein.dev
|
||||||
http:
|
http:
|
||||||
paths:
|
paths:
|
||||||
|
- path: /av-test
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: lesavka-av-observer
|
||||||
|
port: {number: 80}
|
||||||
- path: /lesavka-av-test
|
- path: /lesavka-av-test
|
||||||
pathType: Prefix
|
pathType: Prefix
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@ -13,5 +13,11 @@ configMapGenerator:
|
|||||||
- index.html
|
- index.html
|
||||||
- observer.js
|
- observer.js
|
||||||
- metrics.js
|
- metrics.js
|
||||||
|
- reporting.js
|
||||||
- activity-worklet.js
|
- activity-worklet.js
|
||||||
- style.css
|
- style.css
|
||||||
|
- name: lesavka-av-observer-receipts
|
||||||
|
files:
|
||||||
|
- report-service/server.py
|
||||||
|
- report-service/receipts.py
|
||||||
|
- report-service/validation.py
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export class VideoMetrics {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function receipt(video,audio,settings,elapsedMs,reason) {
|
export function receipt(video,audio,settings,elapsedMs,reason) {
|
||||||
return {schema:"lesavka.rct-av-observer.v1",kind:"diagnostic-only",reason,elapsedMs,
|
return {schema:"av-test.observer.v2",kind:"diagnostic-only",reason,elapsedMs,
|
||||||
settings,video:video.summary(),audio,
|
settings,video:video.summary(),audio,
|
||||||
syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
|
syncMeasured:false,freshnessMeasured:false,artifactsMeasured:false,
|
||||||
releaseEligible:false,recordedMedia:false};
|
releaseEligible:false,recordedMedia:false};
|
||||||
|
|||||||
@ -7,7 +7,9 @@ http {
|
|||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
access_log off;
|
access_log off;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
client_max_body_size 1k;
|
client_max_body_size 4k;
|
||||||
|
client_body_timeout 5s;
|
||||||
|
client_header_timeout 5s;
|
||||||
client_body_temp_path /tmp/client;
|
client_body_temp_path /tmp/client;
|
||||||
proxy_temp_path /tmp/proxy;
|
proxy_temp_path /tmp/proxy;
|
||||||
fastcgi_temp_path /tmp/fastcgi;
|
fastcgi_temp_path /tmp/fastcgi;
|
||||||
@ -17,19 +19,37 @@ http {
|
|||||||
listen 8080;
|
listen 8080;
|
||||||
absolute_redirect off;
|
absolute_redirect off;
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
|
||||||
add_header Cache-Control "no-store" always;
|
add_header Cache-Control "no-store" always;
|
||||||
add_header X-Content-Type-Options "nosniff" always;
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
add_header X-Frame-Options "DENY" always;
|
add_header X-Frame-Options "DENY" always;
|
||||||
add_header Referrer-Policy "no-referrer" always;
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), display-capture=()" always;
|
add_header Permissions-Policy "camera=(self), microphone=(self), geolocation=(), display-capture=()" always;
|
||||||
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
add_header Cross-Origin-Opener-Policy "same-origin" always;
|
||||||
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
|
add_header Content-Security-Policy "default-src 'none'; script-src 'self'; style-src 'self'; media-src 'self' blob:; connect-src 'self'; worker-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'" always;
|
||||||
location = /lesavka-av-test { return 308 /lesavka-av-test/; }
|
location = /lesavka-av-test { return 308 /av-test/; }
|
||||||
location = /lesavka-av-test/ { try_files /lesavka-av-test/index.html =404; }
|
location = /lesavka-av-test/ { return 308 /av-test/; }
|
||||||
location ~ ^/lesavka-av-test/(index\.html|observer\.js|metrics\.js|activity-worklet\.js|style\.css)$ {
|
location = /lesavka-av-test/index.html { return 308 /av-test/; }
|
||||||
|
location = /av-test { return 308 /av-test/; }
|
||||||
|
location = /av-test/ {
|
||||||
|
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
||||||
|
try_files /av-test/index.html =404;
|
||||||
|
}
|
||||||
|
location ~ ^/av-test/(index\.html|observer\.js|metrics\.js|reporting\.js|activity-worklet\.js|style\.css)$ {
|
||||||
|
if ($request_method !~ ^(GET|HEAD)$) { return 405; }
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
}
|
}
|
||||||
|
location ~ ^/av-test/api/(start|report)$ {
|
||||||
|
if ($request_method != POST) { return 405; }
|
||||||
|
proxy_pass http://127.0.0.1:8081;
|
||||||
|
proxy_connect_timeout 2s;
|
||||||
|
proxy_read_timeout 5s;
|
||||||
|
proxy_send_timeout 5s;
|
||||||
|
proxy_pass_request_headers off;
|
||||||
|
proxy_set_header Content-Type $http_content_type;
|
||||||
|
proxy_set_header Origin $http_origin;
|
||||||
|
proxy_set_header Authorization $http_authorization;
|
||||||
|
proxy_set_header Host localhost;
|
||||||
|
}
|
||||||
location / { return 404; }
|
location / { return 404; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {VideoMetrics,receipt} from './metrics.js';
|
import {VideoMetrics,receipt} from './metrics.js';
|
||||||
|
import {RunReporter} from './reporting.js';
|
||||||
const el=id=>document.getElementById(id);
|
const el=id=>document.getElementById(id);
|
||||||
let generation=0, current=null, last=null;
|
let generation=0, current=null, last=null, reportingRun=null;
|
||||||
const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop());
|
const stopTracks=stream=>stream?.getTracks().forEach(track=>track.stop());
|
||||||
function release(run) {
|
function release(run) {
|
||||||
if(run.released)return;
|
if(run.released)return;
|
||||||
@ -18,6 +19,7 @@ function stop(reason='operator') {
|
|||||||
if(run) {
|
if(run) {
|
||||||
release(run);
|
release(run);
|
||||||
last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason);
|
last=receipt(run.video,run.audio,run.settings,performance.now()-run.started,reason);
|
||||||
|
run.reporter.finish(last);
|
||||||
el('metrics').textContent=JSON.stringify(last,null,2);
|
el('metrics').textContent=JSON.stringify(last,null,2);
|
||||||
el('export').disabled=false;
|
el('export').disabled=false;
|
||||||
}
|
}
|
||||||
@ -40,11 +42,11 @@ el('devices').onclick=async()=>{
|
|||||||
if(generation!==ticket)return;
|
if(generation!==ticket)return;
|
||||||
for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) {
|
for(const [id,kind] of [['video-device','videoinput'],['audio-device','audioinput']]) {
|
||||||
const select=el(id), saved=select.value;
|
const select=el(id), saved=select.value;
|
||||||
select.replaceChildren(new Option('Select Lesavka device',''));
|
select.replaceChildren(new Option('Select a device',''));
|
||||||
for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId));
|
for(const d of devices.filter(d=>d.kind===kind)) select.add(new Option(d.label||'Unnamed device',d.deviceId));
|
||||||
select.value=saved;
|
select.value=saved;
|
||||||
}
|
}
|
||||||
el('status').textContent='Select both Lesavka devices, then Start.';
|
el('status').textContent='Select a camera and microphone, then Start.';
|
||||||
el('devices').disabled=false; el('stop').disabled=true;
|
el('devices').disabled=false; el('stop').disabled=true;
|
||||||
} catch { if(generation===ticket)stop('device permission unavailable'); }
|
} catch { if(generation===ticket)stop('device permission unavailable'); }
|
||||||
finally {clearTimeout(timer);}
|
finally {clearTimeout(timer);}
|
||||||
@ -61,6 +63,10 @@ el('start').onclick=async()=>{
|
|||||||
const ticket=++generation;
|
const ticket=++generation;
|
||||||
const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}};
|
const run={started:performance.now(),video:new VideoMetrics(),audio:null,settings:{}};
|
||||||
current=run; last=null;
|
current=run; last=null;
|
||||||
|
reportingRun=run;
|
||||||
|
run.reporter=new RunReporter(message=>{
|
||||||
|
if(reportingRun===run)el('report-status').textContent=message;
|
||||||
|
});
|
||||||
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
|
el('start').disabled=true; el('devices').disabled=true; el('stop').disabled=false; el('export').disabled=true;
|
||||||
el('status').textContent='Starting…';
|
el('status').textContent='Starting…';
|
||||||
run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
|
run.setupTimer=setTimeout(()=>{if(current===run)stop('setup timeout');},20000);
|
||||||
@ -84,7 +90,9 @@ el('start').onclick=async()=>{
|
|||||||
run.node.port.onmessage=({data})=>{
|
run.node.port.onmessage=({data})=>{
|
||||||
if(current!==run)return;
|
if(current!==run)return;
|
||||||
run.audio=data; run.node.port.postMessage('ack');
|
run.audio=data; run.node.port.postMessage('ack');
|
||||||
el('metrics').textContent=JSON.stringify(receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing'),null,2);
|
const sample=receipt(run.video,run.audio,run.settings,performance.now()-run.started,'observing');
|
||||||
|
el('metrics').textContent=JSON.stringify(sample,null,2);
|
||||||
|
if(run.observing)run.reporter.observe(sample);
|
||||||
};
|
};
|
||||||
run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks()));
|
run.source=run.context.createMediaStreamSource(new MediaStream(stream.getAudioTracks()));
|
||||||
run.source.connect(run.node);run.node.connect(run.context.destination);
|
run.source.connect(run.node);run.node.connect(run.context.destination);
|
||||||
@ -95,6 +103,7 @@ el('start').onclick=async()=>{
|
|||||||
if(generation!==ticket)return;
|
if(generation!==ticket)return;
|
||||||
clearTimeout(run.setupTimer);
|
clearTimeout(run.setupTimer);
|
||||||
run.started=performance.now();
|
run.started=performance.now();
|
||||||
|
run.observing=true;
|
||||||
function frame(now,metadata) {
|
function frame(now,metadata) {
|
||||||
if(current!==run)return;
|
if(current!==run)return;
|
||||||
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
|
run.video.observe(metadata.expectedDisplayTime,metadata.presentedFrames);
|
||||||
@ -110,7 +119,7 @@ el('stop').onclick=()=>stop();
|
|||||||
el('export').onclick=()=>{
|
el('export').onclick=()=>{
|
||||||
if(!last)return;
|
if(!last)return;
|
||||||
const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'}));
|
const url=URL.createObjectURL(new Blob([JSON.stringify(last,null,2)],{type:'application/json'}));
|
||||||
const link=document.createElement('a');link.href=url;link.download='lesavka-av-observer.json';link.click();
|
const link=document.createElement('a');link.href=url;link.download='av-test-metrics.json';link.click();
|
||||||
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
setTimeout(()=>URL.revokeObjectURL(url),1000);
|
||||||
};
|
};
|
||||||
window.addEventListener('pagehide',()=>stop('page closed'));
|
window.addEventListener('pagehide',()=>stop('page closed'));
|
||||||
|
|||||||
@ -0,0 +1,68 @@
|
|||||||
|
"""Bounded ephemeral write capabilities; validated numeric receipts go to stdout.
|
||||||
|
|
||||||
|
These are unverified browser diagnostics, never release-gate attestations.
|
||||||
|
No public read API, database, cookies, media, device identities or credentials.
|
||||||
|
"""
|
||||||
|
from collections import deque
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
from validation import validate_report
|
||||||
|
|
||||||
|
|
||||||
|
class Refused(Exception):
|
||||||
|
def __init__(self, status):
|
||||||
|
self.status = status
|
||||||
|
|
||||||
|
|
||||||
|
class ReceiptStore:
|
||||||
|
def __init__(self, emit, clock=time.monotonic, wall=time.time):
|
||||||
|
self.emit, self.clock, self.wall = emit, clock, wall
|
||||||
|
self.runs, self.starts = {}, deque()
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
with self.lock:
|
||||||
|
now = self.clock()
|
||||||
|
self.runs = {key: value for key, value in self.runs.items() if value["expires"] > now}
|
||||||
|
while self.starts and now-self.starts[0] >= 60:
|
||||||
|
self.starts.popleft()
|
||||||
|
if len(self.starts) >= 12 or len(self.runs) >= 64:
|
||||||
|
raise Refused(429)
|
||||||
|
run_id, token = secrets.token_hex(16), secrets.token_urlsafe(32)
|
||||||
|
self.runs[run_id] = {"token": hashlib.sha256(token.encode()).digest(), "expires": now+600,
|
||||||
|
"sequence": 0, "digest": None, "terminal": False, "last_progress": None}
|
||||||
|
self.starts.append(now)
|
||||||
|
self.log({"run_id": run_id, "sequence": 0, "state": "starting", "metrics": None})
|
||||||
|
return {"run_id": run_id, "write_token": token}
|
||||||
|
|
||||||
|
def report(self, body, token):
|
||||||
|
data = validate_report(body)
|
||||||
|
if type(token) is not str or len(token) != 43:
|
||||||
|
raise Refused(401)
|
||||||
|
with self.lock:
|
||||||
|
now = self.clock()
|
||||||
|
run = self.runs.get(data["run_id"])
|
||||||
|
if not run or run["expires"] <= now or not secrets.compare_digest(
|
||||||
|
run["token"], hashlib.sha256(token.encode()).digest()):
|
||||||
|
raise Refused(401)
|
||||||
|
digest = hashlib.sha256(json.dumps(data, sort_keys=True, allow_nan=False).encode()).digest()
|
||||||
|
if data["sequence"] == run["sequence"] and run["digest"] == digest:
|
||||||
|
return # Idempotent network retry, no duplicate log entry.
|
||||||
|
if run["terminal"] or data["sequence"] <= run["sequence"]:
|
||||||
|
raise Refused(409)
|
||||||
|
if data["state"] == "running" and run["last_progress"] is not None and now-run["last_progress"] < 8:
|
||||||
|
raise Refused(429)
|
||||||
|
self.log(data)
|
||||||
|
run.update(sequence=data["sequence"], digest=digest, terminal=data["state"] != "running")
|
||||||
|
if data["state"] == "running":
|
||||||
|
run["last_progress"] = now
|
||||||
|
else:
|
||||||
|
run["expires"] = now+60 # Retain briefly for idempotent final retry.
|
||||||
|
|
||||||
|
def log(self, data):
|
||||||
|
self.emit({"schema": "av-test.receipt.v1", "kind": "unverified-browser-diagnostic",
|
||||||
|
"received_unix_ms": int(self.wall()*1000), "release_eligible": False, **data})
|
||||||
103
services/bstein-dev-home/av-observer/report-service/server.py
Normal file
103
services/bstein-dev-home/av-observer/report-service/server.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Loopback-only aggregate receipt service behind the site's exact-path proxy."""
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from socketserver import ThreadingMixIn
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from receipts import ReceiptStore, Refused
|
||||||
|
from validation import no_duplicate_fields
|
||||||
|
|
||||||
|
|
||||||
|
MAX_BODY = 4096
|
||||||
|
|
||||||
|
|
||||||
|
class Handler(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *_args):
|
||||||
|
pass # Never log request paths, headers, tokens, bodies or errors.
|
||||||
|
|
||||||
|
def reply(self, status, data=None):
|
||||||
|
body = json.dumps(data, allow_nan=False).encode() if data is not None else b""
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Cache-Control", "no-store")
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
if body:
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
self.reply(405)
|
||||||
|
|
||||||
|
do_HEAD = do_GET
|
||||||
|
do_OPTIONS = do_GET
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
try:
|
||||||
|
if self.path not in ("/av-test/api/start", "/av-test/api/report"):
|
||||||
|
return self.reply(404)
|
||||||
|
if any(len(self.headers.get_all(name, [])) > 1 for name in ("Origin", "Authorization", "Content-Type")):
|
||||||
|
return self.reply(400)
|
||||||
|
if self.headers.get("Origin") != self.server.origin:
|
||||||
|
return self.reply(403)
|
||||||
|
if self.headers.get("Content-Type") != "application/json" or self.headers.get("Transfer-Encoding"):
|
||||||
|
return self.reply(415)
|
||||||
|
sizes = self.headers.get_all("Content-Length", [])
|
||||||
|
if len(sizes) != 1 or not sizes[0].isdigit() or not 0 < int(sizes[0]) <= MAX_BODY:
|
||||||
|
return self.reply(413)
|
||||||
|
raw = self.rfile.read(int(sizes[0]))
|
||||||
|
if len(raw) != int(sizes[0]):
|
||||||
|
return self.reply(400)
|
||||||
|
data = json.loads(raw, object_pairs_hook=no_duplicate_fields)
|
||||||
|
if self.path == "/av-test/api/start":
|
||||||
|
if data != {}:
|
||||||
|
return self.reply(400)
|
||||||
|
return self.reply(201, self.server.store.start())
|
||||||
|
authorization = self.headers.get("Authorization", "")
|
||||||
|
token = authorization[7:] if authorization.startswith("Bearer ") else ""
|
||||||
|
self.server.store.report(data, token)
|
||||||
|
self.reply(204)
|
||||||
|
except Refused as error:
|
||||||
|
self.reply(error.status)
|
||||||
|
except (ValueError, UnicodeError, RecursionError, TypeError):
|
||||||
|
self.reply(400)
|
||||||
|
except (OSError, TimeoutError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Server(ThreadingMixIn, HTTPServer):
|
||||||
|
daemon_threads = True
|
||||||
|
def __init__(self, address, origin, store):
|
||||||
|
self.origin, self.store = origin, store
|
||||||
|
self.workers = threading.BoundedSemaphore(4)
|
||||||
|
super().__init__(address, Handler)
|
||||||
|
|
||||||
|
def get_request(self):
|
||||||
|
connection, address = super().get_request()
|
||||||
|
connection.settimeout(3)
|
||||||
|
return connection, address
|
||||||
|
|
||||||
|
def process_request(self, request, client_address):
|
||||||
|
if not self.workers.acquire(blocking=False):
|
||||||
|
self.shutdown_request(request)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
super().process_request(request, client_address)
|
||||||
|
except Exception:
|
||||||
|
self.workers.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def process_request_thread(self, request, client_address):
|
||||||
|
try:
|
||||||
|
super().process_request_thread(request, client_address)
|
||||||
|
finally:
|
||||||
|
self.workers.release()
|
||||||
|
|
||||||
|
def handle_error(self, *_args):
|
||||||
|
pass # No raw exception/request logging, including unexpected input.
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
store = ReceiptStore(lambda entry: print(json.dumps(entry, allow_nan=False), flush=True))
|
||||||
|
Server(("127.0.0.1", 8081), os.environ.get("OBSERVER_ORIGIN", "https://bstein.dev"), store).serve_forever()
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
"""Strict aggregate-only receipt schema: unknown fields and text are refused."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
STATES = {"running", "completed", "hidden", "closed", "device-change", "stopped", "failed"}
|
||||||
|
BOUNDS = {
|
||||||
|
"elapsed_ms": 180_000,
|
||||||
|
"video_width": 8192, "video_height": 8192, "video_fps": 240,
|
||||||
|
"audio_rate": 384_000, "audio_channels": 32, "audio_context_rate": 384_000,
|
||||||
|
"video_callbacks": 100_000, "video_unobserved": 100_000, "video_resets": 100_000,
|
||||||
|
"video_interval_p95_ms": 180_000, "video_gap_max_ms": 180_000,
|
||||||
|
"audio_input_frames": 100_000_000, "audio_processed_frames": 100_000_000,
|
||||||
|
"audio_zero_frames": 100_000_000, "audio_rms": 32, "audio_peak": 32,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def no_duplicate_fields(pairs):
|
||||||
|
result = {}
|
||||||
|
for key, value in pairs:
|
||||||
|
if key in result:
|
||||||
|
raise ValueError("duplicate field")
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validate_report(data):
|
||||||
|
"""Return a rebuilt, bounded numeric receipt, never caller-owned text."""
|
||||||
|
if type(data) is not dict or set(data) != {"run_id", "sequence", "state", "metrics"}:
|
||||||
|
raise ValueError("report shape")
|
||||||
|
run_id, sequence, state = data["run_id"], data["sequence"], data["state"]
|
||||||
|
if type(run_id) is not str or len(run_id) != 32 or any(c not in "0123456789abcdef" for c in run_id):
|
||||||
|
raise ValueError("run id")
|
||||||
|
if type(sequence) is not int or not 1 <= sequence <= 64:
|
||||||
|
raise ValueError("sequence")
|
||||||
|
if type(state) is not str or state not in STATES:
|
||||||
|
raise ValueError("state")
|
||||||
|
metrics = data["metrics"]
|
||||||
|
if type(metrics) is not dict or metrics.keys() != BOUNDS.keys():
|
||||||
|
raise ValueError("metric fields")
|
||||||
|
clean = {}
|
||||||
|
for name, limit in BOUNDS.items():
|
||||||
|
value = metrics[name]
|
||||||
|
if value is not None and (type(value) not in (int, float) or not 0 <= value <= limit or not math.isfinite(value)):
|
||||||
|
raise ValueError("metric value")
|
||||||
|
clean[name] = value
|
||||||
|
if clean["elapsed_ms"] is None:
|
||||||
|
raise ValueError("elapsed missing")
|
||||||
|
return {"run_id": run_id, "sequence": sequence, "state": state, "metrics": clean}
|
||||||
91
services/bstein-dev-home/av-observer/reporting.js
Normal file
91
services/bstein-dev-home/av-observer/reporting.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Only the explicit numeric projection below can leave the browser. Never
|
||||||
|
// pass a receipt, device object, error string, frame, PCM or DOM value to fetch.
|
||||||
|
export function reportMetrics(receipt) {
|
||||||
|
const finite=value=>typeof value==='number'&&Number.isFinite(value)&&value>=0 ? value : null;
|
||||||
|
const v=receipt.video??{},a=receipt.audio??{},s=receipt.settings??{};
|
||||||
|
return Object.fromEntries(Object.entries({
|
||||||
|
elapsed_ms:receipt.elapsedMs,
|
||||||
|
video_width:s.video?.width,video_height:s.video?.height,video_fps:s.video?.frameRate,
|
||||||
|
audio_rate:s.audio?.sampleRate,audio_channels:s.audio?.channelCount,
|
||||||
|
video_callbacks:v.callbacks,video_unobserved:v.unobservedPresentedFrames,
|
||||||
|
video_resets:v.clockOrCounterResets,video_interval_p95_ms:v.callbackIntervalP95Ms,
|
||||||
|
video_gap_max_ms:v.callbackGapMaxMs,audio_input_frames:a.inputFrames,
|
||||||
|
audio_processed_frames:a.processedFrames,audio_zero_frames:a.exactZeroFrames,
|
||||||
|
audio_rms:a.rms,audio_peak:a.peak,audio_context_rate:a.sampleRate,
|
||||||
|
}).map(([key,value])=>[key,finite(value)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopState(reason) {
|
||||||
|
if(reason==='120 seconds complete')return 'completed';
|
||||||
|
if(reason==='page hidden; timing invalid')return 'hidden';
|
||||||
|
if(reason==='page closed')return 'closed';
|
||||||
|
if(reason==='device ended'||reason==='device selection changed')return 'device-change';
|
||||||
|
if(reason==='operator'||reason==='selecting devices')return 'stopped';
|
||||||
|
return 'failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RunReporter {
|
||||||
|
constructor(status,request=globalThis.fetch.bind(globalThis)) {
|
||||||
|
this.status=status;this.request=request;this.pending=null;this.busy=false;
|
||||||
|
this.terminal=false;this.sequence=0;this.lastProgress=-Infinity;this.available=true;
|
||||||
|
status('Reporting: connecting…');
|
||||||
|
this.ready=this.open();
|
||||||
|
}
|
||||||
|
async open() {
|
||||||
|
try {
|
||||||
|
const r=await this.request('/av-test/api/start',{method:'POST',credentials:'omit',
|
||||||
|
headers:{'Content-Type':'application/json'},body:'{}',signal:AbortSignal.timeout(5000)});
|
||||||
|
if(!r.ok)throw new Error('start rejected');
|
||||||
|
const data=await r.json();
|
||||||
|
if(!/^[a-f0-9]{32}$/.test(data.run_id)||!/^[A-Za-z0-9_-]{43}$/.test(data.write_token))throw new Error('bad receipt');
|
||||||
|
this.runId=data.run_id;this.token=data.write_token;
|
||||||
|
this.status(`Reporting enabled · test ${this.runId.slice(0,8)}`);
|
||||||
|
} catch {
|
||||||
|
this.available=false;this.status('Reporting unavailable · Save metrics remains available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
observe(receipt) {
|
||||||
|
if(this.terminal||receipt.elapsedMs-this.lastProgress<10000)return;
|
||||||
|
this.lastProgress=receipt.elapsedMs;this.queue('running',receipt);
|
||||||
|
}
|
||||||
|
finish(receipt) {
|
||||||
|
if(this.terminal)return;
|
||||||
|
this.terminal=true;this.queue(stopState(receipt.reason),receipt);
|
||||||
|
}
|
||||||
|
queue(state,receipt) {
|
||||||
|
// One latest snapshot, not a growing queue. Terminal state supersedes
|
||||||
|
// pending progress; a stopped run never becomes running again.
|
||||||
|
this.pending={state,metrics:reportMetrics(receipt)};
|
||||||
|
void this.flush();
|
||||||
|
}
|
||||||
|
async flush() {
|
||||||
|
if(this.busy)return;
|
||||||
|
this.busy=true;
|
||||||
|
try {
|
||||||
|
await this.ready;
|
||||||
|
if(!this.available){this.pending=null;return;}
|
||||||
|
while(this.pending) {
|
||||||
|
const report=this.pending;this.pending=null;
|
||||||
|
const payload={run_id:this.runId,sequence:++this.sequence,...report};
|
||||||
|
let delivered=false;
|
||||||
|
for(let attempt=0;attempt<2&&!delivered;attempt++) {
|
||||||
|
try {
|
||||||
|
const response=await this.request('/av-test/api/report',{
|
||||||
|
method:'POST',credentials:'omit',keepalive:true,
|
||||||
|
headers:{'Content-Type':'application/json',Authorization:`Bearer ${this.token}`},
|
||||||
|
body:JSON.stringify(payload),signal:AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
delivered=response.ok;
|
||||||
|
if(response.status>=400&&response.status<500)break;
|
||||||
|
} catch { /* No raw exception or endpoint response is displayed. */ }
|
||||||
|
}
|
||||||
|
if(report.state!=='running') {
|
||||||
|
this.status(delivered ? `Result reported · test ${this.runId.slice(0,8)}` :
|
||||||
|
'Result not reported · use Save metrics');
|
||||||
|
} else if(!delivered) {
|
||||||
|
this.status('Reporting interrupted · local test continues');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {this.busy=false;}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user