diff --git a/packages/cli/src/lablink_cli/commands/doctor.py b/packages/cli/src/lablink_cli/commands/doctor.py index 5e9e30bef..d37e5d148 100644 --- a/packages/cli/src/lablink_cli/commands/doctor.py +++ b/packages/cli/src/lablink_cli/commands/doctor.py @@ -289,6 +289,44 @@ def _check_default_client_ami(region: str, published: str | None) -> dict: return result +def _check_viewer_streaming(cfg) -> dict: + """Warn when the viewer will be served over plain HTTP. + + KasmVNC's H.264 path needs the browser's WebCodecs VideoDecoder, which Chrome + exposes only on secure origins. With ssl.provider "none" the viewer is served + over http://, the codec probe reports "WebCodecs API not available", and every + session falls back to JPEG/WebP stills — re-encoding damaged regions from + scratch every frame instead of encoding inter-frame change. + + Nothing else surfaces this. The deploy is green, sessions work, and the + server-side encoder is fine; it simply never gets asked for video mode. The + symptom reaches an operator as "the desktop feels laggy", which is a long way + from "ssl.provider is none". + """ + result = {"check": "Viewer streaming", "status": "pass"} + + if cfg is None: + result["status"] = "warn" + result["detail"] = "Skipped (no valid config)" + return result + + provider = (getattr(getattr(cfg, "ssl", None), "provider", "") or "none").lower() + + if provider == "none": + result["status"] = "warn" + result["detail"] = ( + "ssl.provider is 'none', so the viewer is served over HTTP and H.264 " + "streaming cannot engage — sessions fall back to JPEG/WebP. Configure an " + "SSL provider, or to test as-is port-forward the allocator " + "(ssh -L 8443:localhost:5000 ...) and open http://localhost:8443, since " + "localhost counts as a secure origin" + ) + return result + + result["detail"] = f"H.264 available — viewer served over HTTPS ({provider})" + return result + + def _check_ami(cfg) -> dict: """Check that the configured client AMI exists in the configured region. @@ -378,6 +416,9 @@ def _check_aws_prereqs() -> None: # 6. Client AMI checks.append(_check_ami(cfg)) + # 7. Viewer streaming + checks.append(_check_viewer_streaming(cfg)) + _render_checks( checks, pass_message=( diff --git a/packages/cli/tests/test_doctor.py b/packages/cli/tests/test_doctor.py index 644b73d62..7c27f6f42 100644 --- a/packages/cli/tests/test_doctor.py +++ b/packages/cli/tests/test_doctor.py @@ -5,8 +5,11 @@ import json from unittest.mock import MagicMock, patch +import pytest + from lablink_cli.commands.doctor import ( + _check_viewer_streaming, _check_ami, _check_opentofu, ) @@ -438,3 +441,42 @@ def test_renders_all_three_checks(self, capsys): assert "Registered" in out assert "Log shipper" in out assert "All checks passed" in out + + +# ------------------------------------------------------------------ +# _check_viewer_streaming +# ------------------------------------------------------------------ +def _ssl_cfg(provider): + cfg = MagicMock() + cfg.ssl.provider = provider + return cfg + + +class TestCheckViewerStreaming: + def test_no_config(self): + result = _check_viewer_streaming(None) + assert result["status"] == "warn" + + def test_http_deployment_warns_with_a_way_to_test(self): + """The whole point: an HTTP deployment silently loses H.264, and the + operator sees 'laggy desktop', not 'ssl.provider is none'.""" + result = _check_viewer_streaming(_ssl_cfg("none")) + assert result["status"] == "warn" + assert "JPEG/WebP" in result["detail"] + assert "localhost" in result["detail"], "must name the port-forward workaround" + + def test_warns_rather_than_fails(self): + """HTTP is a supported deployment, just a slower one — failing preflight + over a performance cliff would block a legitimate config.""" + assert _check_viewer_streaming(_ssl_cfg("none"))["status"] != "fail" + + @pytest.mark.parametrize("provider", ["letsencrypt", "cloudflare", "acm"]) + def test_https_providers_pass(self, provider): + result = _check_viewer_streaming(_ssl_cfg(provider)) + assert result["status"] == "pass" + assert provider in result["detail"] + + def test_missing_ssl_section_is_treated_as_http(self): + cfg = MagicMock() + cfg.ssl.provider = None + assert _check_viewer_streaming(cfg)["status"] == "warn"