Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/network-surface.yml
Original file line number Diff line number Diff line change
@@ -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
73 changes: 73 additions & 0 deletions compat/network_surface.json
Original file line number Diff line number Diff line change
@@ -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."
]
}
1 change: 1 addition & 0 deletions compat/network_surface.sha256
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
968867fe4719035fc740259af9634babd648c816cf02981d59b31d882028a0f5
10 changes: 10 additions & 0 deletions info/network-access.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
152 changes: 152 additions & 0 deletions scripts/check_network_surface.py
Original file line number Diff line number Diff line change
@@ -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())
Loading