diff --git a/.github/schema/ip-ranges.schema.json b/.github/schema/ip-ranges.schema.json new file mode 100644 index 0000000..833fe55 --- /dev/null +++ b/.github/schema/ip-ranges.schema.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://raw.githubusercontent.com/truefoundry/nat-ips/main/.github/schema/ip-ranges.schema.json", + "title": "TrueFoundry IP ranges", + "description": "Outbound NAT addresses for one control plane. The directory (eu or us) is the control plane.", + "type": "object", + "required": ["endpoints"], + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string" + }, + "endpoints": { + "type": "array", + "items": { "$ref": "#/$defs/endpoint" } + } + }, + "$defs": { + "endpoint": { + "type": "object", + "required": ["type", "hostname", "nat_ips"], + "additionalProperties": false, + "properties": { + "type": { + "enum": ["control_plane", "gateway"] + }, + "airport_code": { + "type": "string", + "pattern": "^[a-z]{3}$", + "description": "IATA code for one gateway airport. Absent on geolocated hostnames such as us.gateway.truefoundry.ai." + }, + "hostname": { + "type": "string", + "format": "hostname", + "description": "The hostname a customer connects to. nat_ips is the full allowlist for this hostname." + }, + "nat_ips": { + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])/(3[0-2]|[12]?[0-9])$", + "description": "IPv4 CIDR. Single addresses are written as /32." + } + } + }, + "allOf": [ + { + "if": { + "properties": { "type": { "const": "control_plane" } }, + "required": ["type"] + }, + "then": { + "not": { "required": ["airport_code"] } + } + } + ] + } + } +} diff --git a/.github/scripts/build.py b/.github/scripts/build.py new file mode 100644 index 0000000..63bf2e3 --- /dev/null +++ b/.github/scripts/build.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Copy eu/ and us/ into the release artifacts in dist/. + +Run with: uv run .github/scripts/build.py --version 2026.10.12 +""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +from datetime import datetime, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] # .github/scripts/build.py -> repo root +DIST_DIR = ROOT / "dist" +PLANES = ("eu", "us") +REPO = "https://github.com/truefoundry/nat-ips" + + +def flat_ips(endpoints: list[dict]) -> list[str]: + unique = {cidr for endpoint in endpoints for cidr in endpoint["nat_ips"]} + return sorted(unique, key=lambda cidr: ipaddress.IPv4Network(cidr)) + + +def load_plane(plane: str) -> dict: + path = ROOT / plane / "ip-ranges.json" + if not path.exists(): + raise SystemExit(f"missing {path} — refusing to build") + doc = json.loads(path.read_text()) + # Release assets are the public document. $schema is only for editors in the repo. + return {"endpoints": doc["endpoints"]} + + +def write_json(path: Path, payload: dict) -> None: + path.write_text(json.dumps(payload, indent=2) + "\n") + print(f"wrote {path.relative_to(ROOT)}") + + +def write_text(path: Path, plane: str, version: str, timestamp: str, ips: list[str]) -> None: + header = [ + f"# TrueFoundry {plane.upper()} outbound NAT IPs", + f"# version: {version}", + f"# generated: {timestamp}", + f"# {REPO}", + ] + path.write_text("\n".join(header + ips) + "\n") + print(f"wrote {path.relative_to(ROOT)}") + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True, help="CalVer release, e.g. 2026.10.12") + args = parser.parse_args() + + timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + DIST_DIR.mkdir(exist_ok=True) + + for plane in PLANES: + doc = load_plane(plane) + write_json(DIST_DIR / f"{plane}.json", doc) + write_text( + DIST_DIR / f"{plane}.txt", + plane, + args.version, + timestamp, + flat_ips(doc["endpoints"]), + ) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/release_notes.py b/.github/scripts/release_notes.py new file mode 100644 index 0000000..e446d9f --- /dev/null +++ b/.github/scripts/release_notes.py @@ -0,0 +1,180 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = [] +# /// +"""Diff published IP range files and write release notes. + +Prints publish=, added=, and removed= for the release workflow. +Run with: uv run .github/scripts/release_notes.py --version 2026.10.12 --eu dist/eu.json --us dist/us.json +""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import sys +from datetime import datetime, timedelta, timezone +from pathlib import Path + +REPO = "https://github.com/truefoundry/nat-ips" +PLANES = ("eu", "us") +ACTIVATION_DAYS = 15 + + +def ip_sort(cidr: str) -> ipaddress.IPv4Network: + return ipaddress.IPv4Network(cidr) + + +def load_doc(path: Path | None) -> dict: + if path is None: + return {"endpoints": []} + if not path.exists(): + raise SystemExit(f"missing {path}") + return json.loads(path.read_text()) + + +def cidrs_of(doc: dict) -> set[str]: + return {cidr for endpoint in doc.get("endpoints", []) for cidr in endpoint["nat_ips"]} + + +def by_hostname(doc: dict) -> dict[str, set[str]]: + return {endpoint["hostname"]: set(endpoint["nat_ips"]) for endpoint in doc.get("endpoints", [])} + + +def noun(count: int, singular: str, plural: str) -> str: + return f"{count} {singular if count == 1 else plural}" + + +def activation_date(version: str) -> str | None: + """CalVer date plus 15 days. Local and CI versions have no calendar date.""" + date_part = version.split("-", 1)[0] + try: + published = datetime.strptime(date_part, "%Y.%m.%d").replace(tzinfo=timezone.utc) + except ValueError: + return None + return (published + timedelta(days=ACTIVATION_DAYS)).strftime("%Y-%m-%d") + + +def render_full(plane: str, doc: dict) -> list[str]: + lines: list[str] = [] + for endpoint in doc.get("endpoints", []): + lines.append(f"**{plane.upper()}** · `{endpoint['hostname']}`") + lines.extend(f"- `{cidr}`" for cidr in endpoint["nat_ips"]) + lines.append("") + return lines + + +def render_hostname_changes(plane: str, hostname: str, added: list[str], removed: list[str]) -> list[str]: + lines = [f"### {plane.upper()} · `{hostname}`", ""] + if added: + lines.append("Added:") + lines.extend(f"- `{cidr}`" for cidr in added) + lines.append("") + if removed: + lines.append("Removed:") + lines.extend(f"- `{cidr}`" for cidr in removed) + lines.append("") + return lines + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--version", required=True) + parser.add_argument("--eu", required=True, type=Path) + parser.add_argument("--us", required=True, type=Path) + parser.add_argument("--old-eu", type=Path) + parser.add_argument("--old-us", type=Path) + parser.add_argument("--out", type=Path, default=Path("RELEASE_NOTES.md")) + args = parser.parse_args() + + new_docs = {"eu": load_doc(args.eu), "us": load_doc(args.us)} + has_baseline = args.old_eu is not None or args.old_us is not None + old_docs = { + "eu": load_doc(args.old_eu) if has_baseline else {"endpoints": []}, + "us": load_doc(args.old_us) if has_baseline else {"endpoints": []}, + } + + added_ips: set[str] = set() + removed_ips: set[str] = set() + hostname_changes: list[tuple[str, str, list[str], list[str]]] = [] + + for plane in PLANES: + new_set = cidrs_of(new_docs[plane]) + old_set = cidrs_of(old_docs[plane]) if has_baseline else set() + if has_baseline: + added_ips |= new_set - old_set + removed_ips |= old_set - new_set + + new_hosts = by_hostname(new_docs[plane]) + old_hosts = by_hostname(old_docs[plane]) if has_baseline else {} + for hostname in sorted(set(new_hosts) | set(old_hosts)): + added = sorted(new_hosts.get(hostname, set()) - old_hosts.get(hostname, set()), key=ip_sort) + removed = sorted(old_hosts.get(hostname, set()) - new_hosts.get(hostname, set()), key=ip_sort) + if added or removed: + hostname_changes.append((plane, hostname, added, removed)) + + publish = not has_baseline or bool(added_ips or removed_ips or hostname_changes) + lines = [f"## TrueFoundry IP ranges — {args.version}", ""] + + if not has_baseline: + lines += [ + "First published release. These addresses are in use. Allowlist them for traffic arriving from TrueFoundry.", + "", + "Addresses added in a later release start carrying traffic 15 days after that release. " + "When an address is removed, a release is published so it can be dropped from allowlists.", + "", + ] + for plane in PLANES: + lines += render_full(plane, new_docs[plane]) + elif not hostname_changes: + lines += [ + "No IP changes since the previous release. **No firewall action needed.**", + "", + ] + else: + if added_ips: + live = activation_date(args.version) + when = f" on **{live}**" if live else "" + lines += [ + f"Allowlist **{noun(len(added_ips), 'new address', 'new addresses')}** " + f"before they start carrying traffic{when}, {ACTIVATION_DAYS} days after this release.", + "", + ] + if removed_ips: + lines += [ + f"**{noun(len(removed_ips), 'address removed', 'addresses removed')}.** " + "They are no longer used and can be dropped from allowlists.", + "", + ] + if hostname_changes and not added_ips and not removed_ips: + lines += [ + "Addresses moved between hostnames. Update the allowlist for the hostnames below.", + "", + ] + for plane, hostname, added, removed in hostname_changes: + lines += render_hostname_changes(plane, hostname, added, removed) + + lines += ["---", "", "### Full list", ""] + for plane in PLANES: + count = len(cidrs_of(new_docs[plane])) + lines.append( + f"- {plane.upper()} ({count} addresses): {REPO}/releases/download/v{args.version}/{plane}.json" + ) + lines += [ + "- Plain text, one CIDR per line: swap `.json` for `.txt`.", + f"- Always-latest: {REPO}/releases/latest/download/eu.json", + "", + ] + + args.out.write_text("\n".join(lines)) + print(f"publish={'true' if publish else 'false'}") + print(f"added={len(added_ips)}") + print(f"removed={len(removed_ips)}") + print(f"wrote {args.out} ({len(added_ips)} added, {len(removed_ips)} removed)", file=sys.stderr) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/scripts/validate.py b/.github/scripts/validate.py new file mode 100644 index 0000000..c39e913 --- /dev/null +++ b/.github/scripts/validate.py @@ -0,0 +1,254 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# dependencies = ["jsonschema>=4.21"] +# /// +"""Validate eu/ip-ranges.json and us/ip-ranges.json. + +Run with: uv run .github/scripts/validate.py +""" + +from __future__ import annotations + +import ipaddress +import json +import re +import sys +from pathlib import Path + +from jsonschema import Draft202012Validator + +ROOT = Path(__file__).resolve().parents[2] # .github/scripts/validate.py -> repo root +SCHEMA_PATH = ROOT / ".github" / "schema" / "ip-ranges.schema.json" +PLANES = ("eu", "us") + +HOSTNAME_RE = re.compile(r"^(?!-)[a-z0-9-]{1,63}(? None: + errors.append(f"{where}: {msg}") + + +def warn(where: str, msg: str) -> None: + warnings.append(f"{where}: {msg}") + + +def ip_sort(cidr: str): + return ipaddress.IPv4Network(cidr) + + +def check_cidr(where: str, raw: str) -> ipaddress.IPv4Network | None: + """Parse a CIDR and reject anything that cannot be a public NAT egress address.""" + try: + net = ipaddress.IPv4Network(raw, strict=True) + except ValueError as exc: + error(where, f"invalid CIDR {raw!r}: {exc}") + return None + + if not net.is_global: + error(where, f"{raw} is not a globally routable address; NAT egress IPs must be public") + return net + + +def endpoint_sort_key(endpoint: dict) -> tuple: + if endpoint["type"] == "control_plane": + return (0, endpoint["hostname"]) + hostname = endpoint["hostname"] + if hostname in GEO_ORDER: + return (1, GEO_ORDER.index(hostname)) + return (2, endpoint.get("airport_code", ""), hostname) + + +def check_rollups(where: str, endpoints: list[dict]) -> None: + """Geolocated hostnames must list exactly the NAT addresses of their airports.""" + airports: dict[str, set[str]] = {} + geos: dict[str, set[str]] = {} + + for endpoint in endpoints: + if endpoint["type"] != "gateway": + continue + if "airport_code" in endpoint: + airports[endpoint["airport_code"]] = set(endpoint["nat_ips"]) + else: + geos[endpoint["hostname"]] = set(endpoint["nat_ips"]) + + if not airports: + return + + expected_global = set().union(*airports.values()) + if GEO_ORDER[0] not in geos: + error(where, f"missing {GEO_ORDER[0]}, the union of every airport") + else: + report_set_diff(where, GEO_ORDER[0], expected_global, geos[GEO_ORDER[0]]) + + for hostname, members in REGIONAL_GATEWAYS.items(): + present = members & airports.keys() + if not present: + if hostname in geos: + error(where, f"{hostname} lists addresses but none of its airports are in this file") + continue + expected = set().union(*(airports[code] for code in present)) + if hostname not in geos: + error(where, f"missing {hostname}") + continue + report_set_diff(where, hostname, expected, geos[hostname]) + + +def report_set_diff(where: str, hostname: str, expected: set[str], actual: set[str]) -> None: + missing = sorted(expected - actual, key=ip_sort) + extra = sorted(actual - expected, key=ip_sort) + if missing: + shown = ", ".join(missing[:6]) + suffix = "" if len(missing) <= 6 else f" (+{len(missing) - 6} more)" + error(where, f"{hostname} is missing {shown}{suffix}") + if extra: + shown = ", ".join(extra[:6]) + suffix = "" if len(extra) <= 6 else f" (+{len(extra) - 6} more)" + error(where, f"{hostname} includes {shown}{suffix}, which are not on its airports") + + +def validate_file(plane: str, validator: Draft202012Validator) -> list[dict]: + path = ROOT / plane / "ip-ranges.json" + rel = f"{plane}/ip-ranges.json" + extras = sorted(p.name for p in (ROOT / plane).glob("*.json") if p.name != "ip-ranges.json") + if extras: + error(plane, f"unexpected JSON files {extras}; keep addresses in ip-ranges.json") + + if not path.exists(): + error(plane, "missing ip-ranges.json") + return [] + + try: + doc = json.loads(path.read_text()) + except json.JSONDecodeError as exc: + error(rel, f"not valid JSON: {exc}") + return [] + + schema_errors = sorted(validator.iter_errors(doc), key=lambda e: list(e.path)) + if schema_errors: + for exc in schema_errors: + location = "/".join(str(part) for part in exc.path) or "" + error(rel, f"{location}: {exc.message}") + return [] + + endpoints = doc["endpoints"] + if plane == "eu" and not endpoints: + error(rel, "EU publishes gateway addresses; endpoints is empty") + + if endpoints != sorted(endpoints, key=endpoint_sort_key): + error(rel, "endpoints must be ordered control plane, then geolocated gateways, then airport code") + + built: list[dict] = [] + seen_hosts: dict[str, int] = {} + for index, endpoint in enumerate(endpoints): + where = f"{rel} endpoints[{index}]" + hostname = endpoint["hostname"] + if not HOSTNAME_RE.match(hostname): + error(where, f"hostname {hostname!r} is not a valid lowercase FQDN") + + if hostname in seen_hosts: + error(where, f"hostname {hostname!r} is already used by endpoints[{seen_hosts[hostname]}]") + seen_hosts[hostname] = index + + airport = endpoint.get("airport_code") + if endpoint["type"] == "gateway" and airport is None and hostname not in GEO_ORDER: + error(where, f"hostname {hostname!r} is not a known geolocated gateway") + if airport is not None: + expected = f"{airport}.gateway.truefoundry.ai" + if hostname != expected: + error(where, f"hostname {hostname!r} does not match airport code {airport!r} ({expected})") + + ordered = sorted(endpoint["nat_ips"], key=ip_sort) + if endpoint["nat_ips"] != ordered: + error(where, "nat_ips must be sorted numerically") + + nets = [net for net in (check_cidr(where, cidr) for cidr in endpoint["nat_ips"]) if net] + for left_index, left in enumerate(nets): + for right in nets[left_index + 1 :]: + if left.overlaps(right): + error(where, f"{left} and {right} overlap; collapse them into one range") + + built.append({"plane": plane, "endpoint": endpoint, "nets": nets}) + + check_rollups(rel, endpoints) + return built + + +def check_duplicates(records: list[dict]) -> None: + """The same address on a geolocated hostname and its airports is expected. + + The same address on two airports, or on two control planes, is a copy-paste slip. + """ + seen: dict[str, list[str]] = {} + for record in records: + endpoint = record["endpoint"] + if endpoint["type"] == "gateway" and "airport_code" not in endpoint: + continue + label = f"{record['plane']} {endpoint['hostname']}" + for net in record["nets"]: + seen.setdefault(str(net), []).append(label) + + for cidr, locations in sorted(seen.items()): + if len(locations) > 1: + warn("duplicate IP", f"{cidr} appears in {len(locations)} endpoints: {', '.join(locations)}") + + +def main() -> int: + if not SCHEMA_PATH.exists(): + print(f"missing schema at {SCHEMA_PATH}", file=sys.stderr) + return 1 + + for plane in PLANES: + if not (ROOT / plane).is_dir(): + print(f"missing {plane}/ directory", file=sys.stderr) + return 1 + + schema = json.loads(SCHEMA_PATH.read_text()) + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + + records: list[dict] = [] + for plane in PLANES: + records.extend(validate_file(plane, validator)) + check_duplicates(records) + + for message in warnings: + print(f"warning {message}") + for message in errors: + print(f"error {message}", file=sys.stderr) + + total_ips = sum(len(record["nets"]) for record in records) + print(f"\nchecked {len(PLANES)} files, {len(records)} endpoints, {total_ips} IP references") + + if errors: + print(f"FAILED with {len(errors)} error(s)", file=sys.stderr) + return 1 + + print("OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c3a1f17..2705065 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,10 +4,10 @@ on: push: branches: [main] paths: - # dist/** is deliberately absent — this workflow commits there and must not retrigger itself. - - "data/**" - - "schema/**" - - "scripts/**" + - "eu/**" + - "us/**" + - ".github/schema/**" + - ".github/scripts/**" workflow_dispatch: inputs: dry_run: @@ -35,13 +35,13 @@ jobs: enable-cache: true - name: Validate source files - run: uv run scripts/validate.py + run: uv run .github/scripts/validate.py - name: Refuse to publish placeholder data run: | - if grep -rqi 'PLACEHOLDER' data/; then - echo "::error::data/ still contains PLACEHOLDER entries. Replace them with real IPs before releasing." - grep -rin 'PLACEHOLDER' data/ + if grep -rqi 'PLACEHOLDER' eu/ us/; then + echo "::error::eu/ or us/ still contains PLACEHOLDER entries. Replace them with real IPs before releasing." + grep -rin 'PLACEHOLDER' eu/ us/ exit 1 fi @@ -60,11 +60,15 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "Releasing v$VERSION" - - name: Capture the previously released artifact + - name: Fetch the previously released artifacts id: previous + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - PREV="$(git tag -l 'v*' --sort=-version:refname | head -n 1)" - if [ -n "$PREV" ] && git show "$PREV:dist/nat-ips.json" > "${RUNNER_TEMP}/old.json" 2>/dev/null; then + PREV="$(gh release list --limit 1 --json tagName --jq '.[0].tagName // empty')" + mkdir -p "${RUNNER_TEMP}/old" + if [ -n "$PREV" ] && gh release download "$PREV" --pattern eu.json --dir "${RUNNER_TEMP}/old" 2>/dev/null; then + gh release download "$PREV" --pattern us.json --dir "${RUNNER_TEMP}/old" 2>/dev/null || true echo "tag=$PREV" >> "$GITHUB_OUTPUT" echo "Comparing against $PREV" else @@ -73,57 +77,44 @@ jobs: fi - name: Build artifacts - run: uv run scripts/build.py --version "${{ steps.version.outputs.version }}" + run: uv run .github/scripts/build.py --version "${{ steps.version.outputs.version }}" - name: Write release notes + id: notes run: | - ARGS=(--new dist/nat-ips.json --out "${RUNNER_TEMP}/notes.md") - if [ -n "${{ steps.previous.outputs.tag }}" ]; then - ARGS+=(--old "${RUNNER_TEMP}/old.json") + ARGS=( + --version "${{ steps.version.outputs.version }}" + --eu dist/eu.json + --us dist/us.json + --out "${RUNNER_TEMP}/notes.md" + ) + if [ -f "${RUNNER_TEMP}/old/eu.json" ]; then + ARGS+=(--old-eu "${RUNNER_TEMP}/old/eu.json") fi - uv run scripts/release_notes.py "${ARGS[@]}" - cat "${RUNNER_TEMP}/notes.md" - - - name: Update CHANGELOG - run: | - printf '%s\n\n' "$(cat "${RUNNER_TEMP}/notes.md")" > "${RUNNER_TEMP}/changelog.md" - if [ -f CHANGELOG.md ]; then - cat CHANGELOG.md >> "${RUNNER_TEMP}/changelog.md" + if [ -f "${RUNNER_TEMP}/old/us.json" ]; then + ARGS+=(--old-us "${RUNNER_TEMP}/old/us.json") fi - mv "${RUNNER_TEMP}/changelog.md" CHANGELOG.md + uv run .github/scripts/release_notes.py "${ARGS[@]}" >> "$GITHUB_OUTPUT" + cat "${RUNNER_TEMP}/notes.md" >> "$GITHUB_STEP_SUMMARY" - name: Stop here on a dry run if: inputs.dry_run - run: | - echo "::notice::Dry run — skipping commit, tag and release." - git --no-pager diff --stat + run: echo "::notice::Dry run — notes are in the job summary. Nothing tagged or published." - - name: Commit, tag and publish - if: ${{ !inputs.dry_run }} + - name: Skip when the address list is unchanged + if: ${{ !inputs.dry_run && steps.notes.outputs.publish != 'true' }} + run: echo "::notice::No addresses were added or removed. No release published." + + - name: Tag and publish + if: ${{ !inputs.dry_run && steps.notes.outputs.publish == 'true' }} env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - git add dist/ CHANGELOG.md - if git diff --staged --quiet; then - echo "::notice::Nothing changed — no release published." - exit 0 - fi - - git commit -m "chore(release): v${VERSION}" - git tag -a "v${VERSION}" -m "NAT IPs v${VERSION}" - git push origin HEAD:main --follow-tags - + # gh creates the tag at this commit, so nothing is ever pushed to a protected branch. gh release create "v${VERSION}" \ - --title "NAT IPs v${VERSION}" \ + --target "${GITHUB_SHA}" \ + --title "IP ranges v${VERSION}" \ --notes-file "${RUNNER_TEMP}/notes.md" \ --latest \ - dist/nat-ips.json \ - dist/nat-ips.txt \ - dist/us.json \ - dist/us.txt \ - dist/eu.json \ - dist/eu.txt + dist/* diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 94a68d9..64f99c7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -3,16 +3,18 @@ name: Validate on: pull_request: paths: - - "data/**" - - "schema/**" - - "scripts/**" + - "eu/**" + - "us/**" + - ".github/schema/**" + - ".github/scripts/**" - ".github/workflows/validate.yml" push: branches: [main] paths: - - "data/**" - - "schema/**" - - "scripts/**" + - "eu/**" + - "us/**" + - ".github/schema/**" + - ".github/scripts/**" workflow_dispatch: permissions: @@ -29,15 +31,18 @@ jobs: enable-cache: true - name: Validate source files - run: uv run scripts/validate.py + run: uv run .github/scripts/validate.py - name: Build artifacts - run: uv run scripts/build.py --version 0.0.0-ci + run: uv run .github/scripts/build.py --version 0.0.0-ci diff: # Show reviewers exactly which IPs a PR adds or removes, so nobody has to read raw JSON. + # Informational only: `validate` is the gate, so a baseline that cannot be rebuilt + # (an old branch, a schema change) must never block the PR. if: github.event_name == 'pull_request' runs-on: ubuntu-latest + continue-on-error: true permissions: contents: read pull-requests: write @@ -52,25 +57,39 @@ jobs: - name: Build this branch run: | - uv run scripts/build.py --version "pr-${{ github.event.pull_request.number }}" - cp dist/nat-ips.json "${RUNNER_TEMP}/new.json" + uv run .github/scripts/build.py --version "pr-${{ github.event.pull_request.number }}" + cp dist/eu.json dist/us.json "${RUNNER_TEMP}/" - name: Build the base branch run: | - git checkout "origin/${{ github.base_ref }}" -- data/ - uv run scripts/build.py --version "${{ github.base_ref }}" - cp dist/nat-ips.json "${RUNNER_TEMP}/old.json" - git checkout HEAD -- data/ + # main predates eu/ and us/. An empty baseline still produces a useful + # "everything added" comment instead of failing the checkout. + mkdir -p "${RUNNER_TEMP}/old" + base="origin/${{ github.base_ref }}" + if git cat-file -e "${base}:eu/ip-ranges.json" \ + && git cat-file -e "${base}:us/ip-ranges.json"; then + git checkout "$base" -- eu us + uv run .github/scripts/build.py --version "${{ github.base_ref }}" + cp dist/eu.json dist/us.json "${RUNNER_TEMP}/old/" + git checkout HEAD -- eu us + else + echo "Base branch has no eu/ or us/ ip-ranges yet; diffing against an empty list." + printf '%s\n' '{"endpoints":[]}' > "${RUNNER_TEMP}/old/eu.json" + printf '%s\n' '{"endpoints":[]}' > "${RUNNER_TEMP}/old/us.json" + fi - name: Render diff run: | - uv run scripts/release_notes.py \ - --new "${RUNNER_TEMP}/new.json" \ - --old "${RUNNER_TEMP}/old.json" \ + uv run .github/scripts/release_notes.py \ + --version "pr-${{ github.event.pull_request.number }}" \ + --eu "${RUNNER_TEMP}/eu.json" \ + --us "${RUNNER_TEMP}/us.json" \ + --old-eu "${RUNNER_TEMP}/old/eu.json" \ + --old-us "${RUNNER_TEMP}/old/us.json" \ --out "${RUNNER_TEMP}/diff.md" { - echo "" + echo "" echo "## Proposed IP changes" echo tail -n +2 "${RUNNER_TEMP}/diff.md" diff --git a/.gitignore b/.gitignore index 1806e14..2ddfefc 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ RELEASE_NOTES.md __pycache__/ *.pyc .DS_Store +dist/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 38770e9..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,41 +0,0 @@ -# Contributing - -This repo publishes TrueFoundry's outbound NAT IPs. Changes to `data/` are made by TrueFoundry engineers; anyone can open an issue. - -## Reporting a problem - -[Open an issue](https://github.com/truefoundry/nat-ips/issues) if an IP looks wrong, a region is missing, or traffic arrives from an address that isn't listed. Include the destination you saw it on and a timestamp. - -Anything security-sensitive goes to security@truefoundry.com, not a public issue. - -## Changing the data - -1. Edit the right file under `data//`. Nothing in `dist/` is hand-edited. -2. Write IPs as CIDR — a single address is `/32`. -3. Run the checks locally: - ```bash - uv run scripts/validate.py - uv run scripts/build.py --version 0.0.0-local - ``` -4. Open a PR. CI validates and comments the exact IP diff. -5. Merge to `main` — the release workflow tags and publishes automatically. - -### Adding a regional gateway PoP - -Gateway entries need both an `airport_code` (lowercase IATA) and a `hostname`: - -```json -{ - "type": "gateway", - "cloud": "gcp", - "region": "asia-south1", - "continent": "ap", - "airport_code": "bom", - "hostname": "bom.us.truefoundry.cloud", - "nat_ips": ["34.100.0.1/32"] -} -``` - -### Timing - -Publish **before** the IPs go live. Customers need lead time to get a firewall change through their own approval process. Retiring an IP is lower risk, but still announce it ahead of the cutover. diff --git a/README.md b/README.md index ea41a36..f8a5d6c 100644 --- a/README.md +++ b/README.md @@ -1,151 +1,84 @@ -# TrueFoundry NAT IPs +# TrueFoundry IP ranges -The outbound (egress) IP addresses TrueFoundry connects **from**. If you restrict inbound traffic to your model endpoints, webhooks, databases, or private APIs, allowlist the IPs for your control plane. +Outbound IP addresses TrueFoundry uses when it connects to your systems. Allowlist these if you restrict inbound access to your model endpoints, webhooks, databases, or private APIs. -Published as machine-readable JSON with a [GitHub Release](https://github.com/truefoundry/nat-ips/releases) for every change, so you can subscribe and automate. +The directory is the control plane: -> **IPs change.** New regions get added and cloud NAT gateways get renumbered. Subscribe to releases rather than copying the list once. - ---- - -## Get the list - -Always-latest URLs — these follow the newest release: - -| What | URL | +| Control plane | List | | --- | --- | -| Everything | `https://github.com/truefoundry/nat-ips/releases/latest/download/nat-ips.json` | -| US control plane only | `https://github.com/truefoundry/nat-ips/releases/latest/download/us.json` | -| EU control plane only | `https://github.com/truefoundry/nat-ips/releases/latest/download/eu.json` | -| Plain text, one CIDR per line | swap `.json` → `.txt` in any URL above | - -Pin to an exact version by replacing `latest/download` with `download/v2026.10.12`. +| EU | [`eu/ip-ranges.json`](eu/ip-ranges.json) | +| US | [`us/ip-ranges.json`](us/ip-ranges.json) | -```bash -# Every IP, ready to paste into a firewall rule -curl -sL https://github.com/truefoundry/nat-ips/releases/latest/download/nat-ips.txt | grep -v '^#' - -# Just the EU gateway PoPs -curl -sL https://github.com/truefoundry/nat-ips/releases/latest/download/eu.json \ - | jq -r '.entries[] | select(.type == "gateway") | .nat_ips[]' -``` +Each [GitHub Release](https://github.com/truefoundry/nat-ips/releases) attaches the same lists: -## Which IPs do I need? +- https://github.com/truefoundry/nat-ips/releases/latest/download/eu.json +- https://github.com/truefoundry/nat-ips/releases/latest/download/us.json -Pick by the TrueFoundry domain you were onboarded onto. - -| Your setup | Allowlist | -| --- | --- | -| Control plane at `*.us.truefoundry.cloud` | `us.json` | -| Control plane at `*.eu.truefoundry.cloud` | `eu.json` | -| Not sure, or you use both | `nat-ips.json` | +Replace `.json` with `.txt` for one CIDR per line. Pin a release by replacing `latest/download` with `download/v2026.10.12`. -Within a control plane there are two kinds of entry: +## What to allowlist -- **`control_plane`** — the TrueFoundry platform itself. Covers deployments, webhooks, and integrations calling out to your systems. -- **`gateway`** — regional AI Gateway points of presence. Traffic to your self-hosted or private model endpoints comes from these. Each has a `hostname` (for example `mad.us.truefoundry.cloud`) and an `airport_code`. - -**If you only serve models through the AI Gateway, the `gateway` entries alone are enough.** Most customers need both. - -## Subscribe to changes - -Pick whichever fits how you work: - -**1. GitHub notifications** — click **Watch → Custom → Releases** at the top of this repo. Everyone on your team who wants alerts should do this individually. - -**2. RSS / Atom** — point any feed reader, Slack RSS app, or internal bot at: - -``` -https://github.com/truefoundry/nat-ips/releases.atom -``` - -In Slack: `/feed subscribe https://github.com/truefoundry/nat-ips/releases.atom` - -**3. Poll from automation** — check the `version` field and act when it changes: - -```bash -VERSION=$(curl -sL https://github.com/truefoundry/nat-ips/releases/latest/download/nat-ips.json | jq -r .version) -``` - -Every release note lists the exact IPs **added** and **removed** versus the previous release, so a diff is never required on your side. - -> Add newly published IPs promptly. Removing retired IPs is optional and can wait for your next maintenance window — stale entries are harmless. - -## Data format +Find the hostname you connect to. `nat_ips` is every address that hostname can connect from. ```json { - "version": "2026.10.12", - "generation_timestamp": "2026-10-12T09:14:03Z", - "entry_count": 4, - "ip_count": 8, - "entries": [ + "endpoints": [ + { + "type": "control_plane", + "hostname": "eu.truefoundry.cloud", + "nat_ips": ["203.0.113.10/32"] + }, { - "control_plane": "us", "type": "gateway", - "host": "mad.us.truefoundry.cloud", - "gateway_host": "us.truefoundry.cloud", - "cloud": "gcp", - "region": "europe-southwest1", - "continent": "eu", - "airport_code": "mad", - "nat_ips": ["34.175.44.33/32", "34.175.219.109/32"] + "hostname": "us.gateway.truefoundry.ai", + "nat_ips": ["52.4.51.133/32", "54.204.114.10/32"] + }, + { + "type": "gateway", + "airport_code": "orf", + "hostname": "orf.gateway.truefoundry.ai", + "nat_ips": ["52.4.51.133/32", "54.204.114.10/32"] } ] } ``` -| Field | Meaning | +`type` is `control_plane` or `gateway`. Airport endpoints include `airport_code`. Continent hostnames combine the regional gateways that can serve them, including the documented fallback, and `nat_ips` is that combined list: + +| Hostname | Addresses | | --- | --- | -| `version` | CalVer release, matching the git tag `v` | -| `generation_timestamp` | UTC build time, ISO 8601 | -| `control_plane` | `us` or `eu` | -| `type` | `control_plane` or `gateway` | -| `host` | The endpoint this entry serves. Wildcard for control planes, regional FQDN for gateways | -| `gateway_host` | Gateway entries only — the global entry point that routes to this PoP | -| `cloud` / `region` | Where the egress NAT lives | -| `airport_code` | Gateway entries only — IATA code for the PoP | -| `nat_ips` | IPv4 CIDRs. Single addresses are written `/32` | +| `gateway.truefoundry.ai` | Every regional gateway | +| `us.gateway.truefoundry.ai` | North Virginia, San Francisco, Dallas, and Toronto | +| `eu.gateway.truefoundry.ai` | London, Madrid, Gavle, and Doha | +| `sa.gateway.truefoundry.ai` | Sao Paulo | +| `au.gateway.truefoundry.ai` | Sydney, Melbourne, and Singapore | +| `as.gateway.truefoundry.ai` | Mumbai, Singapore, Doha, and Melbourne | -Entries are sorted deterministically and IPs are sorted numerically, so a `git diff` between versions is readable. +`us/ip-ranges.json` is empty until the US control plane publishes egress addresses. -JSON Schema for the source files: [`schema/nat-ips.schema.json`](schema/nat-ips.schema.json). +## When addresses change ---- +Address changes are published as a GitHub Release. -## For TrueFoundry engineers +A new address is announced in a release and starts carrying traffic **15 days** after that release. Allowlist it during those 15 days. -Source of truth is `data//.json`. Everything in `dist/` is generated — never edit it by hand. +When an address is removed, a new release is published. Drop it from your allowlist after that release. + +To be notified, choose **Watch → Custom → Releases** on this repository, or subscribe to: ``` -data/ -├── us/ -│ ├── control-plane.json # *.us.truefoundry.cloud -│ └── gateway.json # us.truefoundry.cloud + regional PoPs -└── eu/ - ├── control-plane.json # *.eu.truefoundry.cloud - └── gateway.json # eu.truefoundry.cloud + regional PoPs +https://github.com/truefoundry/nat-ips/releases.atom ``` -To add or change IPs: +## Updating the list -1. Edit the relevant file under `data/`. -2. Open a PR. CI validates the schema and comments the exact IP diff for review. -3. Merge to `main`. The release workflow builds `dist/`, tags `v`, and publishes a release with notes listing added and removed IPs. - -Run the same checks locally: +Edit `eu/ip-ranges.json` or `us/ip-ranges.json` and open a pull request. Merging to `main` publishes a release when an address is added or removed. ```bash -uv run scripts/validate.py -uv run scripts/build.py --version 0.0.0-local +uv run .github/scripts/validate.py +uv run .github/scripts/build.py --version 0.0.0-local ``` -Validation rejects malformed CIDRs, host bits set on a prefix, private or reserved addresses, overlapping ranges within an entry, gateway entries missing a `hostname` or `airport_code`, and a `control_plane` that disagrees with its directory. Duplicate IPs across entries are reported as warnings. - -To preview a release without publishing: **Actions → Release → Run workflow → dry run**. - -> The release workflow pushes a commit to `main`. If you enable branch protection, allow `github-actions[bot]` to bypass it, or the release step will fail. - ## Reporting a problem -Something wrong or missing? [Open an issue](https://github.com/truefoundry/nat-ips/issues) or contact TrueFoundry support. For anything security-sensitive, email security@truefoundry.com instead of filing a public issue. +Something wrong or missing? [Open an issue](https://github.com/truefoundry/nat-ips/issues) or contact TrueFoundry support. For anything security-sensitive, email security@truefoundry.com. diff --git a/data/eu/control-plane.json b/data/eu/control-plane.json deleted file mode 100644 index 064a191..0000000 --- a/data/eu/control-plane.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "../../schema/nat-ips.schema.json", - "plane": "control_plane", - "control_plane": "eu", - "host_pattern": "*.eu.truefoundry.cloud", - "entries": [ - { - "type": "control_plane", - "cloud": "aws", - "region": "eu-west-1", - "continent": "eu", - "nat_ips": [ - "34.175.219.109/32", - "34.175.44.33/32" - ], - "notes": "PLACEHOLDER - not real data, replace before making this repo public" - } - ] -} diff --git a/data/eu/gateway.json b/data/eu/gateway.json deleted file mode 100644 index 051bc0b..0000000 --- a/data/eu/gateway.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "../../schema/nat-ips.schema.json", - "plane": "gateway", - "control_plane": "eu", - "host": "eu.truefoundry.cloud", - "entries": [ - { - "type": "gateway", - "cloud": "gcp", - "region": "europe-southwest1", - "continent": "eu", - "airport_code": "mad", - "hostname": "mad.eu.truefoundry.cloud", - "nat_ips": [ - "34.175.219.109/32", - "34.175.44.33/32" - ], - "notes": "PLACEHOLDER - not real data, replace before making this repo public" - } - ] -} diff --git a/data/us/control-plane.json b/data/us/control-plane.json deleted file mode 100644 index 8584b7e..0000000 --- a/data/us/control-plane.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "$schema": "../../schema/nat-ips.schema.json", - "plane": "control_plane", - "control_plane": "us", - "host_pattern": "*.us.truefoundry.cloud", - "entries": [ - { - "type": "control_plane", - "cloud": "aws", - "region": "us-east-1", - "continent": "na", - "nat_ips": [ - "34.175.219.109/32", - "34.175.44.33/32" - ], - "notes": "PLACEHOLDER - not real data, replace before making this repo public" - } - ] -} diff --git a/data/us/gateway.json b/data/us/gateway.json deleted file mode 100644 index a696723..0000000 --- a/data/us/gateway.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "$schema": "../../schema/nat-ips.schema.json", - "plane": "gateway", - "control_plane": "us", - "host": "us.truefoundry.cloud", - "entries": [ - { - "type": "gateway", - "cloud": "gcp", - "region": "europe-southwest1", - "continent": "eu", - "airport_code": "mad", - "hostname": "mad.us.truefoundry.cloud", - "nat_ips": [ - "34.175.219.109/32", - "34.175.44.33/32" - ], - "notes": "PLACEHOLDER - not real data, replace before making this repo public" - } - ] -} diff --git a/dist/eu.json b/dist/eu.json deleted file mode 100644 index c887327..0000000 --- a/dist/eu.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": "0.0.0-unreleased", - "generation_timestamp": "2026-10-12T12:23:55Z", - "entry_count": 2, - "ip_count": 2, - "entries": [ - { - "control_plane": "eu", - "type": "control_plane", - "host": "*.eu.truefoundry.cloud", - "cloud": "aws", - "region": "eu-west-1", - "continent": "eu", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - }, - { - "control_plane": "eu", - "type": "gateway", - "host": "mad.eu.truefoundry.cloud", - "cloud": "gcp", - "region": "europe-southwest1", - "continent": "eu", - "gateway_host": "eu.truefoundry.cloud", - "airport_code": "mad", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - } - ] -} diff --git a/dist/eu.txt b/dist/eu.txt deleted file mode 100644 index aeea4a1..0000000 --- a/dist/eu.txt +++ /dev/null @@ -1,6 +0,0 @@ -# TrueFoundry outbound NAT IPs -# version: 0.0.0-unreleased -# generated: 2026-10-12T12:23:55Z -# https://github.com/truefoundry/nat-ips -34.175.44.33/32 -34.175.219.109/32 diff --git a/dist/nat-ips.json b/dist/nat-ips.json deleted file mode 100644 index 242a7c4..0000000 --- a/dist/nat-ips.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "version": "0.0.0-unreleased", - "generation_timestamp": "2026-10-12T12:23:55Z", - "entry_count": 4, - "ip_count": 2, - "entries": [ - { - "control_plane": "eu", - "type": "control_plane", - "host": "*.eu.truefoundry.cloud", - "cloud": "aws", - "region": "eu-west-1", - "continent": "eu", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - }, - { - "control_plane": "eu", - "type": "gateway", - "host": "mad.eu.truefoundry.cloud", - "cloud": "gcp", - "region": "europe-southwest1", - "continent": "eu", - "gateway_host": "eu.truefoundry.cloud", - "airport_code": "mad", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - }, - { - "control_plane": "us", - "type": "control_plane", - "host": "*.us.truefoundry.cloud", - "cloud": "aws", - "region": "us-east-1", - "continent": "na", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - }, - { - "control_plane": "us", - "type": "gateway", - "host": "mad.us.truefoundry.cloud", - "cloud": "gcp", - "region": "europe-southwest1", - "continent": "eu", - "gateway_host": "us.truefoundry.cloud", - "airport_code": "mad", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - } - ] -} diff --git a/dist/nat-ips.txt b/dist/nat-ips.txt deleted file mode 100644 index aeea4a1..0000000 --- a/dist/nat-ips.txt +++ /dev/null @@ -1,6 +0,0 @@ -# TrueFoundry outbound NAT IPs -# version: 0.0.0-unreleased -# generated: 2026-10-12T12:23:55Z -# https://github.com/truefoundry/nat-ips -34.175.44.33/32 -34.175.219.109/32 diff --git a/dist/us.json b/dist/us.json deleted file mode 100644 index 2e125fb..0000000 --- a/dist/us.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "version": "0.0.0-unreleased", - "generation_timestamp": "2026-10-12T12:23:55Z", - "entry_count": 2, - "ip_count": 2, - "entries": [ - { - "control_plane": "us", - "type": "control_plane", - "host": "*.us.truefoundry.cloud", - "cloud": "aws", - "region": "us-east-1", - "continent": "na", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - }, - { - "control_plane": "us", - "type": "gateway", - "host": "mad.us.truefoundry.cloud", - "cloud": "gcp", - "region": "europe-southwest1", - "continent": "eu", - "gateway_host": "us.truefoundry.cloud", - "airport_code": "mad", - "notes": "PLACEHOLDER - not real data, replace before making this repo public", - "nat_ips": [ - "34.175.44.33/32", - "34.175.219.109/32" - ] - } - ] -} diff --git a/dist/us.txt b/dist/us.txt deleted file mode 100644 index aeea4a1..0000000 --- a/dist/us.txt +++ /dev/null @@ -1,6 +0,0 @@ -# TrueFoundry outbound NAT IPs -# version: 0.0.0-unreleased -# generated: 2026-10-12T12:23:55Z -# https://github.com/truefoundry/nat-ips -34.175.44.33/32 -34.175.219.109/32 diff --git a/eu/ip-ranges.json b/eu/ip-ranges.json new file mode 100644 index 0000000..b21d022 --- /dev/null +++ b/eu/ip-ranges.json @@ -0,0 +1,227 @@ +{ + "$schema": "../.github/schema/ip-ranges.schema.json", + "endpoints": [ + { + "type": "gateway", + "hostname": "gateway.truefoundry.ai", + "nat_ips": [ + "3.105.148.45/32", + "4.165.99.208/32", + "4.165.129.123/32", + "13.42.115.230/32", + "13.126.176.139/32", + "13.237.191.204/32", + "13.244.129.178/32", + "13.246.115.110/32", + "13.251.96.74/32", + "15.207.191.126/32", + "16.51.12.109/32", + "16.51.101.207/32", + "18.130.148.118/32", + "18.143.12.175/32", + "20.245.207.53/32", + "34.18.22.15/32", + "34.18.192.62/32", + "34.39.220.106/32", + "34.39.220.236/32", + "34.130.196.232/32", + "34.130.234.128/32", + "34.174.18.136/32", + "34.174.240.150/32", + "34.175.44.33/32", + "34.175.219.109/32", + "40.75.129.73/32", + "52.4.51.133/32", + "54.204.114.10/32" + ] + }, + { + "type": "gateway", + "hostname": "us.gateway.truefoundry.ai", + "nat_ips": [ + "20.245.207.53/32", + "34.130.196.232/32", + "34.130.234.128/32", + "34.174.18.136/32", + "34.174.240.150/32", + "40.75.129.73/32", + "52.4.51.133/32", + "54.204.114.10/32" + ] + }, + { + "type": "gateway", + "hostname": "eu.gateway.truefoundry.ai", + "nat_ips": [ + "4.165.99.208/32", + "4.165.129.123/32", + "13.42.115.230/32", + "18.130.148.118/32", + "34.18.22.15/32", + "34.18.192.62/32", + "34.175.44.33/32", + "34.175.219.109/32" + ] + }, + { + "type": "gateway", + "hostname": "sa.gateway.truefoundry.ai", + "nat_ips": [ + "34.39.220.106/32", + "34.39.220.236/32" + ] + }, + { + "type": "gateway", + "hostname": "au.gateway.truefoundry.ai", + "nat_ips": [ + "3.105.148.45/32", + "13.237.191.204/32", + "13.251.96.74/32", + "16.51.12.109/32", + "16.51.101.207/32", + "18.143.12.175/32" + ] + }, + { + "type": "gateway", + "hostname": "as.gateway.truefoundry.ai", + "nat_ips": [ + "13.126.176.139/32", + "13.251.96.74/32", + "15.207.191.126/32", + "16.51.12.109/32", + "16.51.101.207/32", + "18.143.12.175/32", + "34.18.22.15/32", + "34.18.192.62/32" + ] + }, + { + "type": "gateway", + "airport_code": "bom", + "hostname": "bom.gateway.truefoundry.ai", + "nat_ips": [ + "13.126.176.139/32", + "15.207.191.126/32" + ] + }, + { + "type": "gateway", + "airport_code": "cpt", + "hostname": "cpt.gateway.truefoundry.ai", + "nat_ips": [ + "13.244.129.178/32", + "13.246.115.110/32" + ] + }, + { + "type": "gateway", + "airport_code": "dfw", + "hostname": "dfw.gateway.truefoundry.ai", + "nat_ips": [ + "34.174.18.136/32", + "34.174.240.150/32" + ] + }, + { + "type": "gateway", + "airport_code": "dia", + "hostname": "dia.gateway.truefoundry.ai", + "nat_ips": [ + "34.18.22.15/32", + "34.18.192.62/32" + ] + }, + { + "type": "gateway", + "airport_code": "gru", + "hostname": "gru.gateway.truefoundry.ai", + "nat_ips": [ + "34.39.220.106/32", + "34.39.220.236/32" + ] + }, + { + "type": "gateway", + "airport_code": "gvx", + "hostname": "gvx.gateway.truefoundry.ai", + "nat_ips": [ + "4.165.99.208/32", + "4.165.129.123/32" + ] + }, + { + "type": "gateway", + "airport_code": "lhr", + "hostname": "lhr.gateway.truefoundry.ai", + "nat_ips": [ + "13.42.115.230/32", + "18.130.148.118/32" + ] + }, + { + "type": "gateway", + "airport_code": "mad", + "hostname": "mad.gateway.truefoundry.ai", + "nat_ips": [ + "34.175.44.33/32", + "34.175.219.109/32" + ] + }, + { + "type": "gateway", + "airport_code": "mel", + "hostname": "mel.gateway.truefoundry.ai", + "nat_ips": [ + "16.51.12.109/32", + "16.51.101.207/32" + ] + }, + { + "type": "gateway", + "airport_code": "orf", + "hostname": "orf.gateway.truefoundry.ai", + "nat_ips": [ + "52.4.51.133/32", + "54.204.114.10/32" + ] + }, + { + "type": "gateway", + "airport_code": "sfo", + "hostname": "sfo.gateway.truefoundry.ai", + "nat_ips": [ + "20.245.207.53/32", + "40.75.129.73/32" + ] + }, + { + "type": "gateway", + "airport_code": "sin", + "hostname": "sin.gateway.truefoundry.ai", + "nat_ips": [ + "13.251.96.74/32", + "18.143.12.175/32" + ] + }, + { + "type": "gateway", + "airport_code": "syd", + "hostname": "syd.gateway.truefoundry.ai", + "nat_ips": [ + "3.105.148.45/32", + "13.237.191.204/32" + ] + }, + { + "type": "gateway", + "airport_code": "yyz", + "hostname": "yyz.gateway.truefoundry.ai", + "nat_ips": [ + "34.130.196.232/32", + "34.130.234.128/32" + ] + } + ] +} diff --git a/schema/nat-ips.schema.json b/schema/nat-ips.schema.json deleted file mode 100644 index a671c3b..0000000 --- a/schema/nat-ips.schema.json +++ /dev/null @@ -1,93 +0,0 @@ -{ - "$schema": "https://json-schema.org/draft/2020-12/schema", - "$id": "https://raw.githubusercontent.com/truefoundry/nat-ips/main/schema/nat-ips.schema.json", - "title": "TrueFoundry NAT IP source file", - "description": "Schema for the hand-edited source files under data/. Built artifacts in dist/ use the same entry shape inside a flat envelope.", - "type": "object", - "required": ["plane", "control_plane", "entries"], - "additionalProperties": false, - "properties": { - "$schema": { - "type": "string", - "description": "Relative path to this schema, so editors give completion and inline validation." - }, - "plane": { - "enum": ["control_plane", "gateway"], - "description": "Which plane this file describes." - }, - "control_plane": { - "enum": ["us", "eu"], - "description": "Which TrueFoundry control plane owns these IPs." - }, - "host": { - "type": "string", - "description": "Exact hostname customers call, e.g. us.truefoundry.cloud" - }, - "host_pattern": { - "type": "string", - "description": "Wildcard hostname pattern, e.g. *.us.truefoundry.cloud" - }, - "entries": { - "type": "array", - "minItems": 1, - "items": { "$ref": "#/$defs/entry" } - } - }, - "$defs": { - "entry": { - "type": "object", - "required": ["type", "cloud", "region", "continent", "nat_ips"], - "additionalProperties": false, - "properties": { - "type": { - "enum": ["control_plane", "gateway"] - }, - "cloud": { - "enum": ["aws", "gcp", "azure"] - }, - "region": { - "type": "string", - "minLength": 1, - "description": "Cloud provider region id, e.g. us-east-1 or europe-southwest1." - }, - "continent": { - "enum": ["na", "sa", "eu", "ap", "me", "af", "oc"] - }, - "airport_code": { - "type": "string", - "pattern": "^[a-z]{3}$", - "description": "IATA code for the PoP. Required for gateway entries." - }, - "hostname": { - "type": "string", - "format": "hostname", - "description": "Regional gateway endpoint customers resolve. Required for gateway entries." - }, - "notes": { - "type": "string" - }, - "nat_ips": { - "type": "array", - "minItems": 1, - "uniqueItems": true, - "items": { - "type": "string", - "pattern": "^((25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\\.){3}(25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])/(3[0-2]|[12]?[0-9])$", - "description": "IPv4 CIDR. Single addresses are written as /32." - } - } - }, - "allOf": [ - { - "if": { - "properties": { "type": { "const": "gateway" } }, - "required": ["type"] - }, - "then": { - "required": ["airport_code", "hostname"] - } - } - ] - } - } -} diff --git a/scripts/build.py b/scripts/build.py deleted file mode 100644 index e5aab5a..0000000 --- a/scripts/build.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# dependencies = [] -# /// -"""Flatten the per-plane source files under data/ into the published artifacts in dist/. - -Run with: uv run scripts/build.py --version 2026.10.12 -""" - -from __future__ import annotations - -import argparse -import ipaddress -import json -from datetime import datetime, timezone -from pathlib import Path - -ROOT = Path(__file__).resolve().parent.parent -DATA_DIR = ROOT / "data" -DIST_DIR = ROOT / "dist" - -PLANES = ("us", "eu") - - -def sort_key(entry: dict) -> tuple: - """Stable ordering so a rebuild with no data change produces a byte-identical file.""" - return ( - entry["control_plane"], - 0 if entry["type"] == "control_plane" else 1, - entry.get("airport_code", ""), - entry["region"], - ) - - -def load_entries() -> list[dict]: - entries: list[dict] = [] - - for path in sorted(DATA_DIR.rglob("*.json")): - doc = json.loads(path.read_text()) - control_plane = doc["control_plane"] - - for source in doc["entries"]: - entry = { - "control_plane": control_plane, - "type": source["type"], - # host is always the endpoint a customer actually talks to. - "host": source.get("hostname") or doc.get("host_pattern") or doc.get("host"), - "cloud": source["cloud"], - "region": source["region"], - "continent": source["continent"], - } - if source["type"] == "gateway": - entry["gateway_host"] = doc["host"] - entry["airport_code"] = source["airport_code"] - if "notes" in source: - entry["notes"] = source["notes"] - - entry["nat_ips"] = sorted( - source["nat_ips"], key=lambda cidr: ipaddress.IPv4Network(cidr) - ) - entries.append(entry) - - entries.sort(key=sort_key) - return entries - - -def flat_ip_list(entries: list[dict]) -> list[str]: - unique = {cidr for entry in entries for cidr in entry["nat_ips"]} - return sorted(unique, key=lambda cidr: ipaddress.IPv4Network(cidr)) - - -def write_json(path: Path, payload: dict) -> None: - path.write_text(json.dumps(payload, indent=2) + "\n") - print(f"wrote {path.relative_to(ROOT)}") - - -def write_text(path: Path, version: str, timestamp: str, ips: list[str]) -> None: - header = [ - "# TrueFoundry outbound NAT IPs", - f"# version: {version}", - f"# generated: {timestamp}", - "# https://github.com/truefoundry/nat-ips", - ] - path.write_text("\n".join(header + ips) + "\n") - print(f"wrote {path.relative_to(ROOT)}") - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--version", required=True, help="CalVer release, e.g. 2026.10.12") - args = parser.parse_args() - - timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - entries = load_entries() - DIST_DIR.mkdir(exist_ok=True) - - def envelope(subset: list[dict]) -> dict: - return { - "version": args.version, - "generation_timestamp": timestamp, - "entry_count": len(subset), - "ip_count": len(flat_ip_list(subset)), - "entries": subset, - } - - write_json(DIST_DIR / "nat-ips.json", envelope(entries)) - write_text(DIST_DIR / "nat-ips.txt", args.version, timestamp, flat_ip_list(entries)) - - for plane in PLANES: - subset = [e for e in entries if e["control_plane"] == plane] - if not subset: - continue - write_json(DIST_DIR / f"{plane}.json", envelope(subset)) - write_text(DIST_DIR / f"{plane}.txt", args.version, timestamp, flat_ip_list(subset)) - - print(f"\n{len(entries)} entries, {len(flat_ip_list(entries))} unique IPs, version {args.version}") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/release_notes.py b/scripts/release_notes.py deleted file mode 100644 index d137eae..0000000 --- a/scripts/release_notes.py +++ /dev/null @@ -1,142 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# dependencies = [] -# /// -"""Diff two built nat-ips.json files and write release notes a firewall admin can act on. - -Run with: uv run scripts/release_notes.py --new dist/nat-ips.json --old /tmp/prev.json -""" - -from __future__ import annotations - -import argparse -import ipaddress -import json -from collections import defaultdict -from pathlib import Path - -REPO = "https://github.com/truefoundry/nat-ips" - - -def ip_owners(doc: dict) -> dict[str, list[dict]]: - """Map every CIDR to all entries that use it. - - One NAT gateway can back several entries, so this is deliberately many-to-many — - attributing an IP to a single entry would hide where it is actually in use. - """ - index: dict[str, list[dict]] = defaultdict(list) - for entry in doc.get("entries", []): - for cidr in entry["nat_ips"]: - index[cidr].append(entry) - return index - - -def describe(entry: dict) -> str: - bits = [entry["control_plane"].upper(), entry["type"].replace("_", " "), entry["region"]] - if entry.get("airport_code"): - bits.append(entry["airport_code"].upper()) - return " · ".join(bits) - - -def ip_sort(cidr: str) -> ipaddress.IPv4Network: - return ipaddress.IPv4Network(cidr) - - -def render_changed(title: str, cidrs: list[str], index: dict[str, list[dict]]) -> list[str]: - """Group changed IPs under every entry that uses them.""" - if not cidrs: - return [] - - buckets: dict[tuple[str, str], set[str]] = defaultdict(set) - for cidr in cidrs: - for entry in index[cidr]: - buckets[(describe(entry), entry.get("host", ""))].add(cidr) - - lines = [f"### {title} ({len(cidrs)})", ""] - for (label, host), group in sorted(buckets.items()): - lines.append(f"**{label}** — `{host}`") - lines.extend(f"- `{cidr}`" for cidr in sorted(group, key=ip_sort)) - lines.append("") - return lines - - -def render_full(doc: dict) -> list[str]: - """List every entry in order — used for the first release, where nothing is a 'change'.""" - lines = ["### Published IPs", ""] - for entry in doc.get("entries", []): - lines.append(f"**{describe(entry)}** — `{entry.get('host', '')}`") - lines.extend(f"- `{cidr}`" for cidr in entry["nat_ips"]) - lines.append("") - return lines - - -def main() -> int: - parser = argparse.ArgumentParser() - parser.add_argument("--new", required=True, type=Path) - parser.add_argument("--old", type=Path, help="omit for the first release") - parser.add_argument("--out", type=Path, default=Path("RELEASE_NOTES.md")) - args = parser.parse_args() - - new_doc = json.loads(args.new.read_text()) - new_index = ip_owners(new_doc) - version = new_doc["version"] - - old_doc: dict = {} - if args.old and args.old.exists(): - old_doc = json.loads(args.old.read_text()) - old_index = ip_owners(old_doc) - - added = sorted(set(new_index) - set(old_index), key=ip_sort) - removed = sorted(set(old_index) - set(new_index), key=ip_sort) - - lines = [f"## TrueFoundry outbound NAT IPs — {version}", ""] - - if not old_doc: - lines += [ - "First published release. Allowlist the IPs below for traffic arriving from TrueFoundry.", - "", - ] - lines += render_full(new_doc) - elif not added and not removed: - lines += [ - f"No IP changes since `{old_doc['version']}`. Metadata or docs only — **no firewall action needed**.", - "", - ] - else: - action = [] - if added: - action.append(f"**{len(added)} IP(s) to add**") - if removed: - action.append(f"**{len(removed)} IP(s) safe to remove**") - lines += [ - f"Changes since `{old_doc['version']}`: " + " and ".join(action) + ".", - "", - "> Add new IPs **before** the rollout date. Removing retired IPs is optional and can wait.", - "", - ] - lines += render_changed("Added", added, new_index) - lines += render_changed("Removed", removed, old_index) - - lines += [ - "---", - "", - "### Full list", - "", - f"- Everything: {REPO}/releases/download/v{version}/nat-ips.json", - f"- US control plane: {REPO}/releases/download/v{version}/us.json", - f"- EU control plane: {REPO}/releases/download/v{version}/eu.json", - "- Plain text for firewall scripts: swap `.json` for `.txt` in any URL above.", - f"- Always-latest instead of pinned: {REPO}/releases/latest/download/nat-ips.json", - "", - f"Totals in this release: **{new_doc['entry_count']} entries, {new_doc['ip_count']} unique IPs**.", - "", - ] - - args.out.write_text("\n".join(lines)) - print(f"wrote {args.out} ({len(added)} added, {len(removed)} removed)") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/validate.py b/scripts/validate.py deleted file mode 100644 index 1c4933d..0000000 --- a/scripts/validate.py +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env python3 -# /// script -# requires-python = ">=3.11" -# dependencies = ["jsonschema>=4.21"] -# /// -"""Validate every source file under data/ against the schema and our own sanity rules. - -Run with: uv run scripts/validate.py -""" - -from __future__ import annotations - -import ipaddress -import json -import re -import sys -from collections import defaultdict -from pathlib import Path - -from jsonschema import Draft202012Validator - -ROOT = Path(__file__).resolve().parent.parent -DATA_DIR = ROOT / "data" -SCHEMA_PATH = ROOT / "schema" / "nat-ips.schema.json" - -HOSTNAME_RE = re.compile(r"^(?!-)[a-z0-9-]{1,63}(? None: - errors.append(f"{where}: {msg}") - - -def warn(where: str, msg: str) -> None: - warnings.append(f"{where}: {msg}") - - -def check_cidr(where: str, raw: str) -> ipaddress.IPv4Network | None: - """Parse a CIDR and reject anything that cannot be a public NAT egress address.""" - try: - net = ipaddress.IPv4Network(raw, strict=True) - except ValueError as exc: - # strict=True rejects host bits set, e.g. 10.0.0.1/24 — call that out specifically. - error(where, f"invalid CIDR {raw!r}: {exc}") - return None - - if not net.is_global: - error(where, f"{raw} is not a globally routable address; NAT egress IPs must be public") - return net - - -def validate_file(path: Path, validator: Draft202012Validator) -> list[dict]: - rel = path.relative_to(ROOT) - try: - doc = json.loads(path.read_text()) - except json.JSONDecodeError as exc: - error(str(rel), f"not valid JSON: {exc}") - return [] - - schema_errors = sorted(validator.iter_errors(doc), key=lambda e: list(e.path)) - if schema_errors: - for exc in schema_errors: - location = "/".join(str(p) for p in exc.path) or "" - error(str(rel), f"{location}: {exc.message}") - return [] - - # The directory is the source of truth for which control plane a file belongs to. - expected_plane = path.parent.name - if doc["control_plane"] != expected_plane: - error(str(rel), f"control_plane is {doc['control_plane']!r} but the file lives under data/{expected_plane}/") - - expected_type = doc["plane"] - if expected_type == "control_plane" and "host_pattern" not in doc: - error(str(rel), "control-plane files need a host_pattern") - if expected_type == "gateway" and "host" not in doc: - error(str(rel), "gateway files need a host") - - built: list[dict] = [] - for index, entry in enumerate(doc["entries"]): - where = f"{rel} entries[{index}]" - - if entry["type"] != expected_type: - error(where, f"type is {entry['type']!r} but this file declares plane {expected_type!r}") - - hostname = entry.get("hostname") - if hostname and not HOSTNAME_RE.match(hostname): - error(where, f"hostname {hostname!r} is not a valid lowercase FQDN") - - nets = [n for n in (check_cidr(where, ip) for ip in entry["nat_ips"]) if n] - for a_index, a in enumerate(nets): - for b in nets[a_index + 1 :]: - if a.overlaps(b): - error(where, f"{a} and {b} overlap; collapse them into one entry") - - built.append({"file": str(rel), "entry": entry, "nets": nets}) - - return built - - -def check_cross_file(records: list[dict]) -> None: - """A NAT IP appearing in two places is usually a copy-paste slip, not a real shared gateway.""" - seen: dict[str, list[str]] = defaultdict(list) - for record in records: - entry = record["entry"] - label = f"{record['file']} ({entry['region']})" - for net in record["nets"]: - seen[str(net)].append(label) - - for cidr, locations in sorted(seen.items()): - if len(locations) > 1: - warn("duplicate IP", f"{cidr} appears in {len(locations)} entries: {', '.join(locations)}") - - -def main() -> int: - if not SCHEMA_PATH.exists(): - print(f"missing schema at {SCHEMA_PATH}", file=sys.stderr) - return 1 - - schema = json.loads(SCHEMA_PATH.read_text()) - Draft202012Validator.check_schema(schema) - validator = Draft202012Validator(schema) - - paths = sorted(DATA_DIR.rglob("*.json")) - if not paths: - print("no source files found under data/", file=sys.stderr) - return 1 - - records: list[dict] = [] - for path in paths: - records.extend(validate_file(path, validator)) - check_cross_file(records) - - for message in warnings: - print(f"warning {message}") - for message in errors: - print(f"error {message}", file=sys.stderr) - - total_ips = sum(len(r["nets"]) for r in records) - print(f"\nchecked {len(paths)} files, {len(records)} entries, {total_ips} IPs") - - if errors: - print(f"FAILED with {len(errors)} error(s)", file=sys.stderr) - return 1 - - print("OK") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/us/ip-ranges.json b/us/ip-ranges.json new file mode 100644 index 0000000..5e00f9b --- /dev/null +++ b/us/ip-ranges.json @@ -0,0 +1,4 @@ +{ + "$schema": "../.github/schema/ip-ranges.schema.json", + "endpoints": [] +}