@finalai/final-agent-provider 0.3.4 → 0.4.1

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.
@@ -0,0 +1,928 @@
1
+ const workspaceEgressBootstrap = String.raw`import collections, errno, fcntl, hashlib, ipaddress, json, os, pathlib, platform, re, shutil, signal, shlex, socket, stat, struct, subprocess, sys, tempfile, threading, time, urllib.parse, uuid
2
+
3
+ ROOT = pathlib.Path('/run/final-workspace-egress')
4
+ PERSISTENT = pathlib.Path('/var/lib/final-workspace-egress')
5
+ RESOLVER = pathlib.Path('/etc/resolv.conf')
6
+ HOSTS = pathlib.Path('/etc/hosts')
7
+ CHAIN = 'FINAL-EGRESS'
8
+ REPLY = 'FINAL-EGRESS-REPLY'
9
+ FORWARD = 'FINAL-EGRESS-FWD'
10
+ NAT = 'FINAL-EGRESS-NAT'
11
+ MARK = '0x80000/0xff0000'
12
+ os.environ['PATH'] = '/usr/sbin:/usr/bin:/sbin:/bin'
13
+ os.environ.pop('SSL_CERT_FILE', None)
14
+ os.environ.pop('SSL_CERT_DIR', None)
15
+ # Prime autoload before choosing families; later kernel enablement requires reinitialization.
16
+ try: socket.socket(socket.AF_INET6, socket.SOCK_DGRAM).close()
17
+ except OSError as failure:
18
+ if failure.errno not in (errno.EAFNOSUPPORT, errno.EPROTONOSUPPORT, errno.EPERM, errno.EACCES): raise
19
+ FAMILIES = ('iptables', 'ip6tables') if pathlib.Path('/proc/net/if_inet6').exists() else ('iptables',)
20
+
21
+ class WorkspaceConflictError(RuntimeError):
22
+ pass
23
+
24
+ class PrerequisiteError(RuntimeError):
25
+ pass
26
+
27
+ class DenialUnavailableError(RuntimeError):
28
+ pass
29
+
30
+ class EnrollmentLostError(RuntimeError):
31
+ pass
32
+
33
+
34
+ def command(*args, check=True, timeout=25):
35
+ if args[0] in ('iptables', 'ip6tables') and '--version' not in args: args = (args[0], '-w', '5', *args[1:])
36
+ result = subprocess.run(args, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout)
37
+ if check and result.returncode != 0:
38
+ raise RuntimeError('Workspace networking command failed: ' + ' '.join(args[:4]))
39
+ return result
40
+
41
+ def atomic(path, value, binary=False, mode=0o600):
42
+ staged = None
43
+ try:
44
+ with tempfile.NamedTemporaryFile(dir=path.parent, delete=False) as out:
45
+ staged = pathlib.Path(out.name)
46
+ out.write(value if binary else json.dumps(value).encode())
47
+ out.flush(); os.fsync(out.fileno())
48
+ os.chmod(staged, mode)
49
+ os.replace(staged, path)
50
+ directory = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
51
+ try: os.fsync(directory)
52
+ finally: os.close(directory)
53
+ finally:
54
+ if staged is not None: staged.unlink(missing_ok=True)
55
+
56
+ def managed_text(path, content):
57
+ data = content.encode()
58
+ try: atomic(path, data, binary=True, mode=0o644)
59
+ except OSError as failure:
60
+ if failure.errno != errno.EBUSY: raise
61
+ # Docker bind-mounted files cannot be replaced; never follow a raced-in symlink.
62
+ pending = PERSISTENT / 'resolver-write-pending.json' if path == RESOLVER else None
63
+ if pending is not None: atomic(pending, True)
64
+ fd = os.open(path, os.O_WRONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
65
+ with os.fdopen(fd, 'wb') as out:
66
+ if not stat.S_ISREG(os.fstat(out.fileno()).st_mode): raise RuntimeError('Managed network file must be regular')
67
+ os.ftruncate(out.fileno(), 0)
68
+ out.write(data); out.flush(); os.fsync(out.fileno())
69
+ if pending is not None: pending.unlink(missing_ok=True)
70
+
71
+ def load(path, default=None):
72
+ try:
73
+ return json.loads(path.read_text())
74
+ except FileNotFoundError:
75
+ return default
76
+
77
+ def prerequisite(action):
78
+ if sys.version_info < (3, 8):
79
+ raise PrerequisiteError('Device egress requires Python 3.8 or newer')
80
+ if sys.platform != 'linux' or os.geteuid() != 0:
81
+ raise PrerequisiteError('Device egress requires an isolated Linux workspace with privileged networking access')
82
+ ROOT.mkdir(mode=0o700, exist_ok=True)
83
+ if ROOT.is_symlink() or ROOT.stat().st_uid != 0:
84
+ raise RuntimeError('Workspace egress state must be a root-owned directory')
85
+ ROOT.chmod(0o700)
86
+ direct = not load(ROOT / 'config.json') or (load(ROOT / 'config.json', {}).get('mode') == 'direct' and load(ROOT / 'status.json', {}).get('mode') == 'direct')
87
+ if action in ('status', 'artifact', 'claim', 'unlock') or (action == 'disable' and (direct or load(ROOT / 'config.json', {}).get('noNetworkChanges') is True)): return
88
+ if action in ('configure', 'renew') and not pathlib.Path('/dev/net/tun').exists():
89
+ raise PrerequisiteError('This sandbox provider does not expose /dev/net/tun')
90
+ command('ip', '-V')
91
+ for family in FAMILIES:
92
+ command(family, '--version'); command(family + '-restore', '--version')
93
+
94
+ BOOT_ID = pathlib.Path('/proc/sys/kernel/random/boot_id').read_text().strip()
95
+
96
+ def existing_control_replies():
97
+ saved = load(ROOT / 'control-replies.json')
98
+ sockets = []
99
+ for table in ('tcp', 'tcp6'):
100
+ def address(value):
101
+ host, port = value.split(':')
102
+ raw = bytes.fromhex(host)
103
+ raw = b''.join(raw[i:i+4][::-1] for i in range(0, len(raw), 4))
104
+ ip = ipaddress.ip_address(raw)
105
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None: ip = ip.ipv4_mapped
106
+ return str(ip), int(port, 16)
107
+ source = pathlib.Path('/proc/net/' + table)
108
+ if not source.exists(): continue
109
+ for line in source.read_text().splitlines()[1:]:
110
+ columns = line.split()
111
+ if columns[3] in ('01', '0A'): sockets.append((address(columns[1]), address(columns[2]), columns[3], int(columns[7])))
112
+ addresses = json.loads(command('ip', '-j', 'addr', 'show').stdout)
113
+ local_addresses = {ipaddress.ip_address(address['local']) for interface in addresses for address in interface.get('addr_info', [])}
114
+ bridge_networks = [ipaddress.ip_network(network) for _, network in local_bridges()]
115
+ listeners = [local for local, remote, state, uid in sockets if state == '0A' and not ipaddress.ip_address(local[0]).is_loopback]
116
+ replies = []
117
+ for local, remote, state, uid in sockets:
118
+ # Only privileged provider sockets may use the pre-conntrack compatibility exception.
119
+ remote_ip = ipaddress.ip_address(remote[0])
120
+ if uid != 0 or state != '01' or remote_ip.is_loopback or remote_ip in local_addresses: continue
121
+ if any(remote_ip.version == network.version and remote_ip in network for network in bridge_networks): continue
122
+ if any(port == local[1] and (ipaddress.ip_address(host).is_unspecified or host == local[0]) for host, port in listeners):
123
+ replies.append({'source': local[0], 'sourcePort': local[1], 'destination': remote[0], 'destinationPort': remote[1], 'uid': 0})
124
+ if saved is not None and any(reply.get('uid') != 0 for reply in saved): saved = None
125
+ capture_overflow = saved is None and len(replies) > 128
126
+ if saved is not None: replies = saved
127
+ server_ports = [{'source': host, 'sourcePort': port} for host, port in listeners]
128
+ for prior in load(ROOT / 'control-listeners.json', []):
129
+ if any(local[1] == prior['sourcePort'] and state == '01' for local, remote, state, uid in sockets): server_ports.append(prior)
130
+ # A dual-stack listener appears in tcp6 even when the accepted transport uses IPv4.
131
+ server_ports += [{'source': reply['source'], 'sourcePort': reply['sourcePort']} for reply in replies]
132
+ server_ports = [dict(source=host, sourcePort=port) for host, port in sorted({(item['source'], item['sourcePort']) for item in server_ports})]
133
+ prior = load(ROOT / 'control-listeners.json', [])
134
+ privileged = {local for local, remote, state, uid in sockets if state == '0A' and uid == 0}
135
+ server_ports.sort(key=lambda item: (item not in prior, (item['source'], item['sourcePort']) not in privileged))
136
+ atomic(ROOT / 'control-listeners.json', server_ports[:256])
137
+ if len(server_ports) > 256:
138
+ atomic(ROOT / 'listener-limit.json', {'limit': 256, 'observed': len(server_ports)})
139
+ else: (ROOT / 'listener-limit.json').unlink(missing_ok=True)
140
+ if capture_overflow: raise RuntimeError('Too many existing inbound control connections')
141
+ atomic(ROOT / 'control-replies.json', replies)
142
+ return replies
143
+
144
+ def local_bridges():
145
+ result = []
146
+ # Minimal VM kernels can reject a type-filtered dump; ordinary link dumps remain portable.
147
+ links = json.loads(command('ip', '-j', '-d', 'link', 'show').stdout)
148
+ by_name = {link['ifname']: link for link in links}
149
+ uplinks = set()
150
+ for route in json.loads(command('ip', '-j', '-4', 'route', 'show', 'table', 'all').stdout):
151
+ if route.get('dst') == 'default' or route.get('gateway') is not None or route.get('nexthops'):
152
+ uplinks.add(route.get('dev'))
153
+ uplinks.update(hop.get('dev') for hop in route.get('nexthops', []))
154
+ for link in links:
155
+ if link.get('linkinfo', {}).get('info_kind') != 'bridge': continue
156
+ try: members = list((pathlib.Path('/sys/class/net') / link['ifname'] / 'brif').iterdir())
157
+ except FileNotFoundError: continue
158
+ if link['ifname'] in uplinks or any(member.name in uplinks or by_name.get(member.name, {}).get('linkinfo', {}).get('info_kind') != 'veth' for member in members): continue
159
+ name = link['ifname']
160
+ if not re.fullmatch(r'[A-Za-z0-9_.-]{1,15}', name): raise RuntimeError('Unsupported local bridge name')
161
+ for interface in json.loads(command('ip', '-j', '-4', 'addr', 'show', 'dev', name).stdout):
162
+ for address in interface.get('addr_info', []):
163
+ if address.get('family') == 'inet': result.append((name, str(ipaddress.ip_network(address['local'] + '/' + str(address['prefixlen']), strict=False))))
164
+ return sorted(result)
165
+
166
+ def install_guard(allow_tunnel=False, closed=False):
167
+ if closed: allow_tunnel = False
168
+ replies = [reply for reply in load(ROOT / 'control-replies.json', []) if reply.get('uid') == 0] if closed else existing_control_replies()
169
+ bridges = [] if closed else local_bridges()
170
+ try: listeners = load(ROOT / 'control-listeners.json', [])
171
+ except Exception:
172
+ if not closed: raise
173
+ listeners = []
174
+ if closed: tunnel_addresses = load(ROOT / 'tunnel-addresses.json', [])
175
+ else:
176
+ tunnel = command('ip', '-j', 'addr', 'show', 'dev', 'final-egress0', check=False)
177
+ tunnel_addresses = [str(ipaddress.ip_address(address['local'])) for interface in json.loads(tunnel.stdout or b'[]') for address in interface.get('addr_info', [])]
178
+ atomic(ROOT / 'tunnel-addresses.json', tunnel_addresses)
179
+ for family in FAMILIES:
180
+ rules = ['*filter', ':' + CHAIN + ' - [0:0]', '-F ' + CHAIN]
181
+ # Block namespace-local DNS stubs that would otherwise resolve on the provider's network.
182
+ for proto in ('tcp', 'udp'):
183
+ rules.append('-A ' + CHAIN + ' -o lo -p ' + proto + ' --dport 53 -j REJECT')
184
+ if family == 'iptables':
185
+ rules.append('-A ' + CHAIN + ' -d 127.0.0.11 -j REJECT')
186
+ rules.append('-A ' + CHAIN + ' -d 100.100.100.100 -j REJECT')
187
+ rules += ['-A ' + CHAIN + ' -o lo -j ACCEPT', '-A ' + CHAIN + ' -m mark --mark ' + MARK + ' -m owner --uid-owner 0 -j ACCEPT', '-A ' + CHAIN + ' -m mark --mark ' + MARK + ' -m conntrack --ctdir REPLY -j ACCEPT']
188
+ if family == 'ip6tables':
189
+ for kind in (133, 134, 135, 136):
190
+ rules += ['-A ' + CHAIN + ' -p ipv6-icmp --icmpv6-type ' + str(kind) + ' -m owner --uid-owner 1-4294967294 -j REJECT',
191
+ '-A ' + CHAIN + ' -p ipv6-icmp --icmpv6-type ' + str(kind) + ' -j ACCEPT']
192
+ if family == 'iptables':
193
+ for name, network in bridges: rules.append('-A ' + CHAIN + ' -o ' + name + ' -d ' + network + ' -j ACCEPT')
194
+ if family == 'iptables' and allow_tunnel:
195
+ rules.append('-A ' + CHAIN + ' -o final-egress0 -j ACCEPT')
196
+ if closed:
197
+ for reply in replies:
198
+ if (':' in reply['source']) != (family == 'ip6tables'): continue
199
+ rules.append('-A ' + CHAIN + ' -p tcp -s ' + reply['source'] + ' --sport ' + str(reply['sourcePort']) + ' -d ' + reply['destination'] + ' --dport ' + str(reply['destinationPort']) + ' --tcp-flags SYN,RST,ACK ACK -j ACCEPT')
200
+ rules += ['-A ' + CHAIN + ' -j REJECT', 'COMMIT', '*mangle', ':' + REPLY + ' - [0:0]', '-F ' + REPLY,
201
+ # Docker rewrites its DNS stub's port in nat/OUTPUT; block before that translation.
202
+ '-A ' + REPLY + ' -o lo -p tcp --dport 53 -j DROP',
203
+ '-A ' + REPLY + ' -o lo -p udp --dport 53 -j DROP',
204
+ ]
205
+ if family == 'iptables': rules.append('-A ' + REPLY + ' -d 127.0.0.11 -j DROP')
206
+ # mangle/OUTPUT sees the initial exit route even for physical provider replies.
207
+ # Exclude only the actual tunnel source addresses before marking server replies.
208
+ for address in tunnel_addresses:
209
+ if (':' in address) == (family == 'ip6tables'):
210
+ rules.append('-A ' + REPLY + ' -s ' + address + ' -j RETURN')
211
+ # Minimal provider kernels omit connmark. Reserve the existing server ports for TCP replies;
212
+ # an untracked outgoing client's ephemeral port cannot obtain this exception.
213
+ for listener in listeners:
214
+ host = listener['source']
215
+ address = ipaddress.ip_address(host)
216
+ if (':' in host) != (family == 'ip6tables') and not (family == 'iptables' and address.version == 6 and address.is_unspecified): continue
217
+ source = '' if ipaddress.ip_address(host).is_unspecified else ' -s ' + host
218
+ rules.append('-A ' + REPLY + ' -p tcp' + source + ' --sport ' + str(listener['sourcePort']) +
219
+ ' -m conntrack --ctdir REPLY --ctorigdstport ' + str(listener['sourcePort']) + ' -j MARK --set-xmark ' + MARK)
220
+ # Conntrack can load after a provider's long-lived inbound RPC already opened (E2B envd).
221
+ # Preserve only ACKs on those accepted server sockets, never a new outbound SYN.
222
+ for reply in replies:
223
+ if (':' in reply['source']) != (family == 'ip6tables'): continue
224
+ rules.append('-A ' + REPLY + ' -p tcp -s ' + reply['source'] + ' --sport ' + str(reply['sourcePort']) +
225
+ ' -d ' + reply['destination'] + ' --dport ' + str(reply['destinationPort']) +
226
+ ' --tcp-flags SYN,RST,ACK ACK -j MARK --set-xmark ' + MARK)
227
+ rules.append('COMMIT')
228
+ # Docker can prepend filter/FORWARD accepts; mangle runs before that table.
229
+ rules += ['*mangle', ':' + FORWARD + ' - [0:0]', '-F ' + FORWARD]
230
+ if family == 'iptables': rules.append('-A ' + FORWARD + ' -d 100.100.100.100 -j DROP')
231
+ if family == 'iptables':
232
+ for name, network in bridges:
233
+ for ingress, _ in bridges: rules.append('-A ' + FORWARD + ' -i ' + ingress + ' -o ' + name + ' -d ' + network + ' -j RETURN')
234
+ if family == 'iptables' and allow_tunnel:
235
+ for name, network in bridges:
236
+ rules += ['-A ' + FORWARD + ' -i ' + name + ' -s ' + network + ' -o final-egress0 -j RETURN',
237
+ '-A ' + FORWARD + ' -i final-egress0 -o ' + name + ' -d ' + network + ' -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN']
238
+ rules += ['-A ' + FORWARD + ' -j DROP', 'COMMIT']
239
+ rules += ['*filter', ':' + FORWARD + ' - [0:0]', '-F ' + FORWARD]
240
+ if family == 'iptables': rules.append('-A ' + FORWARD + ' -d 100.100.100.100 -j DROP')
241
+ if family == 'iptables':
242
+ for name, network in bridges:
243
+ for ingress, _ in bridges: rules.append('-A ' + FORWARD + ' -i ' + ingress + ' -o ' + name + ' -d ' + network + ' -j RETURN')
244
+ if family == 'iptables' and allow_tunnel:
245
+ for name, network in bridges:
246
+ rules += ['-A ' + FORWARD + ' -i ' + name + ' -s ' + network + ' -o final-egress0 -j ACCEPT',
247
+ '-A ' + FORWARD + ' -i final-egress0 -o ' + name + ' -d ' + network + ' -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT']
248
+ rules += ['-A ' + FORWARD + ' -j REJECT', 'COMMIT']
249
+ if family == 'iptables':
250
+ rules += ['*nat', ':' + NAT + ' - [0:0]', '-F ' + NAT]
251
+ for name, network in bridges:
252
+ rules.append('-A ' + NAT + ' -s ' + network + ' -o final-egress0 -j MASQUERADE')
253
+ rules.append('COMMIT')
254
+ installed = subprocess.run([family + '-restore', '-w', '5', '--noflush'], timeout=25, input=('\n'.join(rules) + '\n').encode(), stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
255
+ if installed.returncode: raise RuntimeError(family + ' firewall installation failed: ' + installed.stderr.decode(errors='replace')[:800])
256
+ hooks = [('mangle', 'OUTPUT', REPLY), ('mangle', 'FORWARD', FORWARD), ('filter', 'OUTPUT', CHAIN), ('filter', 'FORWARD', FORWARD)]
257
+ if family == 'iptables': hooks.append(('nat', 'POSTROUTING', NAT))
258
+ for table, hook, chain in hooks:
259
+ if command(family, '-t', table, '-C', hook, '-j', chain, check=False).returncode != 0:
260
+ command(family, '-t', table, '-I', hook, '1', '-j', chain)
261
+ # Reverse-NAT replies from TUN must return to a nested bridge rather than the exit default route.
262
+ if allow_tunnel:
263
+ while command('ip', '-4', 'rule', 'del', 'pref', '5210', 'iif', 'final-egress0', 'lookup', 'main', 'protocol', '242', check=False).returncode == 0: pass
264
+ existing = command('ip', '-4', 'rule', 'show').stdout.decode()
265
+ for name, network in bridges:
266
+ if not any('to ' + value + ' iif final-egress0 lookup main' in existing for value in (network, network[:-3] if network.endswith('/32') else network)):
267
+ command('ip', '-4', 'rule', 'add', 'pref', '5210', 'iif', 'final-egress0', 'to', network, 'lookup', 'main', 'protocol', '242')
268
+ existing_rules = command('ip', '-4', 'rule', 'show').stdout.decode() if bridges else ''
269
+ for name, network in bridges:
270
+ if not any('to ' + value + ' lookup main' in existing_rules for value in (network, network[:-3] if network.endswith('/32') else network)):
271
+ command('ip', '-4', 'rule', 'add', 'pref', '5210', 'to', network, 'lookup', 'main', 'protocol', '242')
272
+
273
+ def chain_hooked(family, table, hook, chain):
274
+ result = command(family, '-t', table, '-S', check=False)
275
+ if result.returncode != 0:
276
+ if table != 'filter' and result.returncode == 3 and b'Table does not exist' in result.stderr: return False
277
+ raise RuntimeError('Cannot inspect namespace firewall hooks')
278
+ return ('-A ' + hook + ' -j ' + chain) in result.stdout.decode().splitlines()
279
+
280
+ def denial_present():
281
+ for family in FAMILIES:
282
+ for hook, chain in [('OUTPUT', CHAIN), ('FORWARD', FORWARD)]:
283
+ if not chain_hooked(family, 'filter', hook, chain): return False
284
+ rules = command(family, '-t', 'filter', '-S', chain, check=False)
285
+ if rules.returncode or not any(shlex.split(line)[:4] in (['-A', chain, '-j', 'DROP'], ['-A', chain, '-j', 'REJECT']) for line in rules.stdout.decode().splitlines()): return False
286
+ return True
287
+
288
+ def restoration_pending():
289
+ if load(ROOT / 'config.json', {}).get('mode') == 'error' or load(ROOT / 'status.json', {}).get('mode') == 'error': return True
290
+ if (PERSISTENT / 'hosts.original').exists() or (PERSISTENT / 'resolver.original').exists() or (not HOSTS.is_symlink() and HOSTS.exists() and '# final-workspace-egress' in HOSTS.read_text()): return True
291
+ if RESOLVER.exists() and RESOLVER.read_bytes().startswith(b'# Final workspace egress\n'): return True
292
+ if not all(shutil.which(family) for family in FAMILIES):
293
+ return (PERSISTENT / 'identity.json').exists() or load(ROOT / 'config.json', {}).get('mode', 'direct') != 'direct'
294
+ try:
295
+ return any(chain_hooked(family, table, hook, chain)
296
+ for family in FAMILIES
297
+ for table, hook, chain in [('filter', 'OUTPUT', CHAIN), ('filter', 'FORWARD', FORWARD), ('mangle', 'OUTPUT', REPLY), ('mangle', 'FORWARD', FORWARD)] + ([('nat', 'POSTROUTING', NAT)] if family == 'iptables' else []))
298
+ except RuntimeError:
299
+ # Never-managed images need no repair when their provider denies firewall inspection.
300
+ if not (PERSISTENT / 'identity.json').exists() and load(ROOT / 'config.json', {}).get('mode', 'direct') == 'direct': return False
301
+ raise
302
+
303
+ def restore():
304
+ transport_hosts({})
305
+ original = PERSISTENT / 'resolver.original'
306
+ if not original.exists() and (load(PERSISTENT / 'resolver-write-pending.json', False) or (RESOLVER.exists() and RESOLVER.read_bytes().startswith(b'# Final workspace egress\n'))):
307
+ raise RuntimeError('Original workspace resolver backup is missing; repair the provider resolver before Disable')
308
+ if original.exists():
309
+ current = RESOLVER
310
+ if load(PERSISTENT / 'resolver-write-pending.json', False) or not current.exists() or current.read_text().startswith('# Final workspace egress\n'):
311
+ link = load(PERSISTENT / 'resolver-link.json')
312
+ if link is None:
313
+ if load(PERSISTENT / 'resolver-absent.json', False): current.unlink(missing_ok=True)
314
+ else:
315
+ if current.is_symlink(): current.unlink()
316
+ managed_text(current, original.read_text())
317
+ else:
318
+ staged = current.parent / ('.final-egress-resolver-' + uuid.uuid4().hex)
319
+ try:
320
+ os.symlink(link['target'], staged); os.replace(staged, current)
321
+ finally: staged.unlink(missing_ok=True)
322
+ (PERSISTENT / 'resolver-write-pending.json').unlink(missing_ok=True)
323
+ original.unlink()
324
+ (PERSISTENT / 'resolver-link.json').unlink(missing_ok=True)
325
+ (PERSISTENT / 'resolver-absent.json').unlink(missing_ok=True)
326
+ for family in FAMILIES:
327
+ hooks = [('filter', 'OUTPUT', CHAIN), ('mangle', 'OUTPUT', REPLY), ('mangle', 'FORWARD', FORWARD), ('filter', 'FORWARD', FORWARD)]
328
+ if family == 'iptables': hooks.append(('nat', 'POSTROUTING', NAT))
329
+ for table, hook, chain in hooks:
330
+ while chain_hooked(family, table, hook, chain):
331
+ command(family, '-t', table, '-D', hook, '-j', chain)
332
+ command(family, '-t', table, '-F', chain, check=False)
333
+ command(family, '-t', table, '-X', chain, check=False)
334
+ while command('ip', '-4', 'rule', 'del', 'pref', '5210', 'protocol', '242', 'lookup', 'main', check=False).returncode == 0: pass
335
+ for saved in ('control-replies.json', 'control-listeners.json', 'listener-limit.json', 'tunnel-addresses.json'):
336
+ (ROOT / saved).unlink(missing_ok=True)
337
+ (PERSISTENT / 'identity.json').unlink(missing_ok=True)
338
+ (PERSISTENT / 'intent.json').unlink(missing_ok=True)
339
+
340
+ def validated_resolvers(addresses):
341
+ if not isinstance(addresses, list) or not addresses or len(addresses) > 3:
342
+ raise RuntimeError('One to three IPv4 DNS resolvers are required')
343
+ addresses = [str(ipaddress.IPv4Address(address)) for address in addresses]
344
+ if len(set(addresses)) != len(addresses) or any(int(address.split('.')[0]) in (0, 127) or int(address.split('.')[0]) >= 224 or address == '100.100.100.100' for address in addresses):
345
+ raise RuntimeError('Device DNS resolvers must be unique reachable unicast addresses')
346
+ return addresses
347
+
348
+ def resolver(addresses):
349
+ addresses = validated_resolvers(addresses)
350
+ source = RESOLVER
351
+ if not (PERSISTENT / 'resolver.original').exists():
352
+ if source.exists() and source.read_bytes().startswith(b'# Final workspace egress\n'):
353
+ raise RuntimeError('Original workspace resolver backup is missing; repair the provider resolver')
354
+ absent = not source.exists() and not source.is_symlink()
355
+ try: original = source.read_bytes()
356
+ except FileNotFoundError: original = b''
357
+ atomic(PERSISTENT / 'resolver-absent.json', absent)
358
+ if source.is_symlink(): atomic(PERSISTENT / 'resolver-link.json', {'target': os.readlink(source)})
359
+ else: (PERSISTENT / 'resolver-link.json').unlink(missing_ok=True)
360
+ atomic(PERSISTENT / 'resolver.original', original, binary=True)
361
+ content = '# Final workspace egress\n' + ''.join('nameserver ' + address + '\n' for address in addresses) + 'options timeout:2 attempts:2\n'
362
+ if not source.is_symlink() and source.exists() and source.read_bytes() == content.encode(): return
363
+ managed_text(source, content)
364
+
365
+ def transport_hosts(hosts):
366
+ original = PERSISTENT / 'hosts.original'
367
+ absent = PERSISTENT / 'hosts-absent.json'
368
+ if HOSTS.is_symlink():
369
+ if hosts: raise PrerequisiteError('Workspace egress requires a regular /etc/hosts file')
370
+ if original.exists():
371
+ if load(absent, False): HOSTS.unlink()
372
+ else: atomic(HOSTS, original.read_bytes(), binary=True, mode=0o644)
373
+ original.unlink(); absent.unlink(missing_ok=True)
374
+ return
375
+ if hosts and not original.exists():
376
+ atomic(absent, not HOSTS.exists())
377
+ atomic(original, HOSTS.read_bytes() if HOSTS.exists() else b'', binary=True)
378
+ if not HOSTS.exists() and not hosts:
379
+ if original.exists() and not load(absent, False): atomic(HOSTS, original.read_bytes(), binary=True, mode=0o644)
380
+ original.unlink(missing_ok=True); absent.unlink(missing_ok=True)
381
+ return
382
+ lines = HOSTS.read_text().splitlines() if HOSTS.exists() else []
383
+ lines = [line for line in lines if not line.endswith('# final-workspace-egress')]
384
+ for host, address in hosts.items():
385
+ if not host or any(c not in 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-' for c in host):
386
+ raise RuntimeError('Invalid egress transport hostname')
387
+ lines.append(str(ipaddress.IPv4Address(address)) + ' ' + host + ' # final-workspace-egress')
388
+ content = '\n'.join(lines) + '\n'
389
+ if not hosts and load(absent, False) and not lines: HOSTS.unlink(missing_ok=True)
390
+ elif not HOSTS.exists() or HOSTS.read_text() != content: managed_text(HOSTS, content)
391
+ if not hosts:
392
+ original.unlink(missing_ok=True); absent.unlink(missing_ok=True)
393
+
394
+ def status(mode, config, **extra):
395
+ try: policy_routing = command('ip', '-4', 'rule', 'show', check=False).returncode == 0
396
+ except FileNotFoundError: policy_routing = False
397
+ result = {'mode': mode, 'leaseId': config.get('leaseId'), 'ownerUserId': config.get('ownerUserId'),
398
+ 'workspaceId': config.get('workspaceId'), 'architecture': platform.machine(), 'policyRouting': policy_routing, 'expiresAtMs': config.get('expiresAtMs'), 'deviceId': None, 'path': None, **extra}
399
+ atomic(ROOT / 'status.json', result)
400
+ return result
401
+
402
+ def controller_status(mode, config, **extra):
403
+ # A writer may await daemon shutdown while holding this lock; skip stale/busy publication.
404
+ with open(ROOT / 'request.lock', 'a') as requests:
405
+ try: fcntl.flock(requests, fcntl.LOCK_EX | fcntl.LOCK_NB)
406
+ except BlockingIOError: return
407
+ saved = load(ROOT / 'config.json', {})
408
+ fields = ('mode', 'revision', 'leaseId', 'workspaceId', 'ownerUserId', 'expiresAtMs', 'state')
409
+ if any(saved.get(field) != config.get(field) for field in fields): return
410
+ return status(mode, config, **extra)
411
+
412
+ def lease_id(value):
413
+ if not isinstance(value, str) or not value: raise RuntimeError('Missing workspace lease identifier')
414
+ return value
415
+
416
+ def lease_state_name(value):
417
+ value = lease_id(value)
418
+ try: return str(uuid.UUID(value))
419
+ except ValueError: return 'opaque-' + hashlib.sha256(value.encode()).hexdigest()
420
+
421
+ def helper_environment():
422
+ env = {'PATH': '/usr/sbin:/usr/bin:/sbin:/bin'}
423
+ env.update(FINAL_TAILSCALE_MANAGED='1', FINAL_DEVICE_PARENT_PID=str(os.getpid()), TS_NO_LOGS_NO_SUPPORT='true', TS_DEBUG_ENV_FILE='/dev/null', TS_LOGS_DIR=str(ROOT / 'logs'))
424
+ return env
425
+
426
+ def cli(helper, sock, *args):
427
+ # CLI operations are managed children as well: closing their pipe stops them on controller death.
428
+ env = helper_environment()
429
+ errors = os.open(ROOT / 'last-control-error.log', os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600)
430
+ try:
431
+ proc = subprocess.Popen([str(helper), 'final-cli', '--socket=' + str(sock), *args], stdin=subprocess.PIPE,
432
+ stdout=subprocess.PIPE, stderr=errors, env=env)
433
+ finally: os.close(errors)
434
+ try:
435
+ proc.stdin.write(b'final-device-start\n'); proc.stdin.flush()
436
+ deadline = time.monotonic() + 20
437
+ output = bytearray()
438
+ os.set_blocking(proc.stdout.fileno(), False)
439
+ while proc.poll() is None:
440
+ chunk = proc.stdout.read(65536)
441
+ if chunk: output.extend(chunk)
442
+ if len(output) > 1024 * 1024 or time.monotonic() > deadline:
443
+ raise RuntimeError('Workspace egress control operation exceeded its limit: ' + args[0])
444
+ time.sleep(.01)
445
+ output.extend(proc.stdout.read() or b'')
446
+ if proc.returncode != 0:
447
+ detail = ': ' + bytes(output).decode(errors='replace')[:1000] if args[0] == 'ping' else ''
448
+ raise RuntimeError('Workspace egress control operation failed (' + args[0] + ')' + detail)
449
+ return bytes(output)
450
+ finally:
451
+ try: proc.stdin.close()
452
+ except BrokenPipeError: pass
453
+ if proc.poll() is None: proc.kill()
454
+ proc.wait()
455
+
456
+ def stop(proc):
457
+ if proc is not None:
458
+ try: proc.stdin.close()
459
+ except BrokenPipeError: pass
460
+ try: proc.wait(timeout=2)
461
+ except subprocess.TimeoutExpired: proc.kill(); proc.wait()
462
+
463
+ def minimal_guard():
464
+ failures = []
465
+ # Cached accepted sockets permit recovery even without conntrack or mangle.
466
+ try: replies = [reply for reply in load(ROOT / 'control-replies.json', []) if reply.get('uid') == 0]
467
+ except Exception: replies = []
468
+ for family in FAMILIES:
469
+ try:
470
+ for hook, chain in [('OUTPUT', CHAIN), ('FORWARD', FORWARD)]:
471
+ existing = command(family, '-t', 'filter', '-S', chain, check=False)
472
+ hooked = command(family, '-t', 'filter', '-C', hook, '-j', chain, check=False)
473
+ # Retain a successfully installed closed family and its control exceptions.
474
+ if existing.returncode == 0 and hooked.returncode == 0 and b'-o final-egress0' not in existing.stdout and any(shlex.split(line)[:4] in (['-A', chain, '-j', 'DROP'], ['-A', chain, '-j', 'REJECT']) for line in existing.stdout.decode().splitlines()): continue
475
+ rules = ['*filter', ':' + chain + ' - [0:0]', '-F ' + chain]
476
+ if hook == 'OUTPUT':
477
+ for proto in ('tcp', 'udp'):
478
+ rules.append('-A ' + chain + ' -o lo -p ' + proto + ' --dport 53 -j DROP')
479
+ if family == 'iptables':
480
+ rules += ['-A ' + chain + ' -d 127.0.0.11 -j DROP', '-A ' + chain + ' -d 100.100.100.100 -j DROP']
481
+ rules.append('-A ' + chain + ' -o lo -j ACCEPT')
482
+ if family == 'ip6tables':
483
+ for kind in (133, 134, 135, 136):
484
+ rules += ['-A ' + chain + ' -p ipv6-icmp --icmpv6-type ' + str(kind) + ' -m owner --uid-owner 1-4294967294 -j REJECT',
485
+ '-A ' + chain + ' -p ipv6-icmp --icmpv6-type ' + str(kind) + ' -j ACCEPT']
486
+ for reply in replies:
487
+ if (':' in reply['source']) != (family == 'ip6tables'): continue
488
+ rules.append('-A ' + chain + ' -p tcp -s ' + reply['source'] + ' --sport ' + str(reply['sourcePort']) +
489
+ ' -d ' + reply['destination'] + ' --dport ' + str(reply['destinationPort']) +
490
+ ' --tcp-flags SYN,RST,ACK ACK -j ACCEPT')
491
+ rules += ['-A ' + chain + ' -j DROP', 'COMMIT']
492
+ result = subprocess.run([family + '-restore', '-w', '5', '--noflush'], input=('\n'.join(rules) + '\n').encode(), capture_output=True, timeout=25)
493
+ if result.returncode and family == 'ip6tables':
494
+ # Without owner matching, deny IPv6 neighbor traffic too rather than leaving the family open.
495
+ fallback = [rule for rule in rules if '-p ipv6-icmp ' not in rule]
496
+ result = subprocess.run([family + '-restore', '-w', '5', '--noflush'], input=('\n'.join(fallback) + '\n').encode(), capture_output=True, timeout=25)
497
+ if result.returncode: raise RuntimeError('Atomic emergency denial failed for ' + family)
498
+ if hooked.returncode != 0: command(family, '-t', 'filter', '-I', hook, '1', '-j', chain)
499
+ except Exception as cause: failures.append(str(cause))
500
+ if failures: raise RuntimeError('Cannot install minimal namespace denial: ' + '; '.join(failures))
501
+
502
+ def guard(allow_tunnel=False):
503
+ try: install_guard(allow_tunnel)
504
+ except Exception:
505
+ try: install_guard(closed=True)
506
+ except Exception:
507
+ try: minimal_guard()
508
+ except Exception as failure:
509
+ raise DenialUnavailableError('Workspace network denial could not be installed') from failure
510
+ raise
511
+
512
+ def clear_identity():
513
+ for path in ROOT.glob('state-*'):
514
+ if path.is_file(): path.unlink()
515
+ (ROOT / 'enrollment.key').unlink(missing_ok=True)
516
+
517
+ def stop_controller():
518
+ with open(ROOT / 'daemon.lock', 'a') as lock:
519
+ deadline = time.monotonic() + 5
520
+ signaled = None
521
+ while True:
522
+ pid = load(ROOT / 'controller.pid')
523
+ if isinstance(pid, int) and pid != signaled:
524
+ try:
525
+ args = pathlib.Path('/proc/' + str(pid) + '/cmdline').read_bytes().split(bytes([0]))
526
+ if str(ROOT / 'controller.py').encode() in args and b'run' in args:
527
+ os.kill(pid, signal.SIGTERM); signaled = pid
528
+ except ProcessLookupError: pass
529
+ except FileNotFoundError: pass
530
+ try:
531
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
532
+ clear_identity()
533
+ return
534
+ except BlockingIOError:
535
+ if time.monotonic() >= deadline: raise RuntimeError('Workspace egress controller did not stop')
536
+ time.sleep(.05)
537
+
538
+ def capture_errors(proc):
539
+ tail = collections.deque(maxlen=128)
540
+ while True:
541
+ chunk = proc.stderr.read1(4096)
542
+ if not chunk: return
543
+ tail.append(chunk)
544
+ atomic(ROOT / 'daemon.log', b''.join(tail), binary=True)
545
+
546
+ def select_exit(config, peers, selected, health_misses, helper, sock):
547
+ candidates = sorted(config.get('devices', []), key=lambda item: item.get('tailscaleIp') != selected)
548
+ for item in candidates:
549
+ ip = item.get('tailscaleIp')
550
+ if ip not in peers or not peers[ip].get('Online') or peers[ip].get('Expired'): continue
551
+ try:
552
+ # TSMP tests the encrypted peer path without opening a destination connection.
553
+ cli(helper, sock, 'ping', '--tsmp', '--c=1', '--timeout=2s', str(ipaddress.IPv4Address(ip)))
554
+ health_misses[ip] = 0
555
+ return item
556
+ except (RuntimeError, ValueError) as cause:
557
+ atomic(ROOT / 'last-peer-error.json', {'deviceId': item['deviceId'], 'error': str(cause), 'atMs': int(time.time() * 1000)})
558
+ health_misses[ip] = health_misses.get(ip, 0) + 1
559
+ if ip == selected and health_misses[ip] < 3: break
560
+ continue
561
+ return None
562
+
563
+ def run():
564
+ with open(ROOT / 'daemon.lock', 'a') as lock:
565
+ deadline = time.monotonic() + 5
566
+ while True:
567
+ try:
568
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
569
+ break
570
+ except BlockingIOError:
571
+ pid = load(ROOT / 'controller.pid')
572
+ alive = False
573
+ try:
574
+ alive = isinstance(pid, int) and str(ROOT / 'controller.py').encode() in pathlib.Path('/proc/' + str(pid) + '/cmdline').read_bytes().split(bytes([0]))
575
+ except FileNotFoundError: pass
576
+ if time.monotonic() >= deadline:
577
+ if alive: return
578
+ raise RuntimeError('Workspace controller startup lock is busy')
579
+ time.sleep(.01)
580
+ atomic(ROOT / 'controller.pid', os.getpid())
581
+ proc = None
582
+ current = None
583
+ selected = None
584
+ health_misses = {}
585
+ bridges = None
586
+ revision = None
587
+ def terminate(signum, frame):
588
+ raise SystemExit(0)
589
+ signal.signal(signal.SIGTERM, terminate)
590
+ signal.signal(signal.SIGINT, terminate)
591
+ try:
592
+ while True:
593
+ config = load(ROOT / 'config.json', {})
594
+ mode = config.get('mode', 'blocked')
595
+ if mode != 'device' or config.get('bootId') != BOOT_ID or config.get('expiresAtMs', 0) <= time.time() * 1000 or config.get('deadlineMonotonic', 0) <= time.monotonic():
596
+ # Coordinate relinquishing the daemon lock with writers so a renewal cannot lose its controller.
597
+ with open(ROOT / 'request.lock', 'a') as requests:
598
+ fcntl.flock(requests, fcntl.LOCK_EX)
599
+ if load(ROOT / 'config.json', {}) != config: continue
600
+ stop(proc); proc = None; current = None; selected = None
601
+ clear_identity()
602
+ if mode == 'direct':
603
+ restore(); status('direct', config)
604
+ else:
605
+ guard(); status('blocked', config)
606
+ fcntl.flock(lock, fcntl.LOCK_UN)
607
+ return
608
+ helper = ROOT / 'helper'
609
+ sock = ROOT / 'control.sock'
610
+ revision = config.get('revision', config['leaseId'])
611
+ if revision != current or proc is None or proc.poll() is not None:
612
+ stop(proc); proc = None
613
+ with open(ROOT / 'request.lock', 'a') as requests:
614
+ fcntl.flock(requests, fcntl.LOCK_EX)
615
+ if load(ROOT / 'config.json', {}).get('revision', load(ROOT / 'config.json', {}).get('leaseId')) != revision: continue
616
+ guard(); transport_hosts(config['transportHosts'])
617
+ sock.unlink(missing_ok=True)
618
+ state = ROOT / ('state-' + lease_state_name(config['leaseId']))
619
+ for old in ROOT.glob('state-*'):
620
+ if old != state and old.is_file(): old.unlink()
621
+ env = helper_environment()
622
+ proc = subprocess.Popen([str(helper), '--tun=final-egress0', '--port=0', '--socket=' + str(sock), '--state=' + str(state)],
623
+ stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
624
+ threading.Thread(target=capture_errors, args=(proc,), daemon=True).start()
625
+ proc.stdin.write(b'final-device-start\n'); proc.stdin.flush()
626
+ deadline = time.monotonic() + 5
627
+ while not sock.exists():
628
+ if proc.poll() is not None or time.monotonic() > deadline:
629
+ raise RuntimeError('Workspace egress daemon did not become ready')
630
+ time.sleep(.05)
631
+ args = ['up', '--reset', '--login-server=' + config['controlUrl'], '--hostname=workspace-' + lease_state_name(config['leaseId'])[:40],
632
+ '--accept-dns=false', '--accept-routes=false', '--shields-up', '--netfilter-mode=off']
633
+ if config.get('authKey'):
634
+ atomic(ROOT / 'enrollment.key', config['authKey'].encode(), binary=True)
635
+ args.append('--auth-key=file:' + str(ROOT / 'enrollment.key'))
636
+ args.append('--force-reauth')
637
+ try: cli(helper, sock, *args)
638
+ finally:
639
+ (ROOT / 'enrollment.key').unlink(missing_ok=True)
640
+ with open(ROOT / 'request.lock', 'a') as requests:
641
+ # A writer may hold request.lock while awaiting our daemon.lock.
642
+ try: fcntl.flock(requests, fcntl.LOCK_EX | fcntl.LOCK_NB)
643
+ except BlockingIOError: pass
644
+ else:
645
+ pending = load(ROOT / 'config.json', {})
646
+ if pending.get('revision', pending.get('leaseId')) == revision:
647
+ pending.pop('authKey', None); atomic(ROOT / 'config.json', pending)
648
+ with open(ROOT / 'request.lock', 'a') as requests:
649
+ fcntl.flock(requests, fcntl.LOCK_EX)
650
+ saved = load(ROOT / 'config.json', {})
651
+ if saved.get('mode') != 'device' or saved.get('leaseId') != config.get('leaseId') or saved.get('revision', saved.get('leaseId')) != revision:
652
+ stop(proc); proc = None; current = None; selected = None
653
+ continue
654
+ resolver(saved.get('dnsResolvers', ['1.1.1.1', '9.9.9.9']))
655
+ saved.pop('authKey', None); atomic(ROOT / 'config.json', saved)
656
+ current = revision; selected = None
657
+ guard(allow_tunnel=True)
658
+ if config.get('state') == 'provisioning':
659
+ controller_status('enrolled', config)
660
+ time.sleep(.2)
661
+ continue
662
+ actual_bridges = local_bridges()
663
+ with open(ROOT / 'request.lock', 'a') as requests:
664
+ fcntl.flock(requests, fcntl.LOCK_EX)
665
+ latest = load(ROOT / 'config.json', {})
666
+ if latest.get('mode') != 'device' or latest.get('revision', latest.get('leaseId')) != revision: continue
667
+ resolver(latest.get('dnsResolvers', ['1.1.1.1', '9.9.9.9']))
668
+ previous_listeners = load(ROOT / 'control-listeners.json', [])
669
+ existing_control_replies()
670
+ actual_tunnel = command('ip', '-j', 'addr', 'show', 'dev', 'final-egress0', check=False)
671
+ actual_addresses = [str(ipaddress.ip_address(address['local'])) for interface in json.loads(actual_tunnel.stdout or b'[]') for address in interface.get('addr_info', [])]
672
+ if bridges != actual_bridges or previous_listeners != load(ROOT / 'control-listeners.json', []) or actual_addresses != load(ROOT / 'tunnel-addresses.json', []):
673
+ guard(allow_tunnel=True); bridges = actual_bridges
674
+ try:
675
+ raw = json.loads(cli(helper, sock, 'status', '--json'))
676
+ peers = {ip: peer for peer in (raw.get('Peer') or {}).values() for ip in (peer.get('TailscaleIPs') or [])}
677
+ candidates = config.get('devices', [])
678
+ candidate = select_exit(config, peers, selected, health_misses, helper, sock)
679
+ if candidate is None:
680
+ last = next((item for item in candidates if item.get('tailscaleIp') == selected), None)
681
+ # A health miss must not tear down an authorized route during control outage/rekey.
682
+ # Keeping it can only use the approved exit; the namespace guard still forbids direct fallback.
683
+ if last is not None and selected in peers and not peers[selected].get('Expired'):
684
+ peer = peers[selected]
685
+ controller_status('reconnecting', config, deviceId=last['deviceId'], path='direct' if peer.get('CurAddr') else 'relay')
686
+ else:
687
+ if selected is not None: cli(helper, sock, 'set', '--exit-node=')
688
+ selected = None
689
+ controller_status('blocked', config)
690
+ else:
691
+ ip = str(ipaddress.IPv4Address(candidate['tailscaleIp']))
692
+ if selected != ip:
693
+ cli(helper, sock, 'set', '--exit-node=' + ip, '--exit-node-allow-lan-access=false')
694
+ selected = ip
695
+ route = command('ip', '-4', 'route', 'get', '192.0.2.254').stdout
696
+ if b'dev final-egress0' not in route:
697
+ controller_status('configuring', config)
698
+ time.sleep(.1)
699
+ continue
700
+ peer = peers[ip]
701
+ controller_status('device', config, deviceId=candidate['deviceId'], path='direct' if peer.get('CurAddr') else 'relay')
702
+ time.sleep(1)
703
+ except (RuntimeError, subprocess.TimeoutExpired, json.JSONDecodeError) as failure:
704
+ atomic(ROOT / 'last-peer-error.json', {'error': str(failure)[:1000], 'atMs': int(time.time() * 1000)})
705
+ controller_status('reconnecting', config)
706
+ time.sleep(1)
707
+ except Exception as cause:
708
+ atomic(ROOT / 'last-error.json', {'error': str(cause), 'atMs': int(time.time() * 1000)})
709
+ controller_status('error', load(ROOT / 'config.json', {}), error='Workspace egress stopped; inspect the controller error')
710
+ raise
711
+ finally:
712
+ stop(proc)
713
+ if load(ROOT / 'controller.pid') == os.getpid(): (ROOT / 'controller.pid').unlink(missing_ok=True)
714
+ # Writers can await daemon.lock while holding request.lock; finish secret cleanup after releasing it.
715
+ fcntl.flock(lock, fcntl.LOCK_UN)
716
+ with open(ROOT / 'request.lock', 'a') as requests:
717
+ fcntl.flock(requests, fcntl.LOCK_EX)
718
+ pending = load(ROOT / 'config.json', {})
719
+ if revision is not None and pending.get('revision', pending.get('leaseId')) == revision and 'authKey' in pending:
720
+ pending.pop('authKey', None); atomic(ROOT / 'config.json', pending)
721
+
722
+ def apply(request):
723
+ prerequisite(request['action'])
724
+ with open(ROOT / 'request.lock', 'a') as lock:
725
+ fcntl.flock(lock, fcntl.LOCK_EX)
726
+ action = request['action']
727
+ source = pathlib.Path(__file__).read_bytes() if '__file__' in globals() else sys.argv[1].encode()
728
+ cached_source = ROOT / 'controller.py'
729
+ if not cached_source.exists() or cached_source.read_bytes() != source: atomic(cached_source, source, binary=True)
730
+ existing = load(ROOT / 'config.json', {})
731
+ capabilities = [int(line.split()[1], 16) for line in pathlib.Path('/proc/self/status').read_text().splitlines() if line.startswith(('CapInh:', 'CapPrm:', 'CapEff:', 'CapBnd:', 'CapAmb:'))]
732
+ # A prior choice with lost volatile state is not proof of an untouched namespace.
733
+ network_pristine = (existing.get('noNetworkChanges') is True or ((not existing or existing.get('mode') == 'direct') and not (PERSISTENT / 'intent.json').exists())) and bool(capabilities) and not any(mask & (1 << 12) for mask in capabilities) and not (PERSISTENT / 'resolver.original').exists() and not (PERSISTENT / 'hosts.original').exists() and not (ROOT / 'controller.pid').exists()
734
+ workspace_id = request.get('workspaceId')
735
+ if action != 'status' and existing.get('workspaceId') is not None and existing['workspaceId'] != workspace_id:
736
+ raise RuntimeError('This network namespace belongs to a different workspace')
737
+ operation = load(ROOT / 'operation.json', {})
738
+ operation_live = operation.get('bootId') == BOOT_ID and operation.get('deadline', 0) > time.monotonic()
739
+ token = str(uuid.UUID(request['configurationToken'])) if request.get('configurationToken') is not None else None
740
+ if action == 'claim':
741
+ if (request.get('refresh') or request.get('verifyOnly')) and (not operation_live or operation.get('token') != token):
742
+ raise WorkspaceConflictError('Workspace configuration claim was superseded')
743
+ if operation_live and operation.get('token') != token:
744
+ return {**load(ROOT / 'status.json', {'mode': 'blocked', 'leaseId': None, 'expiresAtMs': None, 'deviceId': None, 'path': None}), 'busy': True}
745
+ token = str(uuid.UUID(token))
746
+ if not request.get('verifyOnly'):
747
+ atomic(ROOT / 'operation.json', {'token': token, 'deadline': time.monotonic() + 300, 'bootId': BOOT_ID})
748
+ return {**load(ROOT / 'status.json', {'mode': 'blocked', 'leaseId': None, 'expiresAtMs': None, 'deviceId': None, 'path': None}), 'busy': False}
749
+ if action == 'unlock':
750
+ if operation.get('token') == token: (ROOT / 'operation.json').unlink(missing_ok=True)
751
+ return load(ROOT / 'status.json', {'mode': 'blocked', 'leaseId': None, 'expiresAtMs': None, 'deviceId': None, 'path': None})
752
+ if action not in ('status', 'artifact'):
753
+ if token is not None and (not operation_live or operation.get('token') != token):
754
+ raise WorkspaceConflictError('Workspace egress configuration was superseded')
755
+ if operation_live and token != operation.get('token'):
756
+ raise WorkspaceConflictError('Another binding is configuring workspace egress')
757
+ expected = lease_id(request['expectedLeaseId']) if request.get('expectedLeaseId') is not None else None
758
+ if token is None and expected is not None and existing.get('leaseId') != expected:
759
+ if action == 'block' and existing.get('leaseId') is None and existing.get('mode') == 'direct':
760
+ return load(ROOT / 'status.json', {'mode': 'direct', 'leaseId': None, 'expiresAtMs': None, 'deviceId': None, 'path': None})
761
+ if action == 'block' and existing.get('leaseId') is None and existing.get('mode') == 'blocked':
762
+ try: guard()
763
+ except Exception as failure:
764
+ status('error' if isinstance(failure, DenialUnavailableError) else 'blocked', existing)
765
+ raise
766
+ return status('blocked', existing)
767
+ if not (action == 'block' and existing.get('leaseId') is None and (not existing or existing.get('mode') == 'blocked')):
768
+ raise WorkspaceConflictError('Workspace egress lease changed before cleanup')
769
+ if action not in ('status', 'artifact'):
770
+ PERSISTENT.mkdir(mode=0o700, exist_ok=True)
771
+ if PERSISTENT.is_symlink() or PERSISTENT.stat().st_uid != 0:
772
+ raise RuntimeError('Persistent workspace egress state must be root-owned')
773
+ PERSISTENT.chmod(0o700)
774
+ identity = load(PERSISTENT / 'identity.json', {})
775
+ if identity.get('workspaceId') is not None and identity['workspaceId'] != workspace_id:
776
+ raise RuntimeError('Persistent egress state belongs to a different workspace')
777
+ if action != 'disable': atomic(PERSISTENT / 'identity.json', {'workspaceId': workspace_id})
778
+ if action == 'block': atomic(PERSISTENT / 'intent.json', {'requiresChoice': not request.get('restoreDirect', False)})
779
+ if action == 'disable' and existing.get('noNetworkChanges') is True:
780
+ if (PERSISTENT / 'resolver.original').exists() or (PERSISTENT / 'hosts.original').exists() or (ROOT / 'controller.pid').exists():
781
+ raise RuntimeError('Workspace contains restoration artifacts; privileged repair is required')
782
+ (PERSISTENT / 'identity.json').unlink(missing_ok=True)
783
+ (PERSISTENT / 'intent.json').unlink(missing_ok=True)
784
+ config = {'mode': 'direct', 'workspaceId': workspace_id, 'ownerUserId': None}
785
+ atomic(ROOT / 'config.json', config)
786
+ return status('direct', config)
787
+ if action == 'disable' and (not existing or (existing.get('mode') == 'direct' and load(ROOT / 'status.json', {}).get('mode') == 'direct')):
788
+ if restoration_pending(): restore()
789
+ (PERSISTENT / 'identity.json').unlink(missing_ok=True)
790
+ (PERSISTENT / 'intent.json').unlink(missing_ok=True)
791
+ config = {'mode': 'direct', 'workspaceId': workspace_id, 'ownerUserId': None}
792
+ atomic(ROOT / 'config.json', config)
793
+ return status('direct', config)
794
+ if action == 'artifact':
795
+ ready = False
796
+ try: fd = os.open(ROOT / 'helper', os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
797
+ except FileNotFoundError: pass
798
+ else:
799
+ with os.fdopen(fd, 'rb') as source:
800
+ info = os.fstat(source.fileno())
801
+ if stat.S_ISREG(info.st_mode) and info.st_size <= 128 * 1024 * 1024:
802
+ ready = hashlib.sha256(source.read(128 * 1024 * 1024 + 1)).hexdigest() == request['sha256']
803
+ return {**load(ROOT / 'status.json', {'mode': 'blocked', 'leaseId': None, 'expiresAtMs': None, 'deviceId': None, 'path': None}), 'helperReady': ready}
804
+ if action == 'status':
805
+ result = load(ROOT / 'status.json', {'mode': 'direct', 'leaseId': None, 'expiresAtMs': None, 'deviceId': None, 'path': None, 'architecture': platform.machine()})
806
+ alive = False
807
+ with open(ROOT / 'daemon.lock', 'a') as daemon_lock:
808
+ try:
809
+ fcntl.flock(daemon_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
810
+ if result.get('mode') != 'error' and existing.get('mode') not in ('direct', 'error') and existing:
811
+ result = {**result, 'mode': 'blocked' if denial_present() else 'error', 'deviceId': None, 'path': None}
812
+ except BlockingIOError: alive = True
813
+ return {**result, 'networkPristine': existing.get('noNetworkChanges', False), 'controllerAlive': alive, 'configuring': operation_live, 'restorePending': restoration_pending(), 'choicePending': load(PERSISTENT / 'intent.json', {}).get('requiresChoice', existing.get('mode') == 'blocked')}
814
+ if action in ('block', 'configure'):
815
+ try: guard()
816
+ except Exception as failure:
817
+ mode = 'error' if isinstance(failure, DenialUnavailableError) else 'blocked'
818
+ failed = {'mode': mode, 'workspaceId': workspace_id, 'ownerUserId': request.get('ownerUserId'), 'noNetworkChanges': network_pristine}
819
+ atomic(ROOT / 'config.json', failed); status(mode, failed)
820
+ raise
821
+ if action == 'configure':
822
+ # Validation failure must not leave an old direct/device status after guard installation.
823
+ pending = {'mode': 'blocked', 'workspaceId': workspace_id, 'ownerUserId': request.get('ownerUserId')}
824
+ atomic(ROOT / 'config.json', pending); status('blocked', pending)
825
+ if request.get('config', {}).get('workspaceId') != workspace_id: raise RuntimeError('Enrollment belongs to a different workspace')
826
+ if command('ip', '-4', 'rule', 'show', check=False).returncode != 0:
827
+ raise PrerequisiteError('This workspace kernel does not support Linux policy routing')
828
+ config = request['config']
829
+ config['dnsResolvers'] = validated_resolvers(config.get('dnsResolvers', ['1.1.1.1', '9.9.9.9']))
830
+ control = urllib.parse.urlsplit(config['controlUrl'])
831
+ if control.scheme != 'https' or not control.hostname or control.username is not None or control.password is not None or control.query or control.fragment:
832
+ raise RuntimeError('Workspace control URL must use HTTPS without credentials, query or fragment')
833
+ config['leaseId'] = lease_id(config['leaseId'])
834
+ remaining = config['expiresAtMs'] - time.time() * 1000
835
+ if not 0 < remaining <= 16 * 60 * 1000:
836
+ raise RuntimeError('Workspace egress lease is expired or exceeds fifteen minutes')
837
+ config['bootId'] = BOOT_ID
838
+ config['deadlineMonotonic'] = time.monotonic() + min(remaining, 15 * 60 * 1000) / 1000
839
+ staged = pathlib.Path(request['helperPath'])
840
+ try: fd = os.open(staged, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
841
+ except FileNotFoundError as failure:
842
+ raise RuntimeError('Staged networking helper is missing; transfer the approved artifact again') from failure
843
+ with os.fdopen(fd, 'rb') as source:
844
+ if not stat.S_ISREG(os.fstat(source.fileno()).st_mode):
845
+ raise RuntimeError('Workspace networking helper must be a regular file')
846
+ content = source.read(128 * 1024 * 1024 + 1)
847
+ if len(content) > 128 * 1024 * 1024 or hashlib.sha256(content).hexdigest() != config['helper']['sha256']:
848
+ raise RuntimeError('Workspace networking helper failed integrity verification')
849
+ machine = {'x86_64': 62, 'aarch64': 183}.get(platform.machine())
850
+ if content[:6] != b'\x7fELF\x02\x01' or len(content) < 20 or struct.unpack('<H', content[18:20])[0] != machine:
851
+ raise RuntimeError('The pinned networking helper does not match this Linux workspace architecture')
852
+ atomic(ROOT / 'helper', content, binary=True, mode=0o700)
853
+ config = dict(config, mode='device')
854
+ elif action == 'renew':
855
+ request['lease']['leaseId'] = lease_id(request['lease']['leaseId'])
856
+ if request.get('lease', {}).get('workspaceId') != workspace_id: raise RuntimeError('Renewal belongs to a different workspace')
857
+ config = load(ROOT / 'config.json')
858
+ if config is None or config.get('leaseId') != request['lease']['leaseId']:
859
+ raise WorkspaceConflictError('Workspace egress lease changed')
860
+ if config.get('bootId') != BOOT_ID or config.get('deadlineMonotonic', 0) <= time.monotonic() or config.get('expiresAtMs', 0) <= time.time() * 1000:
861
+ raise EnrollmentLostError('Workspace enrollment expired locally; authorized reenrollment is required')
862
+ lease = request['lease']
863
+ lease['leaseId'] = lease_id(lease['leaseId'])
864
+ remaining = lease['expiresAtMs'] - time.time() * 1000
865
+ if not 0 < remaining <= 16 * 60 * 1000:
866
+ raise RuntimeError('Workspace egress renewal is invalid')
867
+ config.update(bootId=BOOT_ID, expiresAtMs=lease['expiresAtMs'], devices=lease['devices'], state=lease['state'], deadlineMonotonic=time.monotonic() + min(remaining, 15 * 60 * 1000) / 1000)
868
+ elif action in ('block', 'disable'):
869
+ config = {'mode': 'direct' if action == 'disable' else 'blocked', 'workspaceId': workspace_id, 'ownerUserId': None if action == 'disable' else request.get('ownerUserId')}
870
+ else:
871
+ raise RuntimeError('Unknown workspace egress action')
872
+ atomic(ROOT / 'config.json', config)
873
+ if action in ('block', 'disable'): stop_controller()
874
+ if action == 'renew':
875
+ with open(ROOT / 'daemon.lock', 'a') as daemon_lock:
876
+ try: fcntl.flock(daemon_lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
877
+ except BlockingIOError:
878
+ prior = load(ROOT / 'status.json', {})
879
+ return {**prior, 'expiresAtMs': config['expiresAtMs']}
880
+ # The controller uses a root-owned copy after this caller's temporary files are removed.
881
+ source = pathlib.Path(__file__).read_bytes() if '__file__' in globals() else sys.argv[1].encode()
882
+ atomic(ROOT / 'controller.py', source, binary=True)
883
+ subprocess.Popen([sys.executable, '-I', str(ROOT / 'controller.py'), 'run'], stdin=subprocess.DEVNULL,
884
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
885
+ if action == 'block': return status('blocked', config)
886
+ return status('configuring', config)
887
+
888
+ if __name__ == '__main__':
889
+ try:
890
+ if len(sys.argv) > 1 and sys.argv[1] == 'run': run()
891
+ else: print(json.dumps(apply(json.load(sys.stdin))))
892
+ except Exception as cause:
893
+ try:
894
+ if ROOT.exists() and not ROOT.is_symlink() and ROOT.stat().st_uid == 0:
895
+ atomic(ROOT / 'last-error.json', {'error': str(cause)[:1000], 'atMs': int(time.time() * 1000)})
896
+ except OSError: pass
897
+ code = 'egress_enrollment_lost' if isinstance(cause, EnrollmentLostError) else 'prerequisite' if isinstance(cause, (FileNotFoundError, PrerequisiteError)) else 'conflict' if isinstance(cause, WorkspaceConflictError) else 'configuration'
898
+ print(json.dumps({'errorCode': code}))
899
+ sys.exit(1)
900
+ `;
901
+ const cachedWorkspaceEgressBootstrap = (sha256) => {
902
+ if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid workspace controller SHA256");
903
+ return String.raw`import hashlib, os, pathlib, stat
904
+ path = pathlib.Path('/run/final-workspace-egress/controller.py')
905
+ try:
906
+ fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
907
+ with os.fdopen(fd, 'rb') as file:
908
+ info = os.fstat(file.fileno())
909
+ source = file.read(128 * 1024 + 1)
910
+ valid = stat.S_ISREG(info.st_mode) and info.st_uid == 0 and not info.st_mode & 0o022 and len(source) <= 128 * 1024 and hashlib.sha256(source).hexdigest() == '${sha256}'
911
+ except OSError: valid = False
912
+ if valid: exec(compile(source, str(path), 'exec'), {'__name__': '__main__', '__file__': str(path)})
913
+ else: print('{"needsBootstrap":true}')
914
+ `;
915
+ };
916
+ const workspaceEgressLauncher = String.raw`import io,json,sys
917
+ request=json.load(sys.stdin)
918
+ source=request['source']
919
+ sys.argv=['-c',source]
920
+ sys.stdin=io.StringIO(request['input'])
921
+ exec(compile(source,'<final-workspace-egress>','exec'),{'__name__':'__main__'})
922
+ `;
923
+ export {
924
+ cachedWorkspaceEgressBootstrap,
925
+ workspaceEgressBootstrap,
926
+ workspaceEgressLauncher
927
+ };
928
+ //# sourceMappingURL=egress-bootstrap.mjs.map