From 74671fa2a748baf1101cca1466de8dddd4fd61c1 Mon Sep 17 00:00:00 2001 From: Thomas Bekkers <261084914+thomasbek3@users.noreply.github.com> Date: Mon, 7 Sep 2026 00:41:00 -0400 Subject: [PATCH] hosts + HD agent: in-band auth frame, ownership-checked pipeline, verified agent download, Linux restart on upgrade, CI fixes hiperf-agent.py: Exp-Golomb reader fixed (suffix from 0 plus offset), so non-IDR I-slices classify correctly. Auth accepts a first-frame {"type":"auth","token"} within 5 s; ?token= still works but logs a deprecation. Every await-then-mutate in start/stop/_spawn_pipeline checks (ws, gen) ownership; a stale spawn kills what it created. 19 new tests in computer-viewer/tests/test-hiperf-agent.py. hiperf-mac/linux/windows: the downloaded hiperf-agent.py is staged and its SHA-256 checked against MANIFEST.sha256 at the same ref (local checkout manifest as offline fallback) before it replaces the installed agent. connect-linux/hiperf-linux: enable, then restart if active else start, and print the effective listener; enable --now never restarted a live unit so upgrades kept the old bind. test-bind.sh covers it with shimmed systemctl. connect-windows.ps1: TightVNC MSI and UltraVNC zip pinned by SHA-256 (Get-FileHash before use); Amyuni's unversioned zip is gated on the WHQL-signed catalog's Authenticode signature (the exe itself is unsigned). PowerShell edits are unexecuted here; CI now parses all four .ps1 files on windows-latest. CI + release.sh: xargs -0 -n 1 bash -n (the batched form only parsed the first file); the HD agent tests run on all OSes. install.sh also installs hiperf-linux.sh and the two Windows scripts. release.sh accepts a same-day follow-up tag vYYYY.MM.DD.N. Manifest regenerated. Co-Authored-By: Claude Fable 5.1 --- .github/workflows/ci.yml | 41 ++- MANIFEST.sha256 | 12 +- RELEASING.md | 2 +- computer-viewer/connect-linux.sh | 23 +- computer-viewer/connect-windows.ps1 | 88 ++++++ computer-viewer/hiperf-agent.py | 144 +++++++-- computer-viewer/hiperf-linux.sh | 94 +++++- computer-viewer/hiperf-mac.sh | 71 ++++- computer-viewer/hiperf-windows.ps1 | 63 +++- computer-viewer/tests/test-bind.sh | 77 +++++ computer-viewer/tests/test-hiperf-agent.py | 349 +++++++++++++++++++++ install.sh | 3 + scripts/release.sh | 8 +- tests/test-install.sh | 4 + 14 files changed, 921 insertions(+), 58 deletions(-) create mode 100644 computer-viewer/tests/test-hiperf-agent.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cecaf7a..d425ae7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,8 +34,13 @@ jobs: - name: Orgo computer plugin tests run: python3 -m unittest discover -s computer-viewer/agent-plugin/orgo-computer/tests -p 'test_*.py' + # -n 1: without it xargs passes every later path as an ARGUMENT to the + # first script, so only one file was ever parsed. - name: Shell syntax - run: find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 bash -n + run: find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 -n 1 bash -n + + - name: HD agent tests + run: python3 computer-viewer/tests/test-hiperf-agent.py - name: Host-script bind tests run: bash computer-viewer/tests/test-bind.sh @@ -63,3 +68,37 @@ jobs: - name: Installer tests run: bash tests/test-install.sh + + windows: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: PowerShell syntax + shell: pwsh + run: | + $failed = $false + $files = Get-ChildItem -Path . -Recurse -Filter '*.ps1' | + Where-Object { $_.FullName -notmatch '\\\.git\\' } + if (-not $files) { Write-Host 'no .ps1 files found'; exit 1 } + foreach ($f in $files) { + $errors = $null + [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null + if ($errors -and $errors.Count -gt 0) { + $failed = $true + Write-Host "FAIL $($f.FullName)" + foreach ($e in $errors) { + Write-Host " line $($e.Extent.StartLineNumber): $($e.Message)" + } + } else { + Write-Host "OK $($f.FullName)" + } + } + if ($failed) { exit 1 } + + - name: HD agent tests + run: python3 computer-viewer/tests/test-hiperf-agent.py diff --git a/MANIFEST.sha256 b/MANIFEST.sha256 index 8a45895..5eadb00 100644 --- a/MANIFEST.sha256 +++ b/MANIFEST.sha256 @@ -5,13 +5,13 @@ caaaf1e751ddcba1d4b216ab18bd4077d18c4c9c8e85915d45bdbf746a5f95fc computer-viewe 4aa6259a032574b7f363b94abad064ec159ebe845d99043501726e437fca2bac computer-viewer/agent-plugin/orgo-computer/schemas.py 869ee2f55e5660e4c51986541923ac9b850799f50860fcd0ca7fd671c77b95c5 computer-viewer/agent-plugin/orgo-computer/skills/computer-basics/SKILL.md 2f6fc7d4fb1de1ba6bd1b3e81713715e20d575cf142a3d21fafac278a01a7953 computer-viewer/agent-plugin/orgo-computer/tools.py -35e55ae018ff651ff325836edc8f161853b1345fb0d06e6ba65591c9009da412 computer-viewer/connect-linux.sh +447bc5e48cd70db0d177eb496bb3aa3812f32bfd670539a962a6b33f79476dc3 computer-viewer/connect-linux.sh 51f9fa287677242587da208b4d2b62f3275baca1b7860a834cfb874f7ed420bd computer-viewer/connect-mac.sh -963e1c5ac722fbc813a79d1de2a5ba8301de353f968ce71e68e284d5116a311a computer-viewer/connect-windows.ps1 -5aeb18940412105fc8fbf00b98c02f2f14c18d7da64af7858f7b5635033ba203 computer-viewer/hiperf-agent.py -f8366e6251f96f160e90d130bdbb50b5668b78e802ef275879b77bb101f34a07 computer-viewer/hiperf-linux.sh -e9d80a434605888da086b24de5292b69e93791f8ec65aa1e47612798705c975a computer-viewer/hiperf-mac.sh -89456ad0f7c192d5e19149dc64254fd7c0a4e1ca348fb41b3105d0edc8453cc8 computer-viewer/hiperf-windows.ps1 +81d1cfe7c6efe2acc260d7cc21c25888c49bab052da1d873c1062e24f0d742cc computer-viewer/connect-windows.ps1 +1ba00dc54020492dd3a1d61993d1f1511f9246c0de9a1f6b860e3b6782249fa8 computer-viewer/hiperf-agent.py +372a6f5cf5618bd51b13e3f841cd045eaf4e8b9fa8707ef94fc04650d335517f computer-viewer/hiperf-linux.sh +d60528abc1817592c22323ddc8b112c67ef02b6d8204e8b5102d119f375b94b8 computer-viewer/hiperf-mac.sh +1c108d4c236e6685c39fa644ca896ba7430e874b361b2af7873bc1fb91f096e0 computer-viewer/hiperf-windows.ps1 093aee168eb589a88f16a7402654c038b6ebca1aa36e10ea75df811c00cc34d3 computer-viewer/plugin.js 08b527c943eb410ffa1a35d7d14c018d9eba22363150c3dc35a70327f1e88a28 computer-viewer/vendor/novnc-rfb.mjs 0aceb385ca7c9f1d305b2b0d7daa0f177b3a778c60beb6e901a2f0ba5622654c task-dock/plugin.js diff --git a/RELEASING.md b/RELEASING.md index da38a70..8c03ad4 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,6 +1,6 @@ # Cutting a release -Releases are annotated tags named `vYYYY.MM.DD`. The install one-liners pin +Releases are annotated tags named `vYYYY.MM.DD` (a same-day follow-up is `vYYYY.MM.DD.N`). The install one-liners pin that tag and check every copied file against that tag's `MANIFEST.sha256`. ## How to cut one diff --git a/computer-viewer/connect-linux.sh b/computer-viewer/connect-linux.sh index 433a2df..00c1f6f 100755 --- a/computer-viewer/connect-linux.sh +++ b/computer-viewer/connect-linux.sh @@ -209,24 +209,39 @@ WantedBy=default.target EOF } +show_effective_listener() { + # `enable --now` is a no-op on an already-active unit, so a re-run used to + # leave the OLD bind in place. Print what is actually listening now. + local port="$1" + command -v ss >/dev/null 2>&1 || return 0 + echo " effective listener on :${port}" + ss -ltnp 2>/dev/null | grep ":${port}" || echo " (nothing listening on :${port} yet)" +} + enable_user_units() { - echo "==> systemctl --user daemon-reload && enable --now" + echo "==> systemctl --user daemon-reload, then enable + restart" if ! command -v systemctl >/dev/null 2>&1; then echo "systemctl not found. User units written to ${UNIT_DIR} but not started." >&2 - echo " After login: systemctl --user daemon-reload && systemctl --user enable --now $*" >&2 + echo " After login: systemctl --user daemon-reload && systemctl --user enable $* && systemctl --user restart $*" >&2 return 0 fi if ! systemctl --user daemon-reload; then echo "systemd --user is not running (typical over SSH without lingering)." >&2 echo "Units written. On the graphical session run:" >&2 echo " systemctl --user daemon-reload" >&2 - echo " systemctl --user enable --now $*" >&2 + echo " systemctl --user enable $* && systemctl --user restart $*" >&2 return 0 fi local u for u in "$@"; do - systemctl --user enable --now "$u" || echo " failed to enable $u (will be available after login)" >&2 + systemctl --user enable "$u" || echo " failed to enable $u (will be available after login)" >&2 + if systemctl --user is-active --quiet "$u"; then + systemctl --user restart "$u" || echo " failed to restart $u" >&2 + else + systemctl --user start "$u" || echo " failed to start $u (will be available after login)" >&2 + fi done + show_effective_listener "${LISTEN_PORT}" } note_linger() { diff --git a/computer-viewer/connect-windows.ps1 b/computer-viewer/connect-windows.ps1 index 7b0779e..8be08c9 100644 --- a/computer-viewer/connect-windows.ps1 +++ b/computer-viewer/connect-windows.ps1 @@ -40,12 +40,78 @@ $UsbmmiddTaskName = 'ComputerViewerVirtualDisplay' $UltraVncZipUrl = 'https://uvnc.eu/download/1800/UltraVNC_1824.zip' $UltraVncDir = Join-Path ${env:ProgramFiles} 'UltraVNC' +# sha256 of the two version-pinned third-party downloads, computed 2026-09-07 +# from the exact URLs above. These URLs name a version, so their bytes must not +# change; if a pin ever fails, bump the URL and the pin together, deliberately. +$TightVncSha256 = 'FA86D817AC29C5FFE1E8E7095E738D9BA5CA28AA62304AC234580916622A8CA2' +$UltraVncZipSha256 = '8AF948089626008F02EDD1254AFC15C814E454EC5FC9E3EAA860356F19D4F113' + +# $UsbmmiddUrl is an unversioned "latest" link, so no hash can be pinned to it. +# The weaker fallback is an Authenticode check before anything from the zip is +# executed. Note that deviceinstaller64.exe itself carries NO Authenticode +# signature (its PE certificate table is empty, checked 2026-09-07); the signed +# artefact in the zip is the driver catalog usbmmidd.cat, WHQL-signed by +# "Microsoft Windows Hardware Compatibility Publisher" (Amyuni's driver, signed +# through Microsoft's hardware program). That catalog is what Windows itself +# validates at install time, so it is the strongest signal available here. +$UsbmmiddSigner = 'Microsoft Windows Hardware Compatibility Publisher' + function Test-IsAdmin { $id = [Security.Principal.WindowsIdentity]::GetCurrent() $p = New-Object Security.Principal.WindowsPrincipal $id return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } +function Test-PinnedHash { + # $true when the downloaded file matches the pinned sha256. + param([string]$Path, [string]$Expected, [string]$Label) + if (-not (Test-Path -LiteralPath $Path)) { + Write-Warning "$Label was not downloaded; nothing to verify." + return $false + } + $actual = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash + if ($actual -ne $Expected.ToUpperInvariant()) { + Write-Warning "$Label sha256 mismatch - refusing to use it." + Write-Warning " expected: $Expected" + Write-Warning " got: $actual" + return $false + } + Write-Host " $Label sha256 $actual verified" + return $true +} + +function Test-SignedBy { + # Weaker than a pinned hash - only for downloads behind a moving URL. + # Requires a Valid Authenticode status AND the expected signer subject. + param([string]$Path, [string]$SignerMatch, [string]$Label) + if (-not (Test-Path -LiteralPath $Path)) { + Write-Warning "$Label is missing; cannot check its signature." + return $false + } + $sig = $null + try { + $sig = Get-AuthenticodeSignature -LiteralPath $Path + } catch { + Write-Warning "$Label signature could not be read: $_" + return $false + } + if (-not $sig -or $sig.Status -ne 'Valid') { + $status = if ($sig) { $sig.Status } else { 'none' } + Write-Warning "$Label Authenticode status is '$status', not 'Valid' - refusing to use it." + return $false + } + $subject = '' + if ($sig.SignerCertificate) { $subject = [string]$sig.SignerCertificate.Subject } + if ($subject -notlike "*$SignerMatch*") { + Write-Warning "$Label is signed by an unexpected publisher - refusing to use it." + Write-Warning " expected subject to contain: $SignerMatch" + Write-Warning " got: $subject" + return $false + } + Write-Host " $Label signature Valid, signer $subject" + return $true +} + function Write-Step([string]$Message) { Write-Host "==> $Message" } @@ -288,6 +354,8 @@ function Install-UsbmmiddVirtualDisplay { Write-Warning "usbmmidd extract failed: $_" return $false } + # No pinned hash is possible for a moving "latest" URL; the driver + # catalog's signature is checked below, before anything is executed. $installer = Get-ChildItem -LiteralPath $extract -Recurse -Filter 'deviceinstaller64.exe' | Select-Object -First 1 if (-not $installer) { Write-Warning 'usbmmidd zip did not contain deviceinstaller64.exe' @@ -304,6 +372,17 @@ function Install-UsbmmiddVirtualDisplay { } if (-not $already) { + # Gate on the driver catalog's Authenticode signature BEFORE running the + # installer as admin. See the $UsbmmiddSigner comment for why this is the + # check available (moving URL, unsigned deviceinstaller64.exe). + $catDir = Split-Path -Parent $exe + $cat = Join-Path $catDir 'usbmmidd.cat' + if (-not (Test-Path -LiteralPath $cat)) { $cat = Join-Path $catDir 'usbmmIdd.cat' } + if (-not (Test-SignedBy -Path $cat -SignerMatch $UsbmmiddSigner -Label 'usbmmidd driver catalog')) { + Write-Warning 'Not running deviceinstaller64.exe.' + Write-Warning 'Fallback: plug in a monitor or an HDMI/DisplayPort dummy plug (~$8), then re-run.' + return $false + } Write-Host " $exe install usbmmidd.inf usbmmidd" $outFile = Join-Path $env:TEMP 'usbmmidd-install-out.txt' $errFile = Join-Path $env:TEMP 'usbmmidd-install-err.txt' @@ -449,6 +528,11 @@ function Install-UltraVncHeadlessFallback { Write-Warning 'TightVNC will keep serving :5900. Expect a black picture on a headless IDD. Dummy-plug fallback still applies.' return $false } + if (-not (Test-PinnedHash -Path $zip -Expected $UltraVncZipSha256 -Label 'UltraVNC zip')) { + Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue + Write-Warning 'Not extracting or running UltraVNC. TightVNC keeps serving :5900.' + return $false + } try { if (Test-Path -LiteralPath $extract) { Remove-Item -LiteralPath $extract -Recurse -Force } New-Item -ItemType Directory -Force -Path $extract | Out-Null @@ -546,6 +630,10 @@ if (-not $tvnInstalled) { $msi = Join-Path $env:TEMP 'tightvnc-2.8.88-gpl-setup-64bit.msi' $ProgressPreference = 'SilentlyContinue' Invoke-WebRequest -Uri $TightVncUrl -OutFile $msi -UseBasicParsing + if (-not (Test-PinnedHash -Path $msi -Expected $TightVncSha256 -Label 'TightVNC MSI')) { + Remove-Item -LiteralPath $msi -Force -ErrorAction SilentlyContinue + throw 'TightVNC MSI failed its pinned sha256 check. Refusing to run msiexec on it.' + } Write-Step 'Installing TightVNC Server (service, SAS/CAD, VNC auth)' # MSI password properties are unreliable - see header comment. Print $vncPassword later regardless. $msiArgs = @( diff --git a/computer-viewer/hiperf-agent.py b/computer-viewer/hiperf-agent.py index 3f5d05f..182c754 100644 --- a/computer-viewer/hiperf-agent.py +++ b/computer-viewer/hiperf-agent.py @@ -2,7 +2,11 @@ """High-performance H.264 screen-stream agent for the Computer viewer plugin. Python 3.10+. One third-party dependency: websockets>=13,<16. -Serves Annex-B access units over WebSocket at /stream?token=. +Serves Annex-B access units over WebSocket at /stream. + +Auth: send {"type":"auth","token":""} as the first frame, within 5 s. +?token= in the query string still works but is deprecated - a URL leaks +into proxy logs, browser history and Referer headers. """ from __future__ import annotations @@ -40,6 +44,7 @@ def import_serve(): QUEUE_MAX = 30 MAX_MESSAGE = 2**20 GOP_DIV = 2 +AUTH_TIMEOUT_S = 5.0 # Close codes (spec 4.4 / 3) CLOSE_AUTH = 4401 @@ -541,7 +546,7 @@ def _ue(data: bytes, bitpos: int) -> tuple[Optional[int], int]: return None, bitpos else: return None, bitpos - val = (1 << zeros) - 1 + suffix = 0 for _ in range(zeros): if bitpos >= nbits: return None, bitpos @@ -549,8 +554,8 @@ def _ue(data: bytes, bitpos: int) -> tuple[Optional[int], int]: bit_i = 7 - (bitpos % 8) bit = (data[byte_i] >> bit_i) & 1 bitpos += 1 - val = (val << 1) | bit - return val, bitpos + suffix = (suffix << 1) | bit + return suffix + ((1 << zeros) - 1), bitpos def is_i_slice(nal: bytes) -> bool: @@ -692,6 +697,10 @@ def __init__( self.cursor = 0 self.cached: Optional[Candidate] = None self.client = None + # Bumped on every attach. Anything that awaits then mutates shared + # state must re-check its captured generation first, or a superseded + # client can tear down / overwrite the live client's pipeline. + self.client_gen = 0 self.proc: Optional[asyncio.subprocess.Process] = None self.tasks: list[asyncio.Task] = [] self.queue: Optional[asyncio.Queue] = None @@ -715,6 +724,10 @@ def hello_payload(self) -> dict: 'fps': self.fps, } + def owns(self, ws, gen: int) -> bool: + """True while (ws, gen) is still the attached client.""" + return gen == self.client_gen and ws is self.client + async def send_json(self, ws, payload: dict) -> None: await ws.send(json.dumps(payload, separators=(',', ':'))) @@ -788,7 +801,11 @@ async def _drain_stderr(self, proc: asyncio.subprocess.Process, tag: str = 'ffmp except Exception as exc: LOG.info('stderr drain ended: %s', exc) - async def stop_ffmpeg(self) -> None: + async def stop_ffmpeg(self, ws=None, gen: Optional[int] = None) -> None: + """Tear the pipeline down. With (ws, gen) only the owning client may.""" + if gen is not None and not self.owns(ws, gen): + LOG.info('ignoring stale stop (gen %s, current %s)', gen, self.client_gen) + return proc = self.proc self.proc = None q = self.queue @@ -815,8 +832,10 @@ async def stop_ffmpeg(self) -> None: task.cancel() if tasks: await asyncio.gather(*tasks, return_exceptions=True) + # expected_exit is only ever raised by this call, so always clear it. self.expected_exit = False - self.drop_until_key = False + if gen is None or self.owns(ws, gen): + self.drop_until_key = False def advance_after_death(self) -> None: self.death_mono = time.monotonic() @@ -831,8 +850,10 @@ def advance_after_death(self) -> None: self.cursor = nxt LOG.info('advance candidate cursor to %s', nxt) - async def start_ffmpeg(self, ws) -> Optional[str]: + async def start_ffmpeg(self, ws, gen: int) -> Optional[str]: """Spawn cached (or next) pipeline. Returns an error code or None on success.""" + if not self.owns(ws, gen): + return 'superseded' if not os.path.isfile(self.ffmpeg): return 'no-encoder' if self.need_restart_gap: @@ -844,23 +865,29 @@ async def start_ffmpeg(self, ws) -> Optional[str]: await asyncio.sleep(wait_s) except asyncio.CancelledError: raise - if self.client is not ws: - return 'capture-failed' + if not self.owns(ws, gen): + return 'superseded' self.need_restart_gap = False if self.cached is None: await self.probe_from(self.cursor) + if not self.owns(ws, gen): + return 'superseded' if self.cached is None: await self.probe_from(0) + if not self.owns(ws, gen): + return 'superseded' if self.cached is None: return 'capture-failed' tried = 0 while self.cached is not None and tried < max(len(self.candidates), 1): tried += 1 - err = await self._spawn_pipeline(ws) + err = await self._spawn_pipeline(ws, gen) if err is None: return None - if err in ('no-encoder', 'capture-failed'): + if err in ('no-encoder', 'capture-failed', 'superseded'): return err + if not self.owns(ws, gen): + return 'superseded' # 'stall' -> try the next candidate instead of hanging forever nxt = self.cursor + 1 if nxt >= len(self.candidates): @@ -871,9 +898,13 @@ async def start_ffmpeg(self, ws) -> Optional[str]: self.cached = self.candidates[nxt] return 'capture-failed' - async def _spawn_pipeline(self, ws) -> Optional[str]: + async def _spawn_pipeline(self, ws, gen: int) -> Optional[str]: """None on success; 'stall' to try the next candidate; other codes are fatal.""" - await self.stop_ffmpeg() + if not self.owns(ws, gen): + return 'superseded' + await self.stop_ffmpeg(ws, gen) + if not self.owns(ws, gen): + return 'superseded' cand = self.cached if cand is None: return 'capture-failed' @@ -886,11 +917,23 @@ async def _spawn_pipeline(self, ws) -> Optional[str]: except OSError as exc: LOG.warning('ffmpeg spawn failed: %s', exc) return 'capture-failed' + if not self.owns(ws, gen): + # Superseded while exec'ing: kill what we just created, touch nothing. + LOG.info('stale spawn (gen %s); killing the process it created', gen) + kill_process(proc, force=True) + return 'superseded' self.proc = proc self.spawn_mono = time.monotonic() stderr_task = asyncio.create_task(self._drain_stderr(proc), name='stderr') self.tasks = [stderr_task] first = await read_stdout_once(proc, STALL_TIMEOUT_S) + if not self.owns(ws, gen): + LOG.info('stale pipeline (gen %s); killing the process it created', gen) + if self.proc is proc: + self.proc = None + kill_process(proc, force=True) + stderr_task.cancel() + return 'superseded' if not first: if proc.returncode is None: try: @@ -899,7 +942,7 @@ async def _spawn_pipeline(self, ws) -> Optional[str]: pass if proc.returncode is None: log_capture_stall('capture', cand.name) - await self.stop_ffmpeg() + await self.stop_ffmpeg(ws, gen) return 'stall' self.queue = asyncio.Queue(maxsize=QUEUE_MAX) self.drop_until_key = False @@ -909,8 +952,15 @@ async def _spawn_pipeline(self, ws) -> Optional[str]: await self.send_json(ws, self.hello_payload()) except Exception as exc: LOG.warning('hello after spawn failed: %s', exc) - await self.stop_ffmpeg() + await self.stop_ffmpeg(ws, gen) return 'capture-failed' + if not self.owns(ws, gen): + LOG.info('stale pipeline (gen %s); killing the process it created', gen) + if self.proc is proc: + self.proc = None + kill_process(proc, force=True) + stderr_task.cancel() + return 'superseded' self.tasks = [ stderr_task, asyncio.create_task( @@ -1043,6 +1093,45 @@ async def _watch_proc(self, ws, proc: asyncio.subprocess.Process) -> None: except Exception: pass + async def authenticate(self, stream, qs: dict) -> bool: + """Legacy ?token=, else an in-band {"type":"auth"} first frame.""" + got = (qs.get('token') or [''])[0] + if got: + if not tokens_match(got, self.token): + LOG.info('auth failed: bad token in query string') + return False + LOG.warning('token in query string is deprecated; send an auth frame instead') + return True + try: + message = await asyncio.wait_for(stream.__anext__(), timeout=AUTH_TIMEOUT_S) + except asyncio.TimeoutError: + LOG.info('auth failed: no auth frame within %ss', int(AUTH_TIMEOUT_S)) + return False + except StopAsyncIteration: + LOG.info('auth failed: client closed before authenticating') + return False + except asyncio.CancelledError: + raise + except Exception as exc: + LOG.info('auth failed: could not read the first frame: %s', exc) + return False + if isinstance(message, (bytes, bytearray)): + LOG.info('auth failed: first frame is binary') + return False + try: + data = json.loads(message) + except (TypeError, ValueError): + LOG.info('auth failed: first frame is not JSON') + return False + if not isinstance(data, dict) or data.get('type') != 'auth': + LOG.info('auth failed: first frame is not an auth message') + return False + sent = data.get('token') + if not isinstance(sent, str) or not tokens_match(sent, self.token): + LOG.info('auth failed: bad token in auth frame') + return False + return True + async def handler(self, websocket) -> None: path = '' try: @@ -1055,15 +1144,16 @@ async def handler(self, websocket) -> None: await websocket.close(CLOSE_NOT_FOUND, 'not found') return qs = parse_qs(parsed.query) - got = (qs.get('token') or [''])[0] - if not tokens_match(got, self.token): - LOG.info('auth failed') + stream = websocket.__aiter__() + if not await self.authenticate(stream, qs): await websocket.close(CLOSE_AUTH, 'unauthorized') return async with self.lock: old = self.client self.client = websocket + self.client_gen += 1 + gen = self.client_gen await self.stop_ffmpeg() if old is not None and old is not websocket: try: @@ -1078,9 +1168,13 @@ async def handler(self, websocket) -> None: self.client = None return - LOG.info('client attached') + LOG.info('client attached (gen %s)', gen) try: - async for message in websocket: + while True: + try: + message = await stream.__anext__() + except StopAsyncIteration: + break if isinstance(message, (bytes, bytearray)): continue try: @@ -1096,9 +1190,11 @@ async def handler(self, websocket) -> None: except Exception: break elif kind == 'start': - if self.client is not websocket: + if not self.owns(websocket, gen): + break + err = await self.start_ffmpeg(websocket, gen) + if err == 'superseded': break - err = await self.start_ffmpeg(websocket) if err: try: await self.send_json( @@ -1114,7 +1210,9 @@ async def handler(self, websocket) -> None: pass break elif kind == 'stop': - await self.stop_ffmpeg() + if not self.owns(websocket, gen): + break + await self.stop_ffmpeg(websocket, gen) except Exception as exc: LOG.info('client loop ended: %s', exc) finally: diff --git a/computer-viewer/hiperf-linux.sh b/computer-viewer/hiperf-linux.sh index 373fa3b..f576ece 100755 --- a/computer-viewer/hiperf-linux.sh +++ b/computer-viewer/hiperf-linux.sh @@ -131,6 +131,50 @@ script_dir() { fi } +sha256_of() { + # Digest one file with whatever is installed. Non-zero if neither tool exists. + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 -- "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$1" | awk '{print $1}' + else + return 1 + fi +} + +manifest_agent_sha() { + # MANIFEST.sha256 sits at the repo root, one level above RAW_REPO_URL. + local root="${RAW_REPO_URL%/computer-viewer}" + local pick='$2 == "computer-viewer/hiperf-agent.py" { print $1; found = 1; exit } END { if (!found) exit 1 }' + local sum="" + sum="$(curl -fsSL "${root}/MANIFEST.sha256" 2>/dev/null | awk "$pick" || true)" + if [ -z "$sum" ]; then + # Offline fallback: the MANIFEST that ships with a local checkout. + local here + here="$(script_dir)" + if [ -n "$here" ] && [ -f "${here}/../MANIFEST.sha256" ]; then + sum="$(awk "$pick" "${here}/../MANIFEST.sha256" || true)" + fi + fi + [ -n "$sum" ] || return 1 + printf '%s' "$sum" +} + +verify_agent_sha() { + local file="$1" want="$2" got="" + got="$(sha256_of "$file")" || { + echo "Neither shasum nor sha256sum is available; refusing an unverified hiperf-agent.py." >&2 + return 1 + } + if [ "$got" != "$want" ]; then + echo "hiperf-agent.py sha256 mismatch - refusing to install." >&2 + echo " manifest: ${want}" >&2 + echo " file: ${got}" >&2 + return 1 + fi + return 0 +} + ensure_token() { umask 077 if [ -f "${TOKEN_FILE}" ]; then @@ -171,24 +215,39 @@ note_linger() { fi } +show_effective_listener() { + # `enable --now` is a no-op on an already-active unit, so a re-run used to + # leave the OLD bind in place. Print what is actually listening now. + local port="$1" + command -v ss >/dev/null 2>&1 || return 0 + echo " effective listener on :${port}" + ss -ltnp 2>/dev/null | grep ":${port}" || echo " (nothing listening on :${port} yet)" +} + enable_user_units() { - echo "==> systemctl --user daemon-reload && enable --now" + echo "==> systemctl --user daemon-reload, then enable + restart" if ! command -v systemctl >/dev/null 2>&1; then echo "systemctl not found. User unit written to ${UNIT_DIR} but not started." >&2 - echo " After login: systemctl --user daemon-reload && systemctl --user enable --now $*" >&2 + echo " After login: systemctl --user daemon-reload && systemctl --user enable $* && systemctl --user restart $*" >&2 return 0 fi if ! systemctl --user daemon-reload; then echo "systemd --user is not running (typical over SSH without lingering)." >&2 echo "Unit written. On the graphical session run:" >&2 echo " systemctl --user daemon-reload" >&2 - echo " systemctl --user enable --now $*" >&2 + echo " systemctl --user enable $* && systemctl --user restart $*" >&2 return 0 fi local u for u in "$@"; do - systemctl --user enable --now "$u" || echo " failed to enable $u (will be available after login)" >&2 + systemctl --user enable "$u" || echo " failed to enable $u (will be available after login)" >&2 + if systemctl --user is-active --quiet "$u"; then + systemctl --user restart "$u" || echo " failed to restart $u" >&2 + else + systemctl --user start "$u" || echo " failed to start $u (will be available after login)" >&2 + fi done + show_effective_listener "${LISTEN_PORT}" } need_pkg=0 @@ -236,18 +295,37 @@ if ! "${VENV_DIR}/bin/pip" install -q "${WEBSOCKETS_PIN}"; then fi echo "==> Fetching hiperf-agent.py" -if curl -fsSL "${RAW_REPO_URL}/hiperf-agent.py" -o "${AGENT_PATH}"; then - echo " downloaded from ${RAW_REPO_URL}/hiperf-agent.py" +# Staged to a temp file and checked against MANIFEST.sha256 before it replaces +# anything, so a tampered or truncated download cannot become the running agent. +AGENT_SHA="$(manifest_agent_sha || true)" +if [ -z "${AGENT_SHA}" ]; then + echo "Could not read the hiperf-agent.py digest from ${RAW_REPO_URL%/computer-viewer}/MANIFEST.sha256" >&2 + echo "and no local MANIFEST.sha256 was found. Refusing to install an unverified agent." >&2 + exit 1 +fi +AGENT_TMP="${AGENT_PATH}.new" +rm -f "${AGENT_TMP}" +if curl -fsSL "${RAW_REPO_URL}/hiperf-agent.py" -o "${AGENT_TMP}"; then + AGENT_SRC="${RAW_REPO_URL}/hiperf-agent.py" else + rm -f "${AGENT_TMP}" HERE="$(script_dir)" if [ -n "$HERE" ] && [ -f "${HERE}/hiperf-agent.py" ]; then - cp "${HERE}/hiperf-agent.py" "${AGENT_PATH}" - echo " copied local ${HERE}/hiperf-agent.py (download failed)" + cp "${HERE}/hiperf-agent.py" "${AGENT_TMP}" + AGENT_SRC="${HERE}/hiperf-agent.py (download failed)" else echo "Could not download hiperf-agent.py from ${RAW_REPO_URL} and no local copy was found." >&2 exit 1 fi fi +if ! verify_agent_sha "${AGENT_TMP}" "${AGENT_SHA}"; then + rm -f "${AGENT_TMP}" + echo " left ${AGENT_PATH} untouched" >&2 + exit 1 +fi +mv -f "${AGENT_TMP}" "${AGENT_PATH}" +echo " installed from ${AGENT_SRC}" +echo " sha256 ${AGENT_SHA} verified against MANIFEST.sha256" chmod 644 "${AGENT_PATH}" PYTHON_BIN="${VENV_DIR}/bin/python" diff --git a/computer-viewer/hiperf-mac.sh b/computer-viewer/hiperf-mac.sh index 8898d27..513e7f0 100755 --- a/computer-viewer/hiperf-mac.sh +++ b/computer-viewer/hiperf-mac.sh @@ -102,6 +102,50 @@ script_dir() { fi } +sha256_of() { + # Digest one file with whatever is installed. Non-zero if neither tool exists. + if command -v shasum >/dev/null 2>&1; then + shasum -a 256 -- "$1" | awk '{print $1}' + elif command -v sha256sum >/dev/null 2>&1; then + sha256sum -- "$1" | awk '{print $1}' + else + return 1 + fi +} + +manifest_agent_sha() { + # MANIFEST.sha256 sits at the repo root, one level above RAW_REPO_URL. + local root="${RAW_REPO_URL%/computer-viewer}" + local pick='$2 == "computer-viewer/hiperf-agent.py" { print $1; found = 1; exit } END { if (!found) exit 1 }' + local sum="" + sum="$(curl -fsSL "${root}/MANIFEST.sha256" 2>/dev/null | awk "$pick" || true)" + if [ -z "$sum" ]; then + # Offline fallback: the MANIFEST that ships with a local checkout. + local here + here="$(script_dir)" + if [ -n "$here" ] && [ -f "${here}/../MANIFEST.sha256" ]; then + sum="$(awk "$pick" "${here}/../MANIFEST.sha256" || true)" + fi + fi + [ -n "$sum" ] || return 1 + printf '%s' "$sum" +} + +verify_agent_sha() { + local file="$1" want="$2" got="" + got="$(sha256_of "$file")" || { + echo "Neither shasum nor sha256sum is available; refusing an unverified hiperf-agent.py." >&2 + return 1 + } + if [ "$got" != "$want" ]; then + echo "hiperf-agent.py sha256 mismatch - refusing to install." >&2 + echo " manifest: ${want}" >&2 + echo " file: ${got}" >&2 + return 1 + fi + return 0 +} + ensure_token() { umask 077 if [ -f "${TOKEN_FILE}" ]; then @@ -206,18 +250,37 @@ if ! "${VENV_DIR}/bin/pip" install -q "${WEBSOCKETS_PIN}"; then fi echo "==> Fetching hiperf-agent.py" -if curl -fsSL "${RAW_REPO_URL}/hiperf-agent.py" -o "${AGENT_PATH}"; then - echo " downloaded from ${RAW_REPO_URL}/hiperf-agent.py" +# Staged to a temp file and checked against MANIFEST.sha256 before it replaces +# anything, so a tampered or truncated download cannot become the running agent. +AGENT_SHA="$(manifest_agent_sha || true)" +if [ -z "${AGENT_SHA}" ]; then + echo "Could not read the hiperf-agent.py digest from ${RAW_REPO_URL%/computer-viewer}/MANIFEST.sha256" >&2 + echo "and no local MANIFEST.sha256 was found. Refusing to install an unverified agent." >&2 + exit 1 +fi +AGENT_TMP="${AGENT_PATH}.new" +rm -f "${AGENT_TMP}" +if curl -fsSL "${RAW_REPO_URL}/hiperf-agent.py" -o "${AGENT_TMP}"; then + AGENT_SRC="${RAW_REPO_URL}/hiperf-agent.py" else + rm -f "${AGENT_TMP}" HERE="$(script_dir)" if [ -n "$HERE" ] && [ -f "${HERE}/hiperf-agent.py" ]; then - cp "${HERE}/hiperf-agent.py" "${AGENT_PATH}" - echo " copied local ${HERE}/hiperf-agent.py (download failed)" + cp "${HERE}/hiperf-agent.py" "${AGENT_TMP}" + AGENT_SRC="${HERE}/hiperf-agent.py (download failed)" else echo "Could not download hiperf-agent.py from ${RAW_REPO_URL} and no local copy was found." >&2 exit 1 fi fi +if ! verify_agent_sha "${AGENT_TMP}" "${AGENT_SHA}"; then + rm -f "${AGENT_TMP}" + echo " left ${AGENT_PATH} untouched" >&2 + exit 1 +fi +mv -f "${AGENT_TMP}" "${AGENT_PATH}" +echo " installed from ${AGENT_SRC}" +echo " sha256 ${AGENT_SHA} verified against MANIFEST.sha256" chmod 644 "${AGENT_PATH}" PYTHON_BIN="${VENV_DIR}/bin/python" diff --git a/computer-viewer/hiperf-windows.ps1 b/computer-viewer/hiperf-windows.ps1 index abf01bf..32f58b1 100644 --- a/computer-viewer/hiperf-windows.ps1 +++ b/computer-viewer/hiperf-windows.ps1 @@ -260,28 +260,73 @@ if ($LASTEXITCODE -ne 0) { throw "pip install $WebsocketsPin failed (exit $LASTEXITCODE)." } +function Get-ManifestAgentSha { + # MANIFEST.sha256 sits at the repo root, one level above $RawRepoUrl. + param([string]$Root, [string]$Here) + $lines = $null + try { + $ProgressPreference = 'SilentlyContinue' + $resp = Invoke-WebRequest -Uri "$Root/MANIFEST.sha256" -UseBasicParsing + $lines = $resp.Content -split "`n" + } catch { + Write-Warning "Could not fetch $Root/MANIFEST.sha256: $_" + } + if (-not $lines -and $Here) { + # Offline fallback: the MANIFEST that ships with a local checkout. + $localManifest = Join-Path (Split-Path -Parent $Here) 'MANIFEST.sha256' + if (Test-Path -LiteralPath $localManifest) { + $lines = Get-Content -LiteralPath $localManifest + } + } + if (-not $lines) { return $null } + foreach ($line in $lines) { + $parts = ($line -replace "`r", '').Trim() -split '\s+', 2 + if ($parts.Count -eq 2 -and $parts[1].Trim() -eq 'computer-viewer/hiperf-agent.py') { + return $parts[0] + } + } + return $null +} + Write-Step 'Fetching hiperf-agent.py' -$downloaded = $false +# Staged to a temp file and checked against MANIFEST.sha256 before it replaces +# anything, so a tampered or truncated download cannot become the running agent. +$here = $PSScriptRoot +if (-not $here -and $PSCommandPath) { $here = Split-Path -Parent $PSCommandPath } +$manifestRoot = $RawRepoUrl -replace '/computer-viewer$', '' +$agentSha = Get-ManifestAgentSha -Root $manifestRoot -Here $here +if (-not $agentSha) { + throw "Could not read the hiperf-agent.py digest from $manifestRoot/MANIFEST.sha256 and no local MANIFEST.sha256 was found. Refusing to install an unverified agent." +} + +$agentTmp = "$AgentPath.new" +if (Test-Path -LiteralPath $agentTmp) { Remove-Item -LiteralPath $agentTmp -Force } +$agentSrc = $null try { $ProgressPreference = 'SilentlyContinue' - Invoke-WebRequest -Uri "$RawRepoUrl/hiperf-agent.py" -OutFile $AgentPath -UseBasicParsing - $downloaded = $true - Write-Host " downloaded from $RawRepoUrl/hiperf-agent.py" + Invoke-WebRequest -Uri "$RawRepoUrl/hiperf-agent.py" -OutFile $agentTmp -UseBasicParsing + $agentSrc = "$RawRepoUrl/hiperf-agent.py" } catch { Write-Warning "Download failed: $_" } -if (-not $downloaded) { - $here = $PSScriptRoot - if (-not $here -and $PSCommandPath) { $here = Split-Path -Parent $PSCommandPath } +if (-not $agentSrc) { $local = $null if ($here) { $local = Join-Path $here 'hiperf-agent.py' } if ($local -and (Test-Path -LiteralPath $local)) { - Copy-Item -LiteralPath $local -Destination $AgentPath -Force - Write-Host " copied local $local (download failed)" + Copy-Item -LiteralPath $local -Destination $agentTmp -Force + $agentSrc = "$local (download failed)" } else { throw "Could not download hiperf-agent.py from $RawRepoUrl and no local copy was found." } } +$actualSha = (Get-FileHash -LiteralPath $agentTmp -Algorithm SHA256).Hash +if ($actualSha -ne $agentSha.ToUpperInvariant()) { + Remove-Item -LiteralPath $agentTmp -Force -ErrorAction SilentlyContinue + throw "hiperf-agent.py sha256 mismatch - refusing to install. manifest $agentSha, file $actualSha. Left $AgentPath untouched." +} +Move-Item -LiteralPath $agentTmp -Destination $AgentPath -Force +Write-Host " installed from $agentSrc" +Write-Host " sha256 $actualSha verified against MANIFEST.sha256" if (-not (Test-Path -LiteralPath $LogFile)) { Set-Content -LiteralPath $LogFile -Value '' -Encoding ASCII diff --git a/computer-viewer/tests/test-bind.sh b/computer-viewer/tests/test-bind.sh index caa925a..fa53c09 100755 --- a/computer-viewer/tests/test-bind.sh +++ b/computer-viewer/tests/test-bind.sh @@ -97,6 +97,83 @@ for script in "${SCRIPTS[@]}"; do rm -f "$extract" done +# --- enable_user_units must RESTART an already-active unit ------------------- +# `systemctl --user enable --now` is a no-op on an active unit, so a re-run with +# a new bind never took effect. No systemd on the CI macOS runner, so shim +# systemctl/ss and assert on the argv the function produces. + +shim_sys="$(mktemp -d)" +CLEAN_SYS="$shim_sys" +cleanup_sys() { + rm -rf "$CLEAN_SYS" +} +trap 'cleanup; cleanup_sys' EXIT + +cat > "${shim_sys}/systemctl" <<'EOF' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "${SYSCTL_LOG}" +case " $* " in + *" is-active "*) exit "${IS_ACTIVE_RC:-0}" ;; +esac +exit 0 +EOF +chmod +x "${shim_sys}/systemctl" + +cat > "${shim_sys}/ss" <<'EOF' +#!/usr/bin/env bash +printf 'LISTEN 0 128 127.0.0.1:6080 0.0.0.0:* users:(("websockify",pid=1,fd=3))\n' +EOF +chmod +x "${shim_sys}/ss" + +run_units() { + local extract="$1" log="$2" active_rc="$3" + "$ENV_BIN" -i PATH="${shim_sys}:/usr/bin:/bin" HOME="${HOME:-/tmp}" \ + SYSCTL_LOG="$log" IS_ACTIVE_RC="$active_rc" LISTEN_PORT=6080 \ + "$BASH_BIN" --noprofile --norc -c \ + 'set -euo pipefail; . "$1"; enable_user_units demo.service' _ "$extract" >/dev/null 2>&1 +} + +for script in computer-viewer/connect-linux.sh computer-viewer/hiperf-linux.sh; do + extract="$(mktemp)" + sed -n '/^show_effective_listener()/,/^}/p' "$script" > "$extract" + sed -n '/^enable_user_units()/,/^}/p' "$script" >> "$extract" + if ! grep -q '^enable_user_units()' "$extract"; then + echo "FAIL ${script} enable_user_units not found" + fail=1 + rm -f "$extract" + continue + fi + + log="$(mktemp)" + run_units "$extract" "$log" 0 + if grep -q -- '--user restart demo.service' "$log"; then + echo "OK ${script} active unit -> restart" + else + echo "FAIL ${script} active unit was not restarted" + fail=1 + fi + if grep -q -- '--user enable --now' "$log"; then + echo "FAIL ${script} still uses 'enable --now'" + fail=1 + fi + rm -f "$log" + + log="$(mktemp)" + run_units "$extract" "$log" 1 + if grep -q -- '--user start demo.service' "$log"; then + echo "OK ${script} inactive unit -> start" + else + echo "FAIL ${script} inactive unit was not started" + fail=1 + fi + if grep -q -- '--user restart demo.service' "$log"; then + echo "FAIL ${script} inactive unit was restarted instead of started" + fail=1 + fi + rm -f "$log" + rm -f "$extract" +done + if [ "$fail" -ne 0 ]; then echo "test-bind: FAILED" exit 1 diff --git a/computer-viewer/tests/test-hiperf-agent.py b/computer-viewer/tests/test-hiperf-agent.py new file mode 100644 index 0000000..37c0df5 --- /dev/null +++ b/computer-viewer/tests/test-hiperf-agent.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""Unit tests for computer-viewer/hiperf-agent.py. + +The agent is a script, not a package, so it is loaded by path with importlib. +Loading it must not start a server, spawn ffmpeg or write a log file: the +script guards all of that behind `if __name__ == '__main__'`. + +Run: python3 computer-viewer/tests/test-hiperf-agent.py -v +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import os +import sys +import unittest + +HERE = os.path.dirname(os.path.abspath(__file__)) +AGENT_PATH = os.path.join(os.path.dirname(HERE), 'hiperf-agent.py') + + +def load_agent_module(): + spec = importlib.util.spec_from_file_location('hiperf_agent_under_test', AGENT_PATH) + if spec is None or spec.loader is None: + raise RuntimeError('could not build an import spec for ' + AGENT_PATH) + module = importlib.util.module_from_spec(spec) + # Never '__main__': main() must not run on import. + spec.loader.exec_module(module) + return module + + +agent_mod = load_agent_module() + +TOKEN = 'a1b2c3d4e5f6' + + +def bits_to_bytes(bits: str) -> bytes: + padded = bits + '0' * ((8 - len(bits) % 8) % 8) + return bytes(int(padded[i:i + 8], 2) for i in range(0, len(padded), 8)) + + +def slice_nal(nal_type: int, first_mb_bits: str, slice_type_bits: str) -> bytes: + """One byte of NAL header plus an RBSP holding ue(first_mb) ue(slice_type).""" + header = bytes([nal_type & 0x1F]) + return header + bits_to_bytes(first_mb_bits + slice_type_bits + '1') + + +class FakeRequest: + def __init__(self, path: str) -> None: + self.path = path + + +class FakeWS: + """Async-iterable stand-in for a websockets connection.""" + + # A deque plus polling, not an asyncio.Queue, so a FakeWS can be built + # outside a running event loop. + POLL_S = 0.001 + + def __init__(self, path: str = '/stream', messages=None) -> None: + self.request = FakeRequest(path) + self.sent: list = [] + self.closed: list = [] + self._pending: list = list(messages or []) + self._eof = False + + # -- recorders --------------------------------------------------------- + async def send(self, payload) -> None: + self.sent.append(payload) + + async def close(self, code=1000, reason='') -> None: + self.closed.append((code, reason)) + self.eof() + + # -- test helpers ------------------------------------------------------ + def push(self, message) -> None: + self._pending.append(message) + + def eof(self) -> None: + self._eof = True + + def json_sent(self) -> list: + import json + + out = [] + for item in self.sent: + if isinstance(item, str): + try: + out.append(json.loads(item)) + except ValueError: + pass + return out + + def hello_count(self) -> int: + return len([m for m in self.json_sent() if m.get('type') == 'hello']) + + # -- async iterator ---------------------------------------------------- + def __aiter__(self): + return self + + async def __anext__(self): + while True: + if self._pending: + return self._pending.pop(0) + if self._eof: + raise StopAsyncIteration + await asyncio.sleep(self.POLL_S) + + +def auth_frame(token: str = TOKEN) -> str: + import json + + return json.dumps({'type': 'auth', 'token': token}) + + +def make_agent(ffmpeg: str = '') -> object: + return agent_mod.Agent( + ffmpeg=ffmpeg or sys.executable, + token=TOKEN, + fps=30, + bitrate=1_000_000, + display=':0', + bind='127.0.0.1', + port=6090, + ) + + +async def settle(times: int = 20) -> None: + """Let the polling FakeWS iterator and the handler make progress.""" + for _ in range(times): + await asyncio.sleep(0.005) + + +class TestExpGolomb(unittest.TestCase): + def test_ue_vectors(self): + vectors = [ + ('1', 0), + ('010', 1), + ('011', 2), + ('00100', 3), + ('00111', 6), + ('0001000', 7), + ] + for bits, expected in vectors: + with self.subTest(bits=bits): + val, pos = agent_mod._ue(bits_to_bytes(bits), 0) + self.assertEqual(val, expected) + self.assertEqual(pos, len(bits)) + + def test_ue_runs_out_of_bits(self): + val, _pos = agent_mod._ue(bits_to_bytes('0000'), 0) + self.assertIsNone(val) + + +class TestIsISlice(unittest.TestCase): + def test_idr_is_key(self): + self.assertTrue(agent_mod.is_i_slice(bytes([5, 0x80]))) + + def test_non_idr_i_slice_type_2(self): + # first_mb_in_slice = ue(0) = '1', slice_type = ue(2) = '011' + self.assertTrue(agent_mod.is_i_slice(slice_nal(1, '1', '011'))) + + def test_non_idr_i_slice_type_7(self): + # slice_type = ue(7) = '0001000' + self.assertTrue(agent_mod.is_i_slice(slice_nal(1, '1', '0001000'))) + + def test_b_slice_type_1(self): + # slice_type = ue(1) = '010' + self.assertFalse(agent_mod.is_i_slice(slice_nal(1, '1', '010'))) + + def test_b_slice_type_6(self): + # slice_type = ue(6) = '00111' + self.assertFalse(agent_mod.is_i_slice(slice_nal(1, '1', '00111'))) + + +class TestAuth(unittest.TestCase): + def run_handler(self, ws) -> None: + async def go(): + agent = make_agent() + await asyncio.wait_for(agent.handler(ws), timeout=5) + + asyncio.run(go()) + + def test_good_auth_frame_gets_hello(self): + ws = FakeWS('/stream', [auth_frame()]) + ws.eof() + self.run_handler(ws) + self.assertEqual(ws.hello_count(), 1) + self.assertEqual(ws.closed, []) + + def test_bad_token_in_auth_frame_closes(self): + ws = FakeWS('/stream', [auth_frame('deadbeef')]) + ws.eof() + self.run_handler(ws) + self.assertEqual(ws.hello_count(), 0) + self.assertEqual([c[0] for c in ws.closed], [agent_mod.CLOSE_AUTH]) + + def test_non_auth_first_frame_closes(self): + ws = FakeWS('/stream', ['{"type":"ping"}']) + ws.eof() + self.run_handler(ws) + self.assertEqual(ws.hello_count(), 0) + self.assertEqual([c[0] for c in ws.closed], [agent_mod.CLOSE_AUTH]) + + def test_garbage_first_frame_closes(self): + ws = FakeWS('/stream', ['not json at all']) + ws.eof() + self.run_handler(ws) + self.assertEqual(ws.hello_count(), 0) + self.assertEqual([c[0] for c in ws.closed], [agent_mod.CLOSE_AUTH]) + + def test_binary_first_frame_closes(self): + ws = FakeWS('/stream', [b'\x00\x01\x02']) + ws.eof() + self.run_handler(ws) + self.assertEqual(ws.hello_count(), 0) + self.assertEqual([c[0] for c in ws.closed], [agent_mod.CLOSE_AUTH]) + + def test_auth_timeout_closes(self): + async def go(): + agent = make_agent() + ws = FakeWS('/stream', []) # never sends anything + saved = agent_mod.AUTH_TIMEOUT_S + agent_mod.AUTH_TIMEOUT_S = 0.05 + try: + await asyncio.wait_for(agent.handler(ws), timeout=5) + finally: + agent_mod.AUTH_TIMEOUT_S = saved + return ws + + ws = asyncio.run(go()) + self.assertEqual(ws.hello_count(), 0) + self.assertEqual([c[0] for c in ws.closed], [agent_mod.CLOSE_AUTH]) + + def test_legacy_query_token_still_works(self): + ws = FakeWS('/stream?token=' + TOKEN, []) + ws.eof() + self.run_handler(ws) + self.assertEqual(ws.hello_count(), 1) + self.assertEqual(ws.closed, []) + + def test_legacy_query_token_bad_closes(self): + ws = FakeWS('/stream?token=nope', []) + ws.eof() + self.run_handler(ws) + self.assertEqual(ws.hello_count(), 0) + self.assertEqual([c[0] for c in ws.closed], [agent_mod.CLOSE_AUTH]) + + def test_wrong_path_is_not_found(self): + ws = FakeWS('/nope', []) + ws.eof() + self.run_handler(ws) + self.assertEqual([c[0] for c in ws.closed], [agent_mod.CLOSE_NOT_FOUND]) + + +class TestClientGeneration(unittest.TestCase): + """A superseded client must not spawn over the live client's pipeline.""" + + def _run(self): + spawns: list = [] + + async def fake_exec(*argv, **kwargs): + spawns.append(list(argv)) + raise FileNotFoundError('stubbed: no ffmpeg here') + + async def go(): + agent = make_agent() + agent.candidates = [agent_mod.Candidate('stub', [sys.executable, '-c', 'pass'])] + agent.cached = agent.candidates[0] + first = FakeWS('/stream', [auth_frame()]) + second = FakeWS('/stream', [auth_frame()]) + + task_a = asyncio.create_task(agent.handler(first)) + await settle() + self.assertEqual(first.hello_count(), 1, 'first client did not attach') + gen_a = agent.client_gen + + task_b = asyncio.create_task(agent.handler(second)) + await settle() + self.assertEqual(second.hello_count(), 1, 'second client did not attach') + self.assertGreater(agent.client_gen, gen_a) + self.assertEqual( + [c[0] for c in first.closed], [agent_mod.CLOSE_SUPERSEDED] + ) + + # The superseded client's late 'start' must spawn nothing. + first.push('{"type":"start"}') + await settle() + stale_spawns = list(spawns) + + # Control: the live client's 'start' does reach the spawn. + second.push('{"type":"start"}') + await settle() + live_spawns = list(spawns) + + first.eof() + second.eof() + await asyncio.wait_for(asyncio.gather(task_a, task_b), timeout=5) + return stale_spawns, live_spawns + + old_exec = asyncio.create_subprocess_exec + asyncio.create_subprocess_exec = fake_exec + try: + return asyncio.run(go()) + finally: + asyncio.create_subprocess_exec = old_exec + + def test_stale_start_does_not_spawn(self): + stale_spawns, live_spawns = self._run() + self.assertEqual(stale_spawns, [], 'superseded client spawned a pipeline') + self.assertEqual(len(live_spawns), 1, 'live client did not spawn') + + def test_stale_start_ffmpeg_returns_superseded(self): + async def go(): + agent = make_agent() + first = FakeWS('/stream') + second = FakeWS('/stream') + agent.client = first + agent.client_gen = 1 + gen_a = agent.client_gen + agent.client = second + agent.client_gen = 2 + agent.candidates = [agent_mod.Candidate('stub', ['x'])] + agent.cached = agent.candidates[0] + return await agent.start_ffmpeg(first, gen_a) + + self.assertEqual(asyncio.run(go()), 'superseded') + + def test_stale_stop_ffmpeg_is_ignored(self): + async def go(): + agent = make_agent() + first = FakeWS('/stream') + second = FakeWS('/stream') + agent.client = first + agent.client_gen = 1 + agent.drop_until_key = True + marker = object() + agent.proc = marker # type: ignore[assignment] + agent.client = second + agent.client_gen = 2 + await agent.stop_ffmpeg(first, 1) + return agent.proc is marker + + self.assertTrue(asyncio.run(go()), 'stale stop tore down the live pipeline') + + +if __name__ == '__main__': + unittest.main() diff --git a/install.sh b/install.sh index 3f47afa..06fb403 100755 --- a/install.sh +++ b/install.sh @@ -201,6 +201,9 @@ if [ "${KIT_SKIP_COMPUTER:-0}" != "1" ]; then need "computer-viewer/connect-mac.sh" need "computer-viewer/connect-linux.sh" need "computer-viewer/hiperf-mac.sh" + need "computer-viewer/hiperf-linux.sh" + need "computer-viewer/connect-windows.ps1" + need "computer-viewer/hiperf-windows.ps1" need "computer-viewer/hiperf-agent.py" fi if [ "${KIT_SKIP_SECTIONS:-0}" != "1" ]; then diff --git a/scripts/release.sh b/scripts/release.sh index a34d57e..412dd68 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -7,12 +7,13 @@ ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) cd "${ROOT}" usage() { - echo "Usage: scripts/release.sh vYYYY.MM.DD" >&2 + echo "Usage: scripts/release.sh vYYYY.MM.DD[.N]" >&2 } TAG="${1:-}" case "${TAG}" in v[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9]) ;; + v[0-9][0-9][0-9][0-9].[0-9][0-9].[0-9][0-9].[1-9]) ;; # same-day follow-up: vYYYY.MM.DD.N *) usage exit 2 @@ -91,7 +92,10 @@ run_ci_tests() { node --check computer-viewer/plugin.js node --test bubble-mode/plugin.test.mjs task-dock/plugin.test.mjs computer-viewer/plugin.test.mjs python3 -m unittest discover -s computer-viewer/agent-plugin/orgo-computer/tests -p 'test_*.py' - find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 bash -n + # -n 1: without it xargs passes every later path as an ARGUMENT to the + # first script, so only one file was ever parsed. + find . -name '*.sh' -not -path './.git/*' -print0 | xargs -0 -n 1 bash -n + python3 computer-viewer/tests/test-hiperf-agent.py bash computer-viewer/tests/test-bind.sh bash computer-viewer/tests/test-install-agent-plugin.sh python3 -c ' diff --git a/tests/test-install.sh b/tests/test-install.sh index df5e340..a89bb51 100755 --- a/tests/test-install.sh +++ b/tests/test-install.sh @@ -73,10 +73,14 @@ assert_plugin "${home}" "computer-viewer/plugin.js" assert_plugin "${home}" "computer-viewer/connect-mac.sh" assert_plugin "${home}" "computer-viewer/connect-linux.sh" assert_plugin "${home}" "computer-viewer/hiperf-mac.sh" +assert_plugin "${home}" "computer-viewer/hiperf-linux.sh" +assert_plugin "${home}" "computer-viewer/connect-windows.ps1" +assert_plugin "${home}" "computer-viewer/hiperf-windows.ps1" assert_plugin "${home}" "computer-viewer/hiperf-agent.py" assert_exec "${home}/desktop-plugins/computer-viewer/connect-mac.sh" assert_exec "${home}/desktop-plugins/computer-viewer/connect-linux.sh" assert_exec "${home}/desktop-plugins/computer-viewer/hiperf-mac.sh" +assert_exec "${home}/desktop-plugins/computer-viewer/hiperf-linux.sh" if awk '$2 == "computer-viewer/vendor/novnc-rfb.mjs" { found=1 } END { exit found ? 0 : 1 }' "${MANIFEST}"; then assert_plugin "${home}" "computer-viewer/vendor/novnc-rfb.mjs" fi