@catalyst-cloud/cli 0.14.10 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/README.md +41 -0
- package/dist/args.js +34 -2
- package/dist/capabilities.js +2 -1
- package/dist/cli-update-notice.js +76 -0
- package/dist/cli.js +263 -101
- package/dist/oauth.js +48 -18
- package/dist/onboard-accounts.js +34 -5
- package/dist/onboard-automation-management.js +27 -9
- package/dist/onboard-capacity.js +10 -1
- package/dist/onboard-existing.js +132 -31
- package/dist/onboard-github.js +171 -6
- package/dist/onboard-headless.js +647 -0
- package/dist/onboard-interrupts.js +41 -0
- package/dist/onboard-next.js +215 -0
- package/dist/onboard-permissions.js +100 -0
- package/dist/onboard-personal.js +46 -10
- package/dist/onboard-runner-cloud.js +213 -0
- package/dist/onboard-runner-egress.js +67 -0
- package/dist/onboard-runner.js +780 -0
- package/dist/onboard-runtime.js +48 -8
- package/dist/onboard-standalone-copy.js +110 -0
- package/dist/onboard-team-create.js +267 -0
- package/dist/onboard-ui.js +623 -29
- package/dist/onboard-workflow.js +311 -168
- package/dist/onboard-workspace.js +52 -5
- package/dist/onboard.js +359 -212
- package/dist/setup-events.js +100 -0
- package/dist/setup-onboard-copy.js +438 -0
- package/dist/setup-prompt-frame.js +94 -0
- package/dist/setup-prompts.js +108 -0
- package/dist/setup-render.js +339 -0
- package/dist/setup-signin.js +187 -0
- package/dist/setup.js +426 -0
- package/package.json +7 -3
- package/skills/catalyst-github/SKILL.md +1 -1
- package/skills/catalyst-linear/SKILL.md +1 -1
- package/skills/catalyst-onboard/SKILL.md +3 -2
- package/skills/run-this-project/SKILL.md +1 -1
- package/skills/unstick/SKILL.md +1 -1
- package/skills/what-needs-me/SKILL.md +1 -1
- package/skills/what-this-repo-needs/SKILL.md +1 -1
- package/skills/whats-happening/SKILL.md +1 -1
- package/vendor/README.md +4 -0
- package/vendor/self-host/compose.yaml +110 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.service +14 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress-attest.timer +10 -0
- package/vendor/self-host/linux-session-egress/catalyst-session-egress.service +19 -0
- package/vendor/self-host/linux-session-egress/session-egress.py +291 -0
- package/vendor/self-host/provenance.json +11 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# deploy/self-host/compose.yaml — one Catalyst host on one Docker engine.
|
|
2
|
+
#
|
|
3
|
+
# The same file runs on Linux (amd64 or arm64) and on macOS through OrbStack or Docker Desktop. It
|
|
4
|
+
# assumes nothing from the host's init system: no systemd unit, timer or drop-in. Copy .env.example
|
|
5
|
+
# to .env, fill it in, then `docker compose up -d`. README.md explains every choice below.
|
|
6
|
+
#
|
|
7
|
+
# Two long-running services, and neither depends on the other:
|
|
8
|
+
# - supervisor: enrolls this host with a one-time join token, leases work from the tenant DO, and
|
|
9
|
+
# runs each phase as disposable setup and session containers (apps/host-agent/Dockerfile.supervisor).
|
|
10
|
+
# - deadline-watchdog: stops an attempt container once its hard deadline passes, including while
|
|
11
|
+
# the supervisor is dead. That is why it has no `depends_on`.
|
|
12
|
+
# Attempt containers are NOT services here. The supervisor creates them through the Docker API.
|
|
13
|
+
|
|
14
|
+
name: catalyst-host
|
|
15
|
+
|
|
16
|
+
x-hardened: &hardened
|
|
17
|
+
read_only: true
|
|
18
|
+
cap_drop: [ALL]
|
|
19
|
+
security_opt: ["no-new-privileges:true"]
|
|
20
|
+
# ⛔ unless-stopped, never on-failure. A daemon can exit 0 when every slot has retired (today's
|
|
21
|
+
# host agent does exactly that), and on-failure leaves an exit-0 service down for good.
|
|
22
|
+
restart: unless-stopped
|
|
23
|
+
logging:
|
|
24
|
+
driver: local
|
|
25
|
+
options:
|
|
26
|
+
max-size: 10m
|
|
27
|
+
max-file: "3"
|
|
28
|
+
tmpfs:
|
|
29
|
+
- /tmp:size=16m,mode=1777
|
|
30
|
+
|
|
31
|
+
services:
|
|
32
|
+
deadline-watchdog:
|
|
33
|
+
<<: *hardened
|
|
34
|
+
image: ${CATALYST_WATCHDOG_IMAGE:?set CATALYST_WATCHDOG_IMAGE to a digest-pinned image, see .env.example}
|
|
35
|
+
# The Docker socket is root:<gid> 0660 on every engine we run on (docker group on Linux, gid 0 on
|
|
36
|
+
# OrbStack and Docker Desktop). The image runs as uid 10003, so it needs that group and no more.
|
|
37
|
+
group_add:
|
|
38
|
+
- "${DOCKER_SOCKET_GID:?set DOCKER_SOCKET_GID, see .env.example}"
|
|
39
|
+
volumes:
|
|
40
|
+
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
|
41
|
+
environment:
|
|
42
|
+
CATALYST_WATCHDOG_STOP_GRACE_SECONDS: ${CATALYST_WATCHDOG_STOP_GRACE_SECONDS:-120}
|
|
43
|
+
CATALYST_WATCHDOG_INTERVAL_MS: ${CATALYST_WATCHDOG_INTERVAL_MS:-5000}
|
|
44
|
+
CATALYST_WATCHDOG_MAX_UNREACHABLE_MS: ${CATALYST_WATCHDOG_MAX_UNREACHABLE_MS:-30000}
|
|
45
|
+
CATALYST_WATCHDOG_DOCKER_API_VERSION: ${CATALYST_WATCHDOG_DOCKER_API_VERSION:-1.44}
|
|
46
|
+
# A stop it has started keeps running on the engine's side after the watchdog exits.
|
|
47
|
+
stop_grace_period: 10s
|
|
48
|
+
|
|
49
|
+
supervisor:
|
|
50
|
+
<<: *hardened
|
|
51
|
+
# The environment names are the contract's SELF_HOST_SUPERVISOR_ENV
|
|
52
|
+
# (packages/protocol/src/host-enrollment.ts), exactly; a test in apps/deadline-watchdog holds
|
|
53
|
+
# this file to that list. The image runs as 10001:10001 with a read-only root; exit 1 means the
|
|
54
|
+
# Engine was unreachable for 2 min (a fresh container remounts the socket), exit 2 a config error.
|
|
55
|
+
image: ${CATALYST_SUPERVISOR_IMAGE:?set CATALYST_SUPERVISOR_IMAGE to a digest-pinned image, see .env.example}
|
|
56
|
+
group_add:
|
|
57
|
+
- "${DOCKER_SOCKET_GID:?set DOCKER_SOCKET_GID, see .env.example}"
|
|
58
|
+
volumes:
|
|
59
|
+
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
|
60
|
+
# The host journal and outbox. Never authoritative: deleting it costs undelivered evidence and
|
|
61
|
+
# never blocks a start (docs/design/disposable-host-contracts.md, "The host journal").
|
|
62
|
+
- supervisor-state:/var/lib/catalyst/supervisor
|
|
63
|
+
# The per-host credential and the tenant's runner org key (CATALYST_ORG_KEY_FILE), on their own
|
|
64
|
+
# volume so the delete-the-state-directory test (removing supervisor-state) never costs an
|
|
65
|
+
# enrollment. The supervisor replaces the credential by rename, so the directory stays writable.
|
|
66
|
+
- host-credential:/var/lib/catalyst/host-credential
|
|
67
|
+
# ⛔ SAME PATH ON BOTH SIDES. The supervisor passes workspace paths to `docker create`, and the
|
|
68
|
+
# engine resolves a bind source on the HOST. A path that exists only inside this container
|
|
69
|
+
# mounts an empty directory into the phase.
|
|
70
|
+
- ${CATALYST_SLOTS_DIR:-/srv/catalyst/slots}:${CATALYST_SLOTS_DIR:-/srv/catalyst/slots}
|
|
71
|
+
# Thoughts stay as today (CTC-3493): a tenant's thoughts checkout and its pool lock directory
|
|
72
|
+
# are SHARED and read-write across that tenant's session containers, and every thoughts sync
|
|
73
|
+
# runs under thoughts.lock. The supervisor keeps one subdirectory per tenant under each root,
|
|
74
|
+
# so two projects on one engine never share a checkout or a lock. Same path on both sides for
|
|
75
|
+
# the same reason as the slots: the supervisor binds these into session containers, and the
|
|
76
|
+
# engine resolves bind sources on the host. Never mounted into a setup container.
|
|
77
|
+
- ${CATALYST_THOUGHTS_DIR:-/srv/catalyst/thoughts}:${CATALYST_THOUGHTS_DIR:-/srv/catalyst/thoughts}
|
|
78
|
+
- ${CATALYST_POOL_LOCK_DIR:-/srv/catalyst/locks}:${CATALYST_POOL_LOCK_DIR:-/srv/catalyst/locks}
|
|
79
|
+
# CTC-4266: the host's proof that its nftables chain drops private ranges from the session
|
|
80
|
+
# bridge, written by a root timer. Read-only. Only a native Linux engine needs a file in it;
|
|
81
|
+
# on Docker Desktop the directory stays empty (README.md, "Create the session network").
|
|
82
|
+
- ${CATALYST_SESSION_EGRESS_DIR:-./session-egress}:/var/lib/catalyst/session-egress:ro
|
|
83
|
+
environment:
|
|
84
|
+
CATALYST_MIRROR_URL: ${CATALYST_MIRROR_URL:-https://staging.catalystcloud.dev}
|
|
85
|
+
CATALYST_HOST_NAME: ${CATALYST_HOST_NAME:-}
|
|
86
|
+
CATALYST_HOST_JOIN_TOKEN: ${CATALYST_HOST_JOIN_TOKEN:-}
|
|
87
|
+
CATALYST_RUNNER_IMAGE: ${CATALYST_RUNNER_IMAGE:?set CATALYST_RUNNER_IMAGE to name@sha256, see .env.example}
|
|
88
|
+
CATALYST_SLOTS: ${CATALYST_SLOTS:-2}
|
|
89
|
+
CATALYST_SLOTS_DIR: ${CATALYST_SLOTS_DIR:-/srv/catalyst/slots}
|
|
90
|
+
# Empty lets the supervisor choose: filesystem when it finds per-slot filesystems on a native
|
|
91
|
+
# Linux engine, budget otherwise (always budget on macOS).
|
|
92
|
+
CATALYST_SLOT_DISK_MODE: ${CATALYST_SLOT_DISK_MODE:-}
|
|
93
|
+
CATALYST_SLOT_DISK_BUDGET_GIB: ${CATALYST_SLOT_DISK_BUDGET_GIB:-20}
|
|
94
|
+
CATALYST_HOST_CREDENTIAL_FILE: ${CATALYST_HOST_CREDENTIAL_FILE:-/var/lib/catalyst/host-credential/credential}
|
|
95
|
+
CATALYST_DISK_BUDGET_GIB: ${CATALYST_DISK_BUDGET_GIB:-60}
|
|
96
|
+
CATALYST_WORKSPACE_STORAGE: ${CATALYST_WORKSPACE_STORAGE:-}
|
|
97
|
+
CATALYST_THOUGHTS_DIR: ${CATALYST_THOUGHTS_DIR:-/srv/catalyst/thoughts}
|
|
98
|
+
CATALYST_POOL_LOCK_DIR: ${CATALYST_POOL_LOCK_DIR:-/srv/catalyst/locks}
|
|
99
|
+
CATALYST_ORG_KEY_FILE: ${CATALYST_ORG_KEY_FILE:-/var/lib/catalyst/host-credential/org-key}
|
|
100
|
+
# Every setup and session container joins this network. It must exist before the supervisor
|
|
101
|
+
# takes work: missing or not isolated is not-ready, never the default bridge.
|
|
102
|
+
CATALYST_SESSION_NETWORK: ${CATALYST_SESSION_NETWORK:-catalyst-session-v1}
|
|
103
|
+
CATALYST_SESSION_EGRESS_ATTESTATION: ${CATALYST_SESSION_EGRESS_ATTESTATION:-/var/lib/catalyst/session-egress/attestation.json}
|
|
104
|
+
# SIGTERM re-adopts on the next start instead of stopping phases, so the supervisor needs only
|
|
105
|
+
# long enough to flush its journal.
|
|
106
|
+
stop_grace_period: 30s
|
|
107
|
+
|
|
108
|
+
volumes:
|
|
109
|
+
supervisor-state:
|
|
110
|
+
host-credential:
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Prove the Catalyst session egress chain and rewrite its attestation (CTC-4415)
|
|
3
|
+
After=catalyst-session-egress.service
|
|
4
|
+
Requires=docker.service
|
|
5
|
+
|
|
6
|
+
[Service]
|
|
7
|
+
Type=oneshot
|
|
8
|
+
ExecStart=/usr/bin/python3 /usr/lib/catalyst/session-egress.py attest
|
|
9
|
+
NoNewPrivileges=yes
|
|
10
|
+
ProtectSystem=strict
|
|
11
|
+
StateDirectory=catalyst/session-egress
|
|
12
|
+
StateDirectoryMode=0755
|
|
13
|
+
ProtectHome=yes
|
|
14
|
+
PrivateTmp=yes
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[Unit]
|
|
2
|
+
Description=Catalyst self-host session network and its egress chain (CTC-4415)
|
|
3
|
+
After=docker.service network-online.target
|
|
4
|
+
Requires=docker.service
|
|
5
|
+
Wants=network-online.target
|
|
6
|
+
|
|
7
|
+
[Service]
|
|
8
|
+
Type=oneshot
|
|
9
|
+
ExecStart=/usr/bin/python3 /usr/lib/catalyst/session-egress.py converge
|
|
10
|
+
RemainAfterExit=yes
|
|
11
|
+
NoNewPrivileges=yes
|
|
12
|
+
ProtectSystem=strict
|
|
13
|
+
StateDirectory=catalyst/session-egress
|
|
14
|
+
StateDirectoryMode=0755
|
|
15
|
+
ProtectHome=yes
|
|
16
|
+
PrivateTmp=yes
|
|
17
|
+
|
|
18
|
+
[Install]
|
|
19
|
+
WantedBy=multi-user.target
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Own and prove the self-host session network's egress policy (CTC-4415, for CTC-4266).
|
|
3
|
+
|
|
4
|
+
A self-hosted supervisor on a native Linux engine is not-ready until it reads a fresh attestation
|
|
5
|
+
that an nftables forward chain drops private ranges from its session bridge (`host.session-egress`,
|
|
6
|
+
apps/host-agent/src/supervisor/network.ts). The supervisor runs unprivileged and cannot read
|
|
7
|
+
nftables, so this root helper does the work and writes the proof:
|
|
8
|
+
|
|
9
|
+
converge make sure catalyst-session-v1 exists with the reviewed shape, install the forward and
|
|
10
|
+
input chains in its own table, then attest (the boot service).
|
|
11
|
+
attest prove both chains and the network still match, and rewrite the attestation (the 5-minute
|
|
12
|
+
timer). Any mismatch removes the old attestation, so the host goes not-ready at once.
|
|
13
|
+
|
|
14
|
+
Modeled on apps/host-provision/infra/ensure-research-network.py and egress-policy.py
|
|
15
|
+
(research-egress-v1), with the same drop list. It owns ONE nftables table, inet catalyst_session,
|
|
16
|
+
and never touches another table, so it is safe on a box with its own firewall.
|
|
17
|
+
"""
|
|
18
|
+
import json, os, subprocess, sys, tempfile, time
|
|
19
|
+
|
|
20
|
+
NAME = "catalyst-session-v1"
|
|
21
|
+
# Linux interface names are limited to 15 visible bytes.
|
|
22
|
+
BRIDGE = "catalyst-sess0"
|
|
23
|
+
LABEL_KEY = "dev.catalystcloud.session-network"
|
|
24
|
+
LABEL_VALUE = "v1"
|
|
25
|
+
# Inter-container traffic is off: a phase cannot reach another tenant's container on the bridge.
|
|
26
|
+
OPTIONS = {"com.docker.network.bridge.name": BRIDGE, "com.docker.network.bridge.enable_icc": "false"}
|
|
27
|
+
ENFORCEMENT = "nftables-docker-forward-v1"
|
|
28
|
+
TABLE = "catalyst_session"
|
|
29
|
+
CHAIN = "session_egress"
|
|
30
|
+
# CTC-4415 P1 (Codex, #8870): a session's traffic to the bridge gateway, or to any address the host
|
|
31
|
+
# holds, is delivered locally and traverses INPUT, never FORWARD. A dedicated host drops it with its
|
|
32
|
+
# own default-drop input chain (host-provision's render.ts). An arbitrary self-host box may accept
|
|
33
|
+
# everything there, so this table owns an input chain too.
|
|
34
|
+
INPUT_CHAIN = "session_input"
|
|
35
|
+
DEFAULT_ATTESTATION = "/var/lib/catalyst/session-egress/attestation.json"
|
|
36
|
+
# The gateway, RFC 1918, CGNAT, loopback, link-local, benchmarking, multicast and reserved ranges:
|
|
37
|
+
# the research-egress-v1 list, kept equal by apps/host-agent/test/session-egress-producer.test.ts.
|
|
38
|
+
IPV4 = [('0.0.0.0', 8), ('10.0.0.0', 8), ('100.64.0.0', 10), ('127.0.0.0', 8), ('169.254.0.0', 16), ('172.16.0.0', 12), ('192.0.0.0', 24), ('192.168.0.0', 16), ('198.18.0.0', 15), ('224.0.0.0', 4), ('240.0.0.0', 4)]
|
|
39
|
+
IPV6 = [('::1', 128), ('fc00::', 7), ('fe80::', 10), ('ff00::', 8)]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Refusal(RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def run(argv, stdin=None):
|
|
47
|
+
try:
|
|
48
|
+
return subprocess.run(
|
|
49
|
+
argv,
|
|
50
|
+
input=stdin,
|
|
51
|
+
capture_output=True,
|
|
52
|
+
text=True,
|
|
53
|
+
timeout=30,
|
|
54
|
+
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/var/empty", "LC_ALL": "C"},
|
|
55
|
+
)
|
|
56
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
57
|
+
raise Refusal(os.path.basename(argv[0]) + "_unavailable") from error
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------------------------
|
|
61
|
+
# The network.
|
|
62
|
+
|
|
63
|
+
def inspect(docker):
|
|
64
|
+
result = run([docker, "network", "inspect", NAME])
|
|
65
|
+
if result.returncode != 0:
|
|
66
|
+
# Docker 29 prints [] for a missing network; Docker 28 prints nothing.
|
|
67
|
+
missing = f"Error response from daemon: network {NAME} not found"
|
|
68
|
+
if result.stdout.strip() not in ("", "[]") or result.stderr.strip() != missing:
|
|
69
|
+
raise Refusal("network_inspection_failed")
|
|
70
|
+
return None
|
|
71
|
+
try:
|
|
72
|
+
value = json.loads(result.stdout)
|
|
73
|
+
except json.JSONDecodeError as error:
|
|
74
|
+
raise Refusal("network_inspection_invalid") from error
|
|
75
|
+
if not isinstance(value, list) or len(value) != 1 or not isinstance(value[0], dict):
|
|
76
|
+
raise Refusal("network_inspection_invalid")
|
|
77
|
+
return value[0]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def matches(network):
|
|
81
|
+
"""The keys the supervisor verifies (sessionNetworkShape), plus this helper's own bridge name,
|
|
82
|
+
which its chains are bound to. Required keys only: an operator's extra option (an MTU) or an
|
|
83
|
+
unrelated label is kept, as the supervisor keeps it (CTC-4415 P2, Codex #8870), never removed
|
|
84
|
+
with the network. No subnet is pinned: Docker picks a free one, and a customer box may already
|
|
85
|
+
use research-egress-v1's 172.30.0.0/24."""
|
|
86
|
+
if not isinstance(network, dict):
|
|
87
|
+
return False
|
|
88
|
+
options = network.get("Options")
|
|
89
|
+
labels = network.get("Labels")
|
|
90
|
+
return (
|
|
91
|
+
network.get("Name") == NAME
|
|
92
|
+
and network.get("Driver") == "bridge"
|
|
93
|
+
and network.get("Internal") is False
|
|
94
|
+
and network.get("EnableIPv6") is False
|
|
95
|
+
and network.get("Ingress") is False
|
|
96
|
+
and network.get("ConfigOnly", False) is False
|
|
97
|
+
and isinstance(options, dict)
|
|
98
|
+
and all(options.get(key) == value for key, value in OPTIONS.items())
|
|
99
|
+
and isinstance(labels, dict)
|
|
100
|
+
and labels.get(LABEL_KEY) == LABEL_VALUE
|
|
101
|
+
and isinstance(network.get("Containers"), dict)
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def create(docker):
|
|
106
|
+
result = run(
|
|
107
|
+
[
|
|
108
|
+
docker,
|
|
109
|
+
"network",
|
|
110
|
+
"create",
|
|
111
|
+
"--driver=bridge",
|
|
112
|
+
*["--opt=" + key + "=" + value for key, value in OPTIONS.items()],
|
|
113
|
+
"--label=" + LABEL_KEY + "=" + LABEL_VALUE,
|
|
114
|
+
NAME,
|
|
115
|
+
]
|
|
116
|
+
)
|
|
117
|
+
if result.returncode != 0 or result.stdout.strip() == "":
|
|
118
|
+
raise Refusal("network_create_failed")
|
|
119
|
+
if not matches(inspect(docker)):
|
|
120
|
+
raise Refusal("network_create_mismatch")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def ensure_network(docker):
|
|
124
|
+
network = inspect(docker)
|
|
125
|
+
if network is None:
|
|
126
|
+
create(docker)
|
|
127
|
+
return "created"
|
|
128
|
+
if matches(network):
|
|
129
|
+
return "unchanged"
|
|
130
|
+
containers = network.get("Containers")
|
|
131
|
+
if not isinstance(containers, dict):
|
|
132
|
+
raise Refusal("network_inspection_invalid")
|
|
133
|
+
# ⛔ Never under a running phase: removing it would cut that phase off mid-session.
|
|
134
|
+
if containers:
|
|
135
|
+
raise Refusal("network_attached")
|
|
136
|
+
if run([docker, "network", "rm", NAME]).returncode != 0:
|
|
137
|
+
raise Refusal("network_remove_failed")
|
|
138
|
+
create(docker)
|
|
139
|
+
return "replaced"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ---------------------------------------------------------------------------------------------
|
|
143
|
+
# The chain.
|
|
144
|
+
|
|
145
|
+
def ruleset():
|
|
146
|
+
"""One atomic transaction: declare the table (a no-op when present), delete it, and add it back
|
|
147
|
+
whole, so a re-run replaces both chains exactly and never duplicates a rule."""
|
|
148
|
+
rules = [f' iifname "{BRIDGE}" ip daddr {addr}/{length} counter drop' for addr, length in IPV4]
|
|
149
|
+
rules += [f' iifname "{BRIDGE}" ip6 daddr {addr}/{length} counter drop' for addr, length in IPV6]
|
|
150
|
+
return (
|
|
151
|
+
f"table inet {TABLE} {{}}\n"
|
|
152
|
+
f"delete table inet {TABLE}\n"
|
|
153
|
+
f"table inet {TABLE} {{\n"
|
|
154
|
+
f" chain {CHAIN} {{\n"
|
|
155
|
+
# Ahead of Docker's own forward rules (priority 0). A drop in any base chain is final.
|
|
156
|
+
" type filter hook forward priority -10; policy accept;\n"
|
|
157
|
+
+ "\n".join(rules)
|
|
158
|
+
+ "\n }\n"
|
|
159
|
+
f" chain {INPUT_CHAIN} {{\n"
|
|
160
|
+
# Nothing on the host is a session's to reach: only replies to connections the host itself
|
|
161
|
+
# opened pass. Public egress is forwarded, and a container's DNS resolver (127.0.0.11) lives
|
|
162
|
+
# in the container's own namespace, so neither needs INPUT.
|
|
163
|
+
" type filter hook input priority -10; policy accept;\n"
|
|
164
|
+
f' iifname "{BRIDGE}" ct state established,related accept\n'
|
|
165
|
+
f' iifname "{BRIDGE}" counter drop\n'
|
|
166
|
+
" }\n}\n"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def apply_chain(nft):
|
|
171
|
+
if run([nft, "-f", "-"], stdin=ruleset()).returncode != 0:
|
|
172
|
+
raise Refusal("chain_apply_failed")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def chain_matches(nft):
|
|
176
|
+
"""Both chains, exactly as `ruleset()` installs them, read back from nft itself. A missing or
|
|
177
|
+
drifted forward OR input chain is a mismatch, so `attest` withdraws the attestation."""
|
|
178
|
+
result = run([nft, "-j", "list", "table", "inet", TABLE])
|
|
179
|
+
if result.returncode != 0:
|
|
180
|
+
return False
|
|
181
|
+
try:
|
|
182
|
+
entries = json.loads(result.stdout)["nftables"]
|
|
183
|
+
chains = {e["chain"]["name"]: e["chain"] for e in entries if "chain" in e}
|
|
184
|
+
rules = {}
|
|
185
|
+
for e in entries:
|
|
186
|
+
if "rule" in e:
|
|
187
|
+
rules.setdefault(e["rule"].get("chain"), []).append(e["rule"])
|
|
188
|
+
except (KeyError, TypeError, AttributeError, json.JSONDecodeError):
|
|
189
|
+
return False
|
|
190
|
+
if set(chains) != {CHAIN, INPUT_CHAIN}:
|
|
191
|
+
return False
|
|
192
|
+
for name, hook in ((CHAIN, "forward"), (INPUT_CHAIN, "input")):
|
|
193
|
+
want = {"family": "inet", "table": TABLE, "type": "filter", "hook": hook, "prio": -10, "policy": "accept"}
|
|
194
|
+
if any(chains[name].get(k) != v for k, v in want.items()):
|
|
195
|
+
return False
|
|
196
|
+
|
|
197
|
+
def match(protocol, right):
|
|
198
|
+
return {"match": {"op": "==", "left": {"payload": {"protocol": protocol, "field": "daddr"}}, "right": right}}
|
|
199
|
+
|
|
200
|
+
interface = {"match": {"op": "==", "left": {"meta": {"key": "iifname"}}, "right": BRIDGE}}
|
|
201
|
+
|
|
202
|
+
# nft JSON serializes a full-width CIDR as a scalar, unlike shorter prefixes.
|
|
203
|
+
def prefix(address, length):
|
|
204
|
+
return address if length in (32, 128) else {"prefix": {"addr": address, "len": length}}
|
|
205
|
+
|
|
206
|
+
forward = [[interface, match("ip", prefix(a, n)), {"counter": None}, {"drop": None}] for a, n in IPV4]
|
|
207
|
+
forward += [[interface, match("ip6", prefix(a, n)), {"counter": None}, {"drop": None}] for a, n in IPV6]
|
|
208
|
+
established = {"match": {"op": "in", "left": {"ct": {"key": "state"}}, "right": ["established", "related"]}}
|
|
209
|
+
local = [[interface, established, {"accept": None}], [interface, {"counter": None}, {"drop": None}]]
|
|
210
|
+
|
|
211
|
+
def canonical(expr):
|
|
212
|
+
return [{"counter": None} if isinstance(item, dict) and set(item) == {"counter"} else item for item in expr]
|
|
213
|
+
|
|
214
|
+
def exactly(found, expected):
|
|
215
|
+
return len(found) == len(expected) and all(
|
|
216
|
+
canonical(rule.get("expr", [])) == want
|
|
217
|
+
and set(rule).issubset({"family", "table", "chain", "handle", "expr", "comment"})
|
|
218
|
+
for rule, want in zip(found, expected)
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return exactly(rules.get(CHAIN, []), forward) and exactly(rules.get(INPUT_CHAIN, []), local)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ---------------------------------------------------------------------------------------------
|
|
225
|
+
# The attestation (read by the supervisor's checkEgressAttestation).
|
|
226
|
+
|
|
227
|
+
def withdraw(path):
|
|
228
|
+
try:
|
|
229
|
+
os.unlink(path)
|
|
230
|
+
except FileNotFoundError:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def attest(docker, nft, path):
|
|
235
|
+
try:
|
|
236
|
+
if not chain_matches(nft):
|
|
237
|
+
raise Refusal("chain_mismatch")
|
|
238
|
+
if not matches(inspect(docker)):
|
|
239
|
+
raise Refusal("network_mismatch")
|
|
240
|
+
except Refusal:
|
|
241
|
+
# A stale proof would keep the host placeable for up to 15 minutes after the policy is gone.
|
|
242
|
+
withdraw(path)
|
|
243
|
+
raise
|
|
244
|
+
directory = os.path.dirname(path)
|
|
245
|
+
os.makedirs(directory, mode=0o755, exist_ok=True)
|
|
246
|
+
body = json.dumps(
|
|
247
|
+
{"version": 1, "enforcement": ENFORCEMENT, "bridge": BRIDGE, "verifiedAtMs": int(time.time() * 1000)}
|
|
248
|
+
)
|
|
249
|
+
handle, temporary = tempfile.mkstemp(prefix=".attestation.", dir=directory)
|
|
250
|
+
try:
|
|
251
|
+
with os.fdopen(handle, "w") as out:
|
|
252
|
+
out.write(body + "\n")
|
|
253
|
+
# The supervisor (uid 10001) reads it through a read-only mount.
|
|
254
|
+
os.chmod(temporary, 0o644)
|
|
255
|
+
os.replace(temporary, path)
|
|
256
|
+
except BaseException:
|
|
257
|
+
withdraw(temporary)
|
|
258
|
+
raise
|
|
259
|
+
return "attested"
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def converge(docker, nft, path):
|
|
263
|
+
try:
|
|
264
|
+
outcome = ensure_network(docker)
|
|
265
|
+
apply_chain(nft)
|
|
266
|
+
except Refusal:
|
|
267
|
+
withdraw(path)
|
|
268
|
+
raise
|
|
269
|
+
attest(docker, nft, path)
|
|
270
|
+
return outcome
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def main(argv):
|
|
274
|
+
if not argv or argv[0] not in ("converge", "attest"):
|
|
275
|
+
raise Refusal("invalid_arguments")
|
|
276
|
+
options = {"docker": "/usr/bin/docker", "nft": "/usr/sbin/nft", "attestation": DEFAULT_ATTESTATION}
|
|
277
|
+
for arg in argv[1:]:
|
|
278
|
+
key, _, value = arg.partition("=")
|
|
279
|
+
if not key.startswith("--") or key[2:] not in options or value == "":
|
|
280
|
+
raise Refusal("invalid_arguments")
|
|
281
|
+
options[key[2:]] = value
|
|
282
|
+
action = converge if argv[0] == "converge" else attest
|
|
283
|
+
return action(options["docker"], options["nft"], options["attestation"])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
try:
|
|
288
|
+
print(main(sys.argv[1:]))
|
|
289
|
+
except Refusal as error:
|
|
290
|
+
print("catalyst_session_egress_" + str(error), file=sys.stderr)
|
|
291
|
+
raise SystemExit(1)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"repository": "coalesce-labs/catalyst-cloud",
|
|
3
|
+
"commit": "3efb201e119fb9ce5a3f636c3212fcbed2d7efef",
|
|
4
|
+
"sha256": {
|
|
5
|
+
"deploy/self-host/compose.yaml": "302f4cd6740572bf1a40386f8ec0d3a1b6919fe8c5e303f232c36969d8f36a53",
|
|
6
|
+
"deploy/self-host/linux-session-egress/session-egress.py": "a20cedfa1e883127ec461e4b7f9588d379fe1633b8244ecd703c81f589d3f142",
|
|
7
|
+
"deploy/self-host/linux-session-egress/catalyst-session-egress.service": "703c150ab23ec87aea47cfc394f63e061b66c4b8c1454a88a9be879c161ce2a5",
|
|
8
|
+
"deploy/self-host/linux-session-egress/catalyst-session-egress-attest.service": "94fa75ea0616f6a1172fc20ca637a5727c36456357e4a977d5bdd65f2fad2e8d",
|
|
9
|
+
"deploy/self-host/linux-session-egress/catalyst-session-egress-attest.timer": "176622c11c835601b95f36e375ba3c2e993136976c67e5d49559f13a3454fef9"
|
|
10
|
+
}
|
|
11
|
+
}
|