diff --git a/src/agent_env/env/envs/computer/utils/sites.py b/src/agent_env/env/envs/computer/utils/sites.py index 040bfe734..931d902d8 100644 --- a/src/agent_env/env/envs/computer/utils/sites.py +++ b/src/agent_env/env/envs/computer/utils/sites.py @@ -8,12 +8,13 @@ site's names (``issue``), and the site proxy (``site_proxy.py``) is installed through the sandbox's root channel and started (``route``), which a reset does again on its fresh computer. In the computer they live in ``/etc/agentenv/sites``, root's alone (0700) before the config and its keys are written into it, until the install has -made the keys and the config private; the CA and certificates are valid for the deploy's ttl and a day more, from a few -minutes back for a clock that's behind. The CA also joins the trust agentenv adds, two files at fixed paths for each OS -site routing reaches (``TRUST_ENVIRONMENTS``), which on Linux are the local provider's (``LOCAL_TRUST_ENV``): -``/etc/agentenv/ca.pem``, every CA agentenv adds, for an app with a certificate list of its own, and -``/etc/agentenv/ca-bundle.pem``, the public roots and them. The computer's boot environment names them, so every program -in it trusts the sites however it was started. Site routing reaches Linux computers alone.""" +made the keys and the config private; the CA and certificates are valid from 1950 to the end of 9999 (``UNIVERSE_FROM`` +to ``UNIVERSE_UNTIL``), since its programs check them against the universe's time. The CA also +joins the trust agentenv adds, two files at fixed paths for each OS site routing reaches (``TRUST_ENVIRONMENTS``), +which on Linux are the local provider's (``LOCAL_TRUST_ENV``): ``/etc/agentenv/ca.pem``, every CA agentenv adds, for +an app with a certificate list of its own, and ``/etc/agentenv/ca-bundle.pem``, the public roots and them. The +computer's boot environment names them, so every program in it trusts the sites however it was started. Site routing +reaches Linux computers alone.""" from __future__ import annotations @@ -26,7 +27,7 @@ import time from collections.abc import Collection from dataclasses import dataclass, field -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING, Iterable, Optional from urllib.parse import urlsplit @@ -69,8 +70,12 @@ NETWORK_LOG = FOLDER / "network.jsonl" SYNTHETIC, ALLOWLIST = "synthetic", "allowlist" RANKS = (SYNTHETIC, ALLOWLIST) -VALID_MARGIN = timedelta(days=1) -CLOCK_SKEW = timedelta(minutes=5) +#: The dates the sites' CA and certificates are valid between, the widest a certificate carries: from 1950, the +#: earliest X.509's UTCTime holds, to the end of 9999, RFC 5280's date for one with no expiry. A program on the computer +#: checks them against the universe's time, so they hold however far the universe is from now. The CA is +#: name-constrained and its key gone once they're signed, so the wide window vouches for nothing more. +UNIVERSE_FROM = datetime(1950, 1, 1, tzinfo=UTC) +UNIVERSE_UNTIL = datetime(9999, 12, 31, 23, 59, 59, tzinfo=UTC) CARD_ATTEMPTS = 3 CARD_RETRY_S = 1.0 #: How long a computer's deploy waits for the run's websites it routes to say they're ready, as each said at its own @@ -228,14 +233,14 @@ class Issued: leaves: dict[str, tuple[str, str]] -def issue(routes: list[Route], *, valid_for: timedelta, label: str, now: Optional[datetime] = None) -> Issued: +def issue(routes: list[Route], *, label: str) -> Issued: """A CA name-constrained to the routes' names, so it vouches for nothing else however widely it's trusted, and a - certificate signed by it for each route's names served over HTTPS, all valid ``valid_for`` and a margin more.""" + certificate signed by it for each route's names served over HTTPS, all valid from ``UNIVERSE_FROM`` to + ``UNIVERSE_UNTIL``.""" tls = [route for route in routes if route.tls_names] if not tls: return Issued(None, {}) - now = now or datetime.now(UTC) - start, end = now - CLOCK_SKEW, now + valid_for + VALID_MARGIN + start, end = UNIVERSE_FROM, UNIVERSE_UNTIL ca_key = ec.generate_private_key(ec.SECP256R1()) subject = x509.Name([x509.NameAttribute(NameOID.ORGANIZATION_NAME, "agentenv"), x509.NameAttribute(NameOID.COMMON_NAME, f"agentenv sites {label}"[:64])]) @@ -430,7 +435,7 @@ async def _exec(self, argv: list[str]) -> str: async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], network: Optional[dict], - valid_for: timedelta, label: str, os_type: str, display: str, live_hosts: Iterable[str] = ()) -> dict: + label: str, os_type: str, display: str, live_hosts: Iterable[str] = ()) -> dict: """Have the ``os_type`` computer on ``sandbox`` reach each of ``sites`` by its names: the table made, the CA and certificates issued, the proxy installed and started as root, with its forward port on a computer with no ``display``, a browser alone's, which also takes the run's ``live_hosts`` straight to them, and each name checked @@ -446,7 +451,7 @@ async def route(sandbox: Sandbox, sites: list[Site], *, user: Optional[str], net return {"names": table.to_dict()} from agent_env.providers.sandbox_providers.sandbox_provider import sandbox_request_headers_for_url - issued = issue(table.routes, valid_for=valid_for, label=label) + issued = issue(table.routes, label=label) upstream_headers = {r.site: sandbox_request_headers_for_url(r.url) for r in table.routes} channel = RootChannel(sandbox) await channel.private_folder(FOLDER) diff --git a/src/agent_env/providers/env_providers/env_computer_provider.py b/src/agent_env/providers/env_providers/env_computer_provider.py index 0176ed859..a4090fd43 100644 --- a/src/agent_env/providers/env_providers/env_computer_provider.py +++ b/src/agent_env/providers/env_providers/env_computer_provider.py @@ -6,7 +6,7 @@ import asyncio import functools import logging -from datetime import datetime, timedelta, timezone +from datetime import datetime, timezone from typing import TYPE_CHECKING, ClassVar, Optional from urllib.parse import urlsplit @@ -68,7 +68,7 @@ class EnvironmentComputerProvider(_SandboxEnvironmentProvider): def __init__(self) -> None: super().__init__() - self._later: dict[str, tuple[Sandbox, int]] = {} # each computer booted with sites_later: its sandbox and CA lifetime + self._later: dict[str, Sandbox] = {} # each computer booted with sites_later: its sandbox async def deploy( self, @@ -131,13 +131,13 @@ async def deploy( async def route_sites(self, env: ComputerEnv, record: DeployedSandboxEnv, sites: list[dict]) -> None: """Route ``sites`` on the computer ``record`` names, which this provider booted with ``sites_later``, launch its held apps and finish its boot as a deploy does; its record then keeps them, so a reset routes them again.""" - sandbox, valid_for_s = self._later.pop(record.sandbox_id) + sandbox = self._later.pop(record.sandbox_id) computer = record.metadata["computer"] params, _, problems = computer_params.checked(env, {**computer["env_params"], "sites": sites}) if problems: raise ValueError(f"{env.id}: " + "; ".join(problems)) computer["env_params"] = params.model_dump(exclude_defaults=True) - if routed := await _finish_boot(env, record, sandbox, params, valid_for_s, computer.get("display")): + if routed := await _finish_boot(env, record, sandbox, params, computer.get("display")): computer["sites"] = routed async def follow_clock(self, record: DeployedSandboxEnv, gateway_url: str, name: str) -> None: @@ -211,8 +211,8 @@ async def _boot(self, env: ComputerEnv, builds: list[tuple[str, str, dict]], par "env_params": params.model_dump(exclude_defaults=True)}}, ) if sites_later: - self._later[record.sandbox_id] = (booted.sandbox, ttl_seconds) - elif routed := await _finish_boot(env, record, booted.sandbox, params, ttl_seconds, values.get("resolution")): + self._later[record.sandbox_id] = booted.sandbox + elif routed := await _finish_boot(env, record, booted.sandbox, params, values.get("resolution")): record.metadata["computer"]["sites"] = routed except BaseException: await discard(booted.sandbox) @@ -245,14 +245,12 @@ async def reboot(self, env: ComputerEnv, deployed: DeployedSandboxEnv) -> None: card = await ready(deployed.environment_url, ready_timeout(env.composed_apps(), platform_of(env.computer.os)), deployed.control_token, answering=bool(params.site_routing), sandbox=sandbox) _require_name(env, card) - valid_for_s = (record.get("sites") or {}).get("valid_for_s", 0) - if routed := await _finish_boot(env, deployed, sandbox, params, valid_for_s, values.get("resolution")): + if routed := await _finish_boot(env, deployed, sandbox, params, values.get("resolution")): record["sites"] = routed async def _finish_boot(env: ComputerEnv, record: DeployedSandboxEnv, sandbox: Sandbox, - params: computer_params.ComputerParams, valid_for_s: int, - resolution: Optional[str]) -> Optional[dict]: + params: computer_params.ComputerParams, resolution: Optional[str]) -> Optional[dict]: """What a deploy and a reset do once the computer's driver answers, in order: the clock follower's config written again when the record keeps one (a reset's); with ``site_routing``, the sites routed, each browser handed their names and the held boot apps launched, the computer then ready; then its display checked; then each browser's start @@ -261,8 +259,7 @@ async def _finish_boot(env: ComputerEnv, record: DeployedSandboxEnv, sandbox: Sa await computer_clock.reconfigure(sandbox, record) routed = None if params.site_routing: - routed = await _route_sites(env, sandbox, params, valid_for_s, - tuple(record.metadata["computer"].get("live_hosts") or ())) + routed = await _route_sites(env, sandbox, params, tuple(record.metadata["computer"].get("live_hosts") or ())) await _launch_held_apps(env, record, record.environment_url, params.sites) await _require_display(record, resolution) if start_tabs := await _start_tabs(record): @@ -272,13 +269,12 @@ async def _finish_boot(env: ComputerEnv, record: DeployedSandboxEnv, sandbox: Sa async def _route_sites(env: ComputerEnv, sandbox: Sandbox, params: computer_params.ComputerParams, - valid_for_s: int, live_hosts: tuple[str, ...] = ()) -> dict: - """The computer on ``sandbox`` routed to ``params.sites`` under a CA of its own, valid ``valid_for_s``, a browser - alone's forward port taking ``live_hosts`` straight to them; what its record keeps of it.""" - routed = await sites.route(sandbox, params.sites, user=env.computer.session.user, network=env.network, - valid_for=timedelta(seconds=valid_for_s), label=env.environment_name, - os_type=env.computer.os.type, display=env.computer.os.display, live_hosts=live_hosts) - return {**routed, "valid_for_s": valid_for_s} + live_hosts: tuple[str, ...] = ()) -> dict: + """The computer on ``sandbox`` routed to ``params.sites`` under a CA of its own, a browser alone's forward port + taking ``live_hosts`` straight to them; what its record keeps of it.""" + return await sites.route(sandbox, params.sites, user=env.computer.session.user, network=env.network, + label=env.environment_name, os_type=env.computer.os.type, + display=env.computer.os.display, live_hosts=live_hosts) async def _launch_held_apps(env: ComputerEnv, record: DeployedSandboxEnv, url: str, diff --git a/tst/integration/computer/clock_desktop_test.py b/tst/integration/computer/clock_desktop_test.py index 8b671d1db..0a604b9f8 100644 --- a/tst/integration/computer/clock_desktop_test.py +++ b/tst/integration/computer/clock_desktop_test.py @@ -5,7 +5,8 @@ Python reach a live site, its certificate checked against real time by the TLS shim; a shell, Chrome's JavaScript and the driver read universe time through 30 forward jumps of 8 h, a day or a week, a rate change and a freeze; Chrome, Xvfb and the desktop's panel run on through them; and the driver's input reaches Chrome after every jump and, frozen, -is drawn. Needs Docker and the local registry on localhost:5000.""" +is drawn. Routing a site, a fresh Chrome loads it over HTTPS with the clock six years back, six years on and past +2099, its certificate checked against the universe's time. Needs Docker and the local registry on localhost:5000.""" import ast import asyncio @@ -28,6 +29,7 @@ from agent_env.task_step.context import TaskStepContext from tst.clock.bounds import LATE_S from tst.integration.computer.conftest import PAGE_WRITER +from tst.integration.computer.start_tabs import host_pages from tst.integration.computer.website_browser_test import _window_center pytestmark = [pytest.mark.integration, pytest.mark.int_test_slow] @@ -50,6 +52,12 @@ "python": ("python3 -c \"import urllib.request; urllib.request.build_opener(urllib.request.ProxyHandler({}))" ".open('https://example.com/', timeout=30)\"")} LIBRARY = str(PurePosixPath(FOLLOWER).parent / "libfaketimeMT.so.1") +#: A fresh Chrome, as the session user, its own profile beside the desktop's, printing the DOM of the page it's given: +#: each run makes a TLS handshake of its own, which checks the site's certificate against the universe's time (Chrome's +#: TLS isn't OpenSSL, so the TLS shim doesn't reach it). +FRESH_CHROME = ('profile=$(mktemp -d) && /opt/agentenv/apps/chromium/chromium/chrome/chrome --headless=new ' + '--no-sandbox --user-data-dir="$profile" --dump-dom "$0"; rm -rf "$profile"') +ROUTED = "https://shop.test/landed" def test_composed_with_the_library_in_every_process_the_computer_passes_the_probe_at_full(clock_desktop): @@ -120,6 +128,30 @@ async def test_the_desktop_follows_the_universe_through_jumps_a_rate_change_and_ assert not report.failed +@pytest.mark.asyncio +async def test_a_fresh_chrome_loads_a_routed_site_six_years_either_side_of_now_and_past_2099(clock_desktop): + with host_pages() as port, namespace_routing(): + params = {"site_routing": True, "sites": [{"name": "shop", "url": f"http://127.0.0.1:{port}/website/shop/", + "domains": ["shop.test"]}]} + deployed = await Env.get(clock_desktop.id).deploy(env_params=params) + context = TaskStepContext(deployed_envs=[deployed]) + try: + clock, loaded = _Clock(deployed), {} + for years in (-6, 6, 80): + clock.set(time.time() + years * 365 * DAY, 1) + await asyncio.sleep(2) # each program maps the page the next time it reads the clock, within a second + ran = await inside.run(deployed, ["sh", "-c", FRESH_CHROME, ROUTED], timeout_s=90) + loaded[years] = {"universe": await clock.read(deployed), + "landed": "landed" in ran["stdout"], + "output": (ran["stdout"] + ran.get("stderr", ""))[-600:]} + finally: + report = await teardown_run(context) + + assert all(shown["universe"] for shown in loaded.values()), loaded + assert {years: shown["landed"] for years, shown in loaded.items()} == {-6: True, 6: True, 80: True}, loaded + assert not report.failed + + class _Clock: """The computer's clock page, written as its follower's user, as its follower writes it, and what the session reads of it.""" diff --git a/tst/unit/env/envs/test_computer_env.py b/tst/unit/env/envs/test_computer_env.py index 838dbd3be..e3fdf992b 100644 --- a/tst/unit/env/envs/test_computer_env.py +++ b/tst/unit/env/envs/test_computer_env.py @@ -15,7 +15,6 @@ import shutil import subprocess import urllib.parse -from datetime import timedelta from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock @@ -1144,11 +1143,11 @@ async def test_a_computer_with_site_routing_reaches_the_websites_the_run_deploye assert sandbox is boots.sandboxes[0] assert [site.model_dump() for site in sites] == [{"name": "notes", "url": "http://localhost:8080/website/notes/", "domains": ["notes.test"]}] - assert options == {"user": "user", "network": None, "valid_for": timedelta(seconds=3600), "label": "desktop", - "os_type": "linux", "display": "x11", "live_hosts": ()} + assert options == {"user": "user", "network": None, "label": "desktop", "os_type": "linux", "display": "x11", + "live_hosts": ()} assert boots.boots[0]["environment"][HOLD_BOOT_LAUNCH_ENV] == "1" record = context.deployed_envs[-1].metadata["computer"] - assert record["sites"] == {"names": {"routes": {"notes": ["notes.test"]}}, "ca": "CA PEM", "valid_for_s": 3600} + assert record["sites"] == {"names": {"routes": {"notes": ["notes.test"]}}, "ca": "CA PEM"} assert record["env_params"]["site_routing"] is True and record["env_params"]["sites"][0]["name"] == "notes" @@ -1166,9 +1165,8 @@ async def test_a_computer_deployed_to_route_its_sites_later_boots_held_and_route assert boots.boots[0]["environment"].items() >= computer_sites.TRUST_ENVIRONMENTS["linux"].items() ((sandbox, sites, options),) = routes assert sandbox is boots.sandboxes[0] and [site.name for site in sites] == ["notes"] - assert options["valid_for"] == timedelta(seconds=3600) record = deployed.metadata["computer"] - assert record["env_params"]["sites"][0]["name"] == "notes" and record["sites"]["valid_for_s"] == 3600 + assert record["env_params"]["sites"][0]["name"] == "notes" and record["sites"]["ca"] == "CA PEM" @pytest.mark.asyncio @@ -1390,7 +1388,7 @@ async def test_a_start_tab_on_one_of_the_env_sites_that_didnt_load_fails_the_boo with pytest.raises(RuntimeError) as failed: await env_computer_provider._finish_boot(SimpleNamespace(), record, None, - SimpleNamespace(site_routing=False), 0, None) + SimpleNamespace(site_routing=False), None) assert record.metadata["computer"]["start_tabs"] == {"chromium": tabs} assert str(failed.value) == ( @@ -1435,8 +1433,8 @@ async def test_a_reset_routes_the_fresh_computer_to_the_same_sites_under_a_ca_of await ResetEnvTaskStep(id="reset", version=None, env_id=env.id, fail_task_on_error=True).execute(context) first, again = routes - assert again[0] is sandbox and again[1] == first[1] and again[2]["valid_for"] == timedelta(seconds=3600) - assert context.deployed_envs[-1].metadata["computer"]["sites"]["valid_for_s"] == 3600 + assert again[0] is sandbox and again[1] == first[1] and again[2] == first[2] + assert context.deployed_envs[-1].metadata["computer"]["sites"]["ca"] == "CA PEM" @pytest.mark.asyncio diff --git a/tst/unit/env/envs/test_computer_site_proxy.py b/tst/unit/env/envs/test_computer_site_proxy.py index 5fd2000a2..a6818f9e3 100644 --- a/tst/unit/env/envs/test_computer_site_proxy.py +++ b/tst/unit/env/envs/test_computer_site_proxy.py @@ -18,7 +18,6 @@ import sys import time import urllib.error -from datetime import timedelta from pathlib import Path from types import SimpleNamespace @@ -121,7 +120,7 @@ async def _exchange(port, request: bytes, *, server_hostname=None, cadata=None, def _issued(tmp_path, routes): - issued = sites.issue(routes, valid_for=timedelta(hours=1), label="test") + issued = sites.issue(routes, label="test") files = {} for name, (cert, key) in issued.leaves.items(): (tmp_path / f"{name}.crt").write_text(cert) @@ -875,7 +874,7 @@ def test_install_writes_the_sites_files_names_and_trust_then_starts_the_proxy_on table = sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", domains=["notes.test"]), SiteParams(name="chat", url="http://gw/website/chat/", domains=["*.chat.test"]), SiteParams(name="bare", url="http://gw/website/bare/")]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + issued = sites.issue(table.routes, label="t") (folder / "config.json").write_text(json.dumps(sites.proxy_config(table, issued, user=None, os_type="linux", forward_port=True, direct=["github.com"]))) started = [] @@ -922,7 +921,7 @@ def test_install_keeps_the_upstream_headers_in_a_file_root_alone_reads_and_serve folder.mkdir() folder.chmod(0o700) table = sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", domains=["notes.test"])]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + issued = sites.issue(table.routes, label="t") given = sites.proxy_config(table, issued, user=None, os_type="linux", forward_port=False, upstream_headers={"notes": {"CF-Access-Client-Secret": "s3cret"}}) (folder / "config.json").write_text(json.dumps(given)) @@ -1061,8 +1060,7 @@ def _calls(store: Path) -> list[str]: def test_every_jdk_trusts_the_ca_an_apt_one_through_the_system_hook_the_rest_by_keytool(tmp_path, monkeypatch): issued = sites.issue(sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", - domains=["notes.test"])]).routes, valid_for=timedelta(hours=1), - label="t") + domains=["notes.test"])]).routes, label="t") fingerprint = site_proxy.sha256_fingerprint(issued.ca_pem) ca = tmp_path / "ca.pem" ca.write_text(issued.ca_pem) @@ -1101,8 +1099,7 @@ def test_a_jdk_store_the_ca_isnt_listed_in_after_routing_is_named(tmp_path, monk apt.unlink() apt.symlink_to(hook) issued = sites.issue(sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", - domains=["notes.test"])]).routes, valid_for=timedelta(hours=1), - label="t") + domains=["notes.test"])]).routes, label="t") trusted = site_proxy.trust_java(site_proxy.Paths(root), str(tmp_path / "ca.pem"), issued.ca_pem) @@ -1120,8 +1117,7 @@ def test_each_runtime_loads_the_first_https_site_through_its_own_trust_and_says_ monkeypatch.delenv("SSL_CERT_FILE", raising=False) unset = site_proxy.check_runtimes("https://notes.test/") issued = sites.issue(sites.site_table([SiteParams(name="notes", url="http://gw/website/notes/", - domains=["notes.test"])]).routes, valid_for=timedelta(hours=1), - label="t") + domains=["notes.test"])]).routes, label="t") (tmp_path / "bundle.pem").write_text(issued.ca_pem) monkeypatch.setenv("SSL_CERT_FILE", str(tmp_path / "bundle.pem")) opened = [] @@ -1153,7 +1149,7 @@ def test_a_wildcard_name_starts_dns_even_when_resolv_conf_names_no_other_nameser if resolv is not None: (root / "etc/resolv.conf").write_text(resolv) table = sites.site_table([SiteParams(name="chat", url="http://gw/website/chat/", domains=["*.chat.test"])]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=1), label="t") + issued = sites.issue(table.routes, label="t") (folder / "config.json").write_text(json.dumps(sites.proxy_config(table, issued, user=None, os_type="linux", forward_port=False))) started = [] diff --git a/tst/unit/env/envs/test_computer_sites.py b/tst/unit/env/envs/test_computer_sites.py index bf90a5485..85937cfe1 100644 --- a/tst/unit/env/envs/test_computer_sites.py +++ b/tst/unit/env/envs/test_computer_sites.py @@ -101,13 +101,12 @@ def test_a_deploys_ca_signs_each_sites_names_and_is_constrained_to_them(): table = sites.site_table([_site("notes", "notes.test"), _site("slack", "slack.com", "*.slack.com"), _site("bare")]) - issued = sites.issue(table.routes, valid_for=timedelta(hours=3), label="desktop", now=now) + issued = sites.issue(table.routes, label="desktop") assert set(issued.leaves) == {"notes", "slack"} ca = x509.load_pem_x509_certificate(issued.ca_pem.encode()) constraints = ca.extensions.get_extension_for_class(x509.NameConstraints).value assert [n.value for n in constraints.permitted_subtrees] == ["notes.test", "slack.com"] - assert ca.not_valid_after_utc == now + timedelta(hours=3) + sites.VALID_MARGIN for site, name in (("notes", "notes.test"), ("slack", "slack.com"), ("slack", "eu.slack.com")): _verify(issued.ca_pem, issued.leaves[site][0], name, now) with pytest.raises(VerificationError): @@ -115,19 +114,32 @@ def test_a_deploys_ca_signs_each_sites_names_and_is_constrained_to_them(): assert "PRIVATE KEY" in issued.leaves["notes"][1] and "PRIVATE KEY" not in issued.ca_pem -def test_a_name_longer_than_a_common_name_takes_is_signed_by_its_alternative_names_alone(): +def test_the_ca_and_certificates_hold_at_any_date_a_computers_clock_can_show(): + issued = sites.issue(sites.site_table([_site("notes", "notes.test")]).routes, label="desktop") now = datetime.now(UTC).replace(microsecond=0) + + for cert in (issued.ca_pem, issued.leaves["notes"][0]): + loaded = x509.load_pem_x509_certificate(cert.encode()) + assert (loaded.not_valid_before_utc, loaded.not_valid_after_utc) == (sites.UNIVERSE_FROM, sites.UNIVERSE_UNTIL) + for universe in (datetime(1950, 1, 1, tzinfo=UTC), now - timedelta(days=6 * 365), now, + now + timedelta(days=6 * 365), datetime(2100, 1, 1, tzinfo=UTC), + datetime(9999, 12, 31, 23, 59, 59, tzinfo=UTC)): + _verify(issued.ca_pem, issued.leaves["notes"][0], "notes.test", universe) + with pytest.raises(VerificationError): + _verify(issued.ca_pem, issued.leaves["notes"][0], "notes.test", sites.UNIVERSE_FROM - timedelta(seconds=1)) + + +def test_a_name_longer_than_a_common_name_takes_is_signed_by_its_alternative_names_alone(): name = f"{'a' * 63}.test" - issued = sites.issue(sites.site_table([_site("long", name)]).routes, valid_for=timedelta(hours=1), label="x", - now=now) + issued = sites.issue(sites.site_table([_site("long", name)]).routes, label="x") leaf = x509.load_pem_x509_certificate(issued.leaves["long"][0].encode()) assert len(leaf.subject) == 0 and leaf.extensions.get_extension_for_class(x509.SubjectAlternativeName).critical - _verify(issued.ca_pem, issued.leaves["long"][0], name, now) + _verify(issued.ca_pem, issued.leaves["long"][0], name, datetime.now(UTC)) def test_no_ca_is_made_when_no_name_is_served_over_https(): - assert sites.issue(sites.site_table([_site("bare")]).routes, valid_for=timedelta(hours=1), label="x") == \ + assert sites.issue(sites.site_table([_site("bare")]).routes, label="x") == \ sites.Issued(None, {}) @@ -185,7 +197,7 @@ async def test_route_installs_the_proxy_as_root_with_each_sites_route_as_the_com sandbox = FakeVm(_REPORT) routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert set(sandbox.written) == {"/etc/agentenv/sites/site_proxy.py", "/etc/agentenv/sites/config.json"} config = json.loads(sandbox.written["/etc/agentenv/sites/config.json"]) @@ -210,7 +222,7 @@ async def test_route_gives_a_browser_alone_the_forward_port_and_a_computer_with_ for sandbox, display in ((alone, "none"), (desktop, "x11")): await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display=display) + label="desktop", os_type="linux", display=display) configs = [json.loads(sandbox.written["/etc/agentenv/sites/config.json"]) for sandbox in (alone, desktop)] assert [config.get("proxy_port") for config in configs] == [3129, None] @@ -221,10 +233,10 @@ async def test_a_browser_alones_forward_port_takes_the_live_sites_hosts_straight routed, alone, desktop = FakeVm(_REPORT), FakeVm(_REPORT), FakeVm(_REPORT) live = ("github.com", "*.githubassets.com") - await sites.route(routed, [_site("notes", "notes.test")], user="user", network=None, valid_for=timedelta(hours=1), - label="browser", os_type="linux", display="none", live_hosts=live) + await sites.route(routed, [_site("notes", "notes.test")], user="user", network=None, label="browser", + os_type="linux", display="none", live_hosts=live) for sandbox, display in ((alone, "none"), (desktop, "x11")): - await sites.route(sandbox, [], user="user", network=None, valid_for=timedelta(hours=1), label="browser", + await sites.route(sandbox, [], user="user", network=None, label="browser", os_type="linux", display=display, live_hosts=live) beside = json.loads(routed.written["/etc/agentenv/sites/config.json"]) @@ -239,7 +251,7 @@ async def test_the_config_holding_the_keys_is_written_over_stdin_into_a_private_ sandbox = FakeVm(_REPORT) await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert ("write", "/etc/agentenv/sites/config.json") not in sandbox.events secret = sandbox.events.index(("secret", "/tmp/secret-1/config.json")) @@ -257,7 +269,7 @@ async def test_the_headers_the_gateways_provider_asks_for_go_in_the_private_conf "CF-Access-Client-Secret": "s3cret"} if url.startswith("http://host.docker.internal:8080/") else {}) routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") (site,) = json.loads(sandbox.written["/etc/agentenv/sites/config.json"])["sites"] assert site["upstream_headers"] == {"CF-Access-Client-Secret": "s3cret"} @@ -272,7 +284,7 @@ async def test_the_sites_keys_land_in_a_folder_root_alone_can_enter(): sandbox = FakeVm(_REPORT) await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") private = sandbox.events.index("docker exec -u 0 computer-1 chmod 0700 /etc/agentenv/sites") assert private < sandbox.events.index(("cp", "/tmp/secret-1/config.json", "computer-1:/etc/agentenv/sites/config.json")) @@ -285,7 +297,7 @@ async def test_a_name_that_doesnt_load_in_the_computer_fails_the_route(): with pytest.raises(RuntimeError, match="https://notes.test/ didn't load in the computer: certificate verify"): await sites.route(FakeVm(report), [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") @pytest.mark.asyncio @@ -295,8 +307,8 @@ async def test_a_network_that_keeps_the_computer_from_the_gateway_is_refused_bef with pytest.raises(RuntimeError, match="allows only api.example, so it can't reach the gateway at " "host.docker.internal"): await sites.route(sandbox, [_site("notes", "notes.test")], user="user", - network={"mode": "allowlist", "allow_hosts": ["api.example"]}, valid_for=timedelta(hours=1), - label="desktop", os_type="linux", display="x11") + network={"mode": "allowlist", "allow_hosts": ["api.example"]}, label="desktop", + os_type="linux", display="x11") assert sandbox.scripts == [] and sandbox.written == {} @@ -310,7 +322,7 @@ async def test_a_gateway_the_sandboxs_applied_network_allows_by_its_address_rang routed = await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network={"mode": "allowlist", "allow_hosts": ["api.example"]}, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert routed["checks"] == _REPORT["checks"] routes = [sites.Route("notes", "http://10.0.0.7:8080/website/notes/", ("notes.test",))] @@ -335,7 +347,7 @@ async def write_file_from_text(content, path): write_file_from_text=write_file_from_text) await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert calls == [("id", "-u"), ("mkdir", "-p", "/etc/agentenv/sites"), ("chmod", "0700", "/etc/agentenv/sites"), ("mkdir", "-p", "/etc/agentenv/sites"), ("write", "/etc/agentenv/sites/site_proxy.py"), @@ -360,7 +372,7 @@ async def write_file_from_text(content, path): with pytest.raises(RuntimeError, match="the coreweave sandbox runs the computer's commands as uid 1000"): await sites.route(sandbox, [_site("notes", "notes.test")], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert calls == [("id", "-u")] @@ -373,7 +385,7 @@ async def test_a_site_naming_the_gateway_its_routes_are_on_is_refused_before_any with pytest.raises(RuntimeError, match=r"the gateway at gw.sites.test \(named by notes's .*sites.test\) would"): await sites.route(sandbox, [_site("notes", *domains)], user="user", network=None, - valid_for=timedelta(hours=1), label="desktop", os_type="linux", display="x11") + label="desktop", os_type="linux", display="x11") assert sandbox.scripts == [] and sandbox.written == {} @@ -430,7 +442,7 @@ def gateway(env_id, *domains): table = sites.site_table([Site(**s) for s in found]) assert {route.site: route.domains for route in table.routes} == {"team-a-notes": ("notes.test",), "team-b-notes": ("notes.example",)} - assert set(sites.issue(table.routes, valid_for=timedelta(hours=1), label="x").leaves) == {"team-a-notes", + assert set(sites.issue(table.routes, label="x").leaves) == {"team-a-notes", "team-b-notes"} clash = await sites.deployed_sites([gateway("team-a", "notes.test"), gateway("team-b", "notes.test")]) with pytest.raises(sites.SiteConflict, match="'notes.test' \\(team-b-notes\\) overlaps 'notes.test' \\(team-a-notes\\)"):