diff --git a/.github/workflows/network-surface.yml b/.github/workflows/network-surface.yml new file mode 100644 index 00000000..ba5ee2e0 --- /dev/null +++ b/.github/workflows/network-surface.yml @@ -0,0 +1,19 @@ +name: Check Network Surface + +on: + pull_request: + push: + branches: [main] + +jobs: + network-surface: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Check the allowlist page matches the published surface + run: python3 scripts/check_network_surface.py diff --git a/compat/network_surface.json b/compat/network_surface.json new file mode 100644 index 00000000..25c4a7bc --- /dev/null +++ b/compat/network_surface.json @@ -0,0 +1,73 @@ +{ + "destinations": [ + { + "feature": "API, SDKs, and authenticated CLI requests", + "hosts": [ + "api.onkernel.com" + ], + "ports": [ + 443 + ], + "protocol": "HTTPS" + }, + { + "feature": "CLI authentication (`kernel login`)", + "hosts": [ + "auth.onkernel.com", + "dashboard.onkernel.com" + ], + "ports": [ + 443 + ], + "protocol": "HTTPS" + }, + { + "feature": "CDP, WebDriver BiDi, Live View, replays, and app connections", + "hosts": [ + "*.onkernel.com", + "*.kernel.sh" + ], + "ports": [ + 443, + 8443 + ], + "protocol": "HTTPS and secure WebSocket (WSS)", + "session_facing": true + }, + { + "feature": "Live View media negotiation", + "hosts": [ + "stun.relay.metered.ca" + ], + "ports": [ + 80 + ], + "protocol": "STUN over UDP" + }, + { + "feature": "Live View media relay", + "hosts": [ + "global.relay.metered.ca" + ], + "ports": [ + 443 + ], + "protocol": "TURN over UDP or TLS/TCP" + }, + { + "feature": "Replay files", + "hosts": [ + "kernel-api-prod.s3.us-east-1.amazonaws.com" + ], + "ports": [ + 443 + ], + "protocol": "HTTPS" + } + ], + "not_covered": [ + "The specific hostname inside a wildcard, as returned in `cdp_ws_url`, `webdriver_ws_url` or `browser_live_view_url`. It changes between sessions.", + "Which metro or host serves a given session.", + "Egress IP addresses, including residential, ISP and datacenter proxy exit IPs." + ] +} diff --git a/compat/network_surface.sha256 b/compat/network_surface.sha256 new file mode 100644 index 00000000..96083576 --- /dev/null +++ b/compat/network_surface.sha256 @@ -0,0 +1 @@ +968867fe4719035fc740259af9634babd648c816cf02981d59b31d882028a0f5 diff --git a/info/network-access.mdx b/info/network-access.mdx index a1b46f1a..d8a33a2d 100644 --- a/info/network-access.mdx +++ b/info/network-access.mdx @@ -8,6 +8,16 @@ Add these destinations to your allowlist when a firewall, proxy, or sandbox rest Allow only the destinations used by your integration. Apply the Live View and replay rules to each viewer's network, not just the machine running your agent. +## What we commit to + +The destinations and ports on this page do not change without a [changelog](/changelog) entry and advance notice. If Kernel needs to serve traffic from somewhere new, it appears here before it reaches your account. + +Within the scope of this page, the following are deliberately not covered and can change at any time: + +- The specific hostname inside a wildcard, as returned in `cdp_ws_url`, `webdriver_ws_url` or `browser_live_view_url`. It changes between sessions. +- Which metro or host serves a given session. +- Egress IP addresses, including residential, ISP and datacenter proxy exit IPs. + ## Required destinations | Feature | Destinations | Port | Protocol | diff --git a/scripts/check_network_surface.py b/scripts/check_network_surface.py new file mode 100755 index 00000000..7d987c59 --- /dev/null +++ b/scripts/check_network_surface.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Assert info/network-access.mdx matches the published network surface. + +Customers copy the destination table and the CSP snippets on that page straight +into firewalls and Content Security Policies. The page and the surface have +already drifted from each other once, so this keeps them the same object: +compat/network_surface.json is vendored byte-identical from +kernel/kernel contracts/network-surface.json, which the API and the Ansible +inventory validator also read. + +The vendored file is paired with compat/network_surface.sha256, the digest of +the canonical manifest it was vendored from. That is the pin: it records which +version of the surface this repo is on, and catches a copy edited in place. +""" + +import hashlib +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SURFACE = ROOT / "compat/network_surface.json" +PIN = ROOT / "compat/network_surface.sha256" +PAGE = ROOT / "info/network-access.mdx" + +TABLE_ROW = re.compile(r"^\|(?!\s*(?:Feature|-))(.+)\|\s*$", re.M) +CODE_FENCE = re.compile(r"```text\n(.*?)```", re.S) +SOURCE = re.compile(r"^(?:https|wss)://(.+)$") + + +def csp_origins(block): + """host:port for every source in a CSP block. + + A source may leave the port off, and both schemes the page uses default to + 443, so the page is compared as hosts and ports rather than as text. + """ + origins = set() + for source in block.split(): + match = SOURCE.match(source.rstrip(";")) + if match is None: + continue + host, _, port = match.group(1).partition(":") + origins.add(f"{host}:{port or '443'}") + return origins + + +def backticked(cell): + return [v.strip() for v in re.findall(r"`([^`]+)`", cell)] + + +def table_rows(page): + """(feature, hosts, ports, protocol) for each row of the destinations table.""" + section = page.split("## Required destinations", 1)[-1].split("\n## ", 1)[0] + rows = [] + for raw in TABLE_ROW.findall(section): + cells = [c.strip() for c in raw.split("|")] + if len(cells) != 4: + continue + feature, hosts, ports, protocol = cells + rows.append((feature, backticked(hosts), backticked(ports), protocol)) + return rows + + +def check_table(surface, page): + errors = [] + documented = table_rows(page) + published = surface["destinations"] + if len(documented) != len(published): + return [f"table has {len(documented)} rows, surface has {len(published)} destinations"] + for row, dest in zip(documented, published): + feature, hosts, ports, protocol = row + if feature != dest["feature"]: + errors.append(f"feature {feature!r} != {dest['feature']!r}") + if hosts != dest["hosts"]: + errors.append(f"{dest['feature']}: hosts {hosts} != {dest['hosts']}") + published_ports = [str(port) for port in dest["ports"]] + if ports != published_ports: + errors.append(f"{dest['feature']}: ports {ports} != {published_ports}") + if protocol != dest["protocol"]: + errors.append(f"{dest['feature']}: protocol {protocol!r} != {dest['protocol']!r}") + return errors + + +def check_csp(surface, page): + """Every session-facing host:port must appear in the Live View CSP, and nothing else.""" + session_facing = { + f"{host}:{port}" + for dest in surface["destinations"] + if dest.get("session_facing") + for host in dest["hosts"] + for port in dest["ports"] + } + blocks = CODE_FENCE.findall(page) + if not blocks: + return ["no ```text CSP block found"] + live_view = csp_origins(blocks[0]) + errors = [] + for missing in sorted(session_facing - live_view): + errors.append(f"Live View CSP is missing {missing}") + for extra in sorted(o for o in live_view - session_facing if o.startswith("*.")): + errors.append(f"Live View CSP allows {extra}, which is not session-facing in the surface") + return errors + + +def check_exclusions(surface, page): + """The stated scope of the commitment has to travel with the commitment.""" + return [f"page is missing the stated exclusion: {line}" + for line in surface["not_covered"] if line not in page] + + +def check_pin(): + """The vendored copy has to be the one the pinned digest names. + + The sidecar is written by the sync workflow in kernel/kernel, never by hand, + so a mismatch means either the copy or the pin was edited in place. + """ + pinned = PIN.read_text().split()[0] + vendored = hashlib.sha256(SURFACE.read_bytes()).hexdigest() + if pinned != vendored: + return [ + f"{SURFACE.relative_to(ROOT)} does not match {PIN.relative_to(ROOT)} " + f"(pinned {pinned}, vendored {vendored})" + ] + return [] + + +def main(): + surface = json.loads(SURFACE.read_text()) + page = PAGE.read_text() + errors = ( + check_table(surface, page) + + check_csp(surface, page) + + check_exclusions(surface, page) + + check_pin() + ) + if errors: + print(f"{PAGE.relative_to(ROOT)} does not match {SURFACE.relative_to(ROOT)}:\n", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + print( + "\nThe surface is a published commitment. Update it in kernel/kernel first, " + "sync the copies, and land the changelog entry in the same change.", + file=sys.stderr, + ) + return 1 + print(f"network surface matches: {len(surface['destinations'])} destinations, CSP and exclusions consistent") + return 0 + + +if __name__ == "__main__": + sys.exit(main())