ai-or-die 0.1.90 → 0.1.91
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/mesh/go.mod +1 -1
- package/mesh/main.go +169 -1
- package/mesh-sidecar.lock.json +7 -7
- package/package.json +1 -1
- package/src/mesh-manager.js +86 -12
- package/src/utils/sidecar-installer.js +33 -11
- package/mesh/main_test.go +0 -123
package/mesh/go.mod
CHANGED
package/mesh/main.go
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Command
|
|
1
|
+
// Command ai-or-die-mesh is the userspace mesh sidecar for ai-or-die. It joins a
|
|
2
2
|
// Tailscale tailnet entirely in userspace via tsnet (no kernel TUN, no admin,
|
|
3
3
|
// no system service) and reverse-proxies the tailnet listener to ai-or-die's
|
|
4
4
|
// local HTTP/WebSocket port. Only this one port is exposed on the mesh; the
|
|
@@ -18,16 +18,24 @@
|
|
|
18
18
|
// MESH-URL http://<name> node is up, but TLS certs unavailable (degraded)
|
|
19
19
|
// MESH-NOCERT hint: enable HTTPS Certificates in the tailnet
|
|
20
20
|
// MESH-PEERS {"self":...,"peers":[...]} tagged fleet peers snapshot
|
|
21
|
+
// MESH-EGRESS http://127.0.0.1:<port> <token> loopback CONNECT proxy for a
|
|
22
|
+
// same-box conductor to reach tagged tailnet peers
|
|
23
|
+
// MESH-UNTAGGED <selfTagged> <total> <tagged> tailnet peers exist but none are
|
|
24
|
+
// tagged tag:aiordie — discovery will stay empty
|
|
21
25
|
// MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
|
|
22
26
|
// MESH-ERR <msg> fatal
|
|
23
27
|
package main
|
|
24
28
|
|
|
25
29
|
import (
|
|
26
30
|
"context"
|
|
31
|
+
"crypto/rand"
|
|
32
|
+
"crypto/subtle"
|
|
27
33
|
"crypto/tls"
|
|
34
|
+
"encoding/hex"
|
|
28
35
|
"encoding/json"
|
|
29
36
|
"flag"
|
|
30
37
|
"fmt"
|
|
38
|
+
"io"
|
|
31
39
|
"net"
|
|
32
40
|
"net/http"
|
|
33
41
|
"net/http/httputil"
|
|
@@ -35,6 +43,7 @@ import (
|
|
|
35
43
|
"os"
|
|
36
44
|
"sort"
|
|
37
45
|
"strings"
|
|
46
|
+
"sync"
|
|
38
47
|
"time"
|
|
39
48
|
|
|
40
49
|
"tailscale.com/ipn/ipnstate"
|
|
@@ -93,6 +102,10 @@ func main() {
|
|
|
93
102
|
|
|
94
103
|
if lc, err := s.LocalClient(); err == nil {
|
|
95
104
|
go emitMeshPeersLoop(context.Background(), lc)
|
|
105
|
+
// Loopback egress: lets a same-box conductor (github-router) reach tagged
|
|
106
|
+
// tailnet peers over WireGuard without the host OS joining the tailnet.
|
|
107
|
+
// Best-effort — a failure here never blocks serving.
|
|
108
|
+
startEgressProxy(s, lc)
|
|
96
109
|
}
|
|
97
110
|
|
|
98
111
|
name := *host
|
|
@@ -223,6 +236,29 @@ func emitMeshPeers(ctx context.Context, lc statusClient) {
|
|
|
223
236
|
return
|
|
224
237
|
}
|
|
225
238
|
fmt.Printf("MESH-PEERS %s\n", b)
|
|
239
|
+
|
|
240
|
+
// Diagnostic: tailnet peers exist but none are tagged tag:aiordie, so the
|
|
241
|
+
// snapshot above is empty and fleet discovery will surface nothing. Emit a
|
|
242
|
+
// machine-parseable line the manager turns into an actionable hint — the
|
|
243
|
+
// silent-empty-peers state is the exact failure this whole path guards against.
|
|
244
|
+
total, tagged := peerCounts(status)
|
|
245
|
+
if total > 0 && tagged == 0 {
|
|
246
|
+
selfTagged := status.Self != nil && peerHasTag(status.Self, meshPeerTag)
|
|
247
|
+
fmt.Printf("MESH-UNTAGGED %t %d %d\n", selfTagged, total, tagged)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func peerCounts(status *ipnstate.Status) (total, tagged int) {
|
|
252
|
+
for _, ps := range status.Peer {
|
|
253
|
+
if ps == nil {
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
total++
|
|
257
|
+
if peerHasTag(ps, meshPeerTag) {
|
|
258
|
+
tagged++
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return total, tagged
|
|
226
262
|
}
|
|
227
263
|
|
|
228
264
|
func meshPeersFromStatus(status *ipnstate.Status) meshPeersSnapshot {
|
|
@@ -339,3 +375,135 @@ func newEdgeProxy(target *url.URL, name string, proto *string, proxyBearer strin
|
|
|
339
375
|
}
|
|
340
376
|
return rp
|
|
341
377
|
}
|
|
378
|
+
|
|
379
|
+
// startEgressProxy stands up a loopback HTTP CONNECT proxy so a same-box conductor
|
|
380
|
+
// process (github-router) can reach TAGGED tailnet peers over the sidecar's
|
|
381
|
+
// in-process WireGuard — the host OS never joins the tailnet. It is bound to
|
|
382
|
+
// loopback, gated by a random per-process bearer, and restricted to tagged
|
|
383
|
+
// .ts.net peers on the served ports, so a stray local process can neither turn it
|
|
384
|
+
// into a generic tailnet egress nor learn the token off the wire. The endpoint +
|
|
385
|
+
// token are announced via MESH-EGRESS for the manager to persist (0600).
|
|
386
|
+
// Best-effort: any failure here never blocks serving.
|
|
387
|
+
func startEgressProxy(s *tsnet.Server, lc statusClient) {
|
|
388
|
+
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
389
|
+
if err != nil {
|
|
390
|
+
return
|
|
391
|
+
}
|
|
392
|
+
token, err := randToken()
|
|
393
|
+
if err != nil {
|
|
394
|
+
_ = ln.Close()
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
fmt.Printf("MESH-EGRESS http://%s %s\n", ln.Addr().String(), token)
|
|
398
|
+
srv := &http.Server{Handler: egressHandler(s.Dial, lc, token)}
|
|
399
|
+
go func() { _ = srv.Serve(ln) }()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// dialFunc dials a tailnet address (tsnet.Server.Dial); injectable for tests.
|
|
403
|
+
type dialFunc func(ctx context.Context, network, addr string) (net.Conn, error)
|
|
404
|
+
|
|
405
|
+
func egressHandler(dial dialFunc, lc statusClient, token string) http.Handler {
|
|
406
|
+
want := "Bearer " + token
|
|
407
|
+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
408
|
+
if r.Method != http.MethodConnect {
|
|
409
|
+
http.Error(w, "only CONNECT is supported", http.StatusMethodNotAllowed)
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
// Constant-time bearer check on the proxy hop so a stray local process
|
|
413
|
+
// cannot use the tailnet egress without the per-process token.
|
|
414
|
+
got := r.Header.Get("Proxy-Authorization")
|
|
415
|
+
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
|
|
416
|
+
w.Header().Set("Proxy-Authenticate", "Bearer")
|
|
417
|
+
http.Error(w, "proxy authorization required", http.StatusProxyAuthRequired)
|
|
418
|
+
return
|
|
419
|
+
}
|
|
420
|
+
host, port, err := net.SplitHostPort(r.Host)
|
|
421
|
+
if err != nil {
|
|
422
|
+
http.Error(w, "bad CONNECT target", http.StatusBadRequest)
|
|
423
|
+
return
|
|
424
|
+
}
|
|
425
|
+
if !egressTargetAllowed(lc, host, port) {
|
|
426
|
+
http.Error(w, "forbidden CONNECT target", http.StatusForbidden)
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
upstream, err := dial(r.Context(), "tcp", net.JoinHostPort(host, port))
|
|
430
|
+
if err != nil {
|
|
431
|
+
http.Error(w, "upstream dial failed", http.StatusBadGateway)
|
|
432
|
+
return
|
|
433
|
+
}
|
|
434
|
+
hj, ok := w.(http.Hijacker)
|
|
435
|
+
if !ok {
|
|
436
|
+
_ = upstream.Close()
|
|
437
|
+
http.Error(w, "hijack unsupported", http.StatusInternalServerError)
|
|
438
|
+
return
|
|
439
|
+
}
|
|
440
|
+
clientConn, _, err := hj.Hijack()
|
|
441
|
+
if err != nil {
|
|
442
|
+
_ = upstream.Close()
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
_, _ = clientConn.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n"))
|
|
446
|
+
// Splice both directions. Half-close each write end on its own EOF (so a
|
|
447
|
+
// one-way FIN — e.g. an HTTP/1.1 request with Connection: close — does not
|
|
448
|
+
// truncate the reply mid-flight), then fully close once BOTH directions
|
|
449
|
+
// have drained. net.Pipe and other non-TCP conns lack CloseWrite; the type
|
|
450
|
+
// assertion simply skips the half-close for them.
|
|
451
|
+
var wg sync.WaitGroup
|
|
452
|
+
wg.Add(2)
|
|
453
|
+
go func() {
|
|
454
|
+
defer wg.Done()
|
|
455
|
+
_, _ = io.Copy(upstream, clientConn)
|
|
456
|
+
if cw, ok := upstream.(interface{ CloseWrite() error }); ok {
|
|
457
|
+
_ = cw.CloseWrite()
|
|
458
|
+
}
|
|
459
|
+
}()
|
|
460
|
+
go func() {
|
|
461
|
+
defer wg.Done()
|
|
462
|
+
_, _ = io.Copy(clientConn, upstream)
|
|
463
|
+
if cw, ok := clientConn.(interface{ CloseWrite() error }); ok {
|
|
464
|
+
_ = cw.CloseWrite()
|
|
465
|
+
}
|
|
466
|
+
}()
|
|
467
|
+
wg.Wait()
|
|
468
|
+
_ = clientConn.Close()
|
|
469
|
+
_ = upstream.Close()
|
|
470
|
+
})
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// egressTargetAllowed permits CONNECT only to a TAGGED same-tailnet .ts.net peer
|
|
474
|
+
// (exact DNSName match, never a suffix) on a served port, and rejects IP-literal
|
|
475
|
+
// targets — so the loopback proxy can never be turned into a generic tailnet
|
|
476
|
+
// egress, preserving the ACL's instance-isolation posture.
|
|
477
|
+
func egressTargetAllowed(lc statusClient, host, port string) bool {
|
|
478
|
+
if port != "443" && port != "7777" {
|
|
479
|
+
return false
|
|
480
|
+
}
|
|
481
|
+
if net.ParseIP(host) != nil {
|
|
482
|
+
return false // no raw IPs — only MagicDNS names bound to a known tagged peer
|
|
483
|
+
}
|
|
484
|
+
want := normalizeDNSName(host)
|
|
485
|
+
if want == "" {
|
|
486
|
+
return false
|
|
487
|
+
}
|
|
488
|
+
status, err := lc.Status(context.Background())
|
|
489
|
+
if err != nil || status == nil {
|
|
490
|
+
return false
|
|
491
|
+
}
|
|
492
|
+
for _, ps := range status.Peer {
|
|
493
|
+
if ps == nil || !peerHasTag(ps, meshPeerTag) {
|
|
494
|
+
continue
|
|
495
|
+
}
|
|
496
|
+
if normalizeDNSName(ps.DNSName) == want {
|
|
497
|
+
return true
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return false
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
func randToken() (string, error) {
|
|
504
|
+
b := make([]byte, 32)
|
|
505
|
+
if _, err := rand.Read(b); err != nil {
|
|
506
|
+
return "", err
|
|
507
|
+
}
|
|
508
|
+
return hex.EncodeToString(b), nil
|
|
509
|
+
}
|
package/mesh-sidecar.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
|
-
"contentHash": "
|
|
3
|
+
"contentHash": "15ca95fdba8ba0d81c2e49f8f9707d2fb124f51ee3982dea3a87d11b140ab279",
|
|
4
4
|
"assets": {
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
5
|
+
"ai-or-die-mesh-windows-amd64.exe": "8b194d82257e7d99e9e0f770830b2baf1ccc17f82c8aad6097fa9c7594ddc89b",
|
|
6
|
+
"ai-or-die-mesh-windows-arm64.exe": "7dc1fe73117a95b9e4889457b9f97f8a59bb547e53c2f64499e998cf2a0016e2",
|
|
7
|
+
"ai-or-die-mesh-linux-amd64": "abf133b731dd2c0dcdefaf157cb0f026d741554e1f0332d329fb2bac542d0645",
|
|
8
|
+
"ai-or-die-mesh-linux-arm64": "d63cd407521ef7e7c72dac90b5cf5ac18c92aa8243046ba7dc7b8c4da58fc468",
|
|
9
|
+
"ai-or-die-mesh-darwin-amd64": "669e36de903fedb5e5d2dc0152971c828582bf25622310f559eefb226f4221de",
|
|
10
|
+
"ai-or-die-mesh-darwin-arm64": "7f0f3fc766a87e110ecc5e908e6b86333e56f3d0517b81ce4dc582963dadad11"
|
|
11
11
|
}
|
|
12
12
|
}
|
package/package.json
CHANGED
package/src/mesh-manager.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// MeshManager — permanent reachability over a Tailscale tailnet via the
|
|
4
|
-
//
|
|
4
|
+
// ai-or-die-mesh sidecar (tsnet in USERSPACE: no kernel TUN, no admin, no system
|
|
5
5
|
// service). Only ai-or-die's own port joins the mesh; the rest of the machine
|
|
6
6
|
// stays off it. Mirrors TunnelManager's lifecycle (detect → start → backoff →
|
|
7
7
|
// stop) so bin/ai-or-die.js supervises it like the dev tunnel. Verified E2E:
|
|
@@ -11,6 +11,7 @@ const { spawn } = require('child_process');
|
|
|
11
11
|
const os = require('os');
|
|
12
12
|
const path = require('path');
|
|
13
13
|
const fs = require('fs');
|
|
14
|
+
const crypto = require('crypto');
|
|
14
15
|
|
|
15
16
|
const URL_TIMEOUT_MS = 60000; // enroll + name can take ~30s on first run
|
|
16
17
|
const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
|
|
@@ -38,11 +39,11 @@ class MeshManager {
|
|
|
38
39
|
? path.join(localApp, 'ai-or-die')
|
|
39
40
|
: path.join(os.homedir(), '.ai-or-die');
|
|
40
41
|
this.stateDir = options.stateDir || path.join(this._appBase, 'ts-state');
|
|
41
|
-
// The sidecar
|
|
42
|
-
//
|
|
43
|
-
//
|
|
42
|
+
// The sidecar is LAUNCHED from a stable, hash-free path (ai-or-die-mesh[.exe])
|
|
43
|
+
// so a single-file WDAC/AppLocker allow-list rule matches the executed image
|
|
44
|
+
// across versions (ADR-0036). The installer verifies + replaces it in place.
|
|
44
45
|
this._installer = options._installer || require('./utils/sidecar-installer');
|
|
45
|
-
this.sidecar = options.sidecar || this.
|
|
46
|
+
this.sidecar = options.sidecar || this._stableSidecarPath(this._appBase);
|
|
46
47
|
this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
47
48
|
|
|
48
49
|
this.proc = null;
|
|
@@ -59,12 +60,16 @@ class MeshManager {
|
|
|
59
60
|
this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
|
|
60
61
|
this._stdoutBuffer = '';
|
|
61
62
|
this.peers = null;
|
|
63
|
+
this._untaggedWarned = false;
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
/** Never throws — degrades to localhost/devtunnel on any failure. */
|
|
65
67
|
async start() {
|
|
66
68
|
console.log('\n Connecting mesh (Tailscale userspace)...');
|
|
67
69
|
this.stopping = false;
|
|
70
|
+
// Clear any egress.json left by a crashed/kill -9'd prior run BEFORE (re)spawn,
|
|
71
|
+
// so a consumer can't route to a freed loopback port a local process squatted.
|
|
72
|
+
this._deleteEgressFile();
|
|
68
73
|
if (!fs.existsSync(this.sidecar)) {
|
|
69
74
|
if (!(await this._ensureSidecar())) { this._printMissing(this._lastError); return; }
|
|
70
75
|
}
|
|
@@ -73,13 +78,12 @@ class MeshManager {
|
|
|
73
78
|
await this._spawn();
|
|
74
79
|
}
|
|
75
80
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
81
|
+
/** Stable, hash-free sidecar path from the installer; safe fallback. */
|
|
82
|
+
_stableSidecarPath(base) {
|
|
78
83
|
try {
|
|
79
|
-
|
|
80
|
-
return this._installer.sidecarPath(lock.contentHash);
|
|
84
|
+
return this._installer.stableSidecarPath();
|
|
81
85
|
} catch (_) {
|
|
82
|
-
const exe = process.platform === 'win32' ? '
|
|
86
|
+
const exe = process.platform === 'win32' ? 'ai-or-die-mesh.exe' : 'ai-or-die-mesh';
|
|
83
87
|
return path.join(base, 'bin', exe);
|
|
84
88
|
}
|
|
85
89
|
}
|
|
@@ -113,6 +117,7 @@ class MeshManager {
|
|
|
113
117
|
async stop() {
|
|
114
118
|
this.stopping = true;
|
|
115
119
|
this._deletePeersFile();
|
|
120
|
+
this._deleteEgressFile();
|
|
116
121
|
this._clearStabilityTimer();
|
|
117
122
|
clearTimeout(this._restartDelayTimer);
|
|
118
123
|
if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
|
|
@@ -156,6 +161,7 @@ class MeshManager {
|
|
|
156
161
|
}
|
|
157
162
|
if (/^MESH-NEEDLOGIN\b/.test(line)) {
|
|
158
163
|
this._deletePeersFile();
|
|
164
|
+
this._deleteEgressFile();
|
|
159
165
|
this._printNotEnrolled();
|
|
160
166
|
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
161
167
|
}
|
|
@@ -164,7 +170,7 @@ class MeshManager {
|
|
|
164
170
|
this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
|
|
165
171
|
this.proc.on('error', (e) => { console.error(` \x1b[31mmesh sidecar failed: ${e.message}\x1b[0m`); this.proc = null; if (!done) { done = true; clearTimeout(timer); resolve(); } });
|
|
166
172
|
this.proc.on('exit', (code) => {
|
|
167
|
-
this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
|
|
173
|
+
this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile(); this._deleteEgressFile();
|
|
168
174
|
if (!this.stopping && code !== 0) this._restart();
|
|
169
175
|
});
|
|
170
176
|
});
|
|
@@ -189,7 +195,9 @@ class MeshManager {
|
|
|
189
195
|
|
|
190
196
|
_handleStdoutLine(line) {
|
|
191
197
|
try {
|
|
192
|
-
if (/^MESH-NEEDLOGIN\b/.test(line)) this._deletePeersFile();
|
|
198
|
+
if (/^MESH-NEEDLOGIN\b/.test(line)) { this._deletePeersFile(); this._deleteEgressFile(); return; }
|
|
199
|
+
if (/^MESH-EGRESS\b/.test(line)) { this._handleEgressLine(line); return; }
|
|
200
|
+
if (/^MESH-UNTAGGED\b/.test(line)) { this._handleUntaggedLine(line); return; }
|
|
193
201
|
const match = /^MESH-PEERS\s+(.+)$/.exec(line);
|
|
194
202
|
if (!match) return;
|
|
195
203
|
const raw = match[1];
|
|
@@ -206,6 +214,8 @@ class MeshManager {
|
|
|
206
214
|
const normalized = this._validatePeersPayload(parsed);
|
|
207
215
|
if (!normalized) return;
|
|
208
216
|
this.peers = normalized;
|
|
217
|
+
// Recovered: tagged peers are visible again, so re-arm the untagged warning.
|
|
218
|
+
if (normalized.peers.length > 0) this._untaggedWarned = false;
|
|
209
219
|
this._writePeersFile(normalized);
|
|
210
220
|
} catch (_) {}
|
|
211
221
|
}
|
|
@@ -249,6 +259,70 @@ class MeshManager {
|
|
|
249
259
|
try { fs.rmSync(this.peersFilePath(), { force: true }); } catch (_) {}
|
|
250
260
|
}
|
|
251
261
|
|
|
262
|
+
egressFilePath() {
|
|
263
|
+
return path.join(this._appBase, 'mesh', 'egress.json');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Parse `MESH-EGRESS <url> <token>` and persist the loopback CONNECT-proxy
|
|
267
|
+
// endpoint + local secret for a same-box conductor (github-router) to consume.
|
|
268
|
+
// Only a loopback http URL is ever accepted; the token is a local credential.
|
|
269
|
+
_handleEgressLine(line) {
|
|
270
|
+
const m = /^MESH-EGRESS\s+(\S+)\s+(\S+)\s*$/.exec(line);
|
|
271
|
+
if (!m) return;
|
|
272
|
+
const url = m[1];
|
|
273
|
+
const token = m[2];
|
|
274
|
+
let u;
|
|
275
|
+
try { u = new URL(url); } catch (_) { return; }
|
|
276
|
+
if (u.protocol !== 'http:') return;
|
|
277
|
+
const host = u.hostname.replace(/^\[/, '').replace(/\]$/, '');
|
|
278
|
+
// Only a numeric loopback literal — never `localhost`, whose resolution a
|
|
279
|
+
// consumer's DNS/hosts config could rebind off-host.
|
|
280
|
+
if (host !== '127.0.0.1' && host !== '::1') return;
|
|
281
|
+
if (!token || /\s/.test(token)) return;
|
|
282
|
+
this._writeEgressFile(url, token);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
_writeEgressFile(url, token) {
|
|
286
|
+
const file = this.egressFilePath();
|
|
287
|
+
const dir = path.dirname(file);
|
|
288
|
+
// Random, exclusive-create tmp so a pre-planted symlink at a predictable path
|
|
289
|
+
// can't redirect the write (TOCTOU / symlink clobber).
|
|
290
|
+
const tmp = path.join(dir, `egress.json.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
291
|
+
try {
|
|
292
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
293
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
294
|
+
version: 1,
|
|
295
|
+
pid: this.proc ? this.proc.pid : process.pid,
|
|
296
|
+
updatedAt: Date.now(),
|
|
297
|
+
url,
|
|
298
|
+
token,
|
|
299
|
+
}), { mode: 0o600, flag: 'wx' });
|
|
300
|
+
fs.chmodSync(tmp, 0o600);
|
|
301
|
+
fs.renameSync(tmp, file);
|
|
302
|
+
} catch (_) {
|
|
303
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) {}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_deleteEgressFile() {
|
|
308
|
+
try { fs.rmSync(this.egressFilePath(), { force: true }); } catch (_) {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Actionable one-shot hint: tailnet peers exist but none carry tag:aiordie, so
|
|
312
|
+
// fleet discovery will surface nothing until they are tagged. The exact failure
|
|
313
|
+
// this whole path guards against — surfaced, not silent.
|
|
314
|
+
_handleUntaggedLine(line) {
|
|
315
|
+
if (this._untaggedWarned) return;
|
|
316
|
+
const m = /^MESH-UNTAGGED\s+(\S+)\s+(\d+)\s+(\d+)\s*$/.exec(line);
|
|
317
|
+
if (!m) return;
|
|
318
|
+
this._untaggedWarned = true;
|
|
319
|
+
const total = m[2];
|
|
320
|
+
console.log(`\n \x1b[33mMesh: ${total} tailnet device(s) visible but \x1b[1m0 tagged tag:aiordie\x1b[0m\x1b[33m.\x1b[0m`);
|
|
321
|
+
console.log(' Fleet discovery stays EMPTY until instances carry the tag:');
|
|
322
|
+
console.log(' • enroll with a REUSABLE + TAGGED key (tag:aiordie), or retag each device in the admin console;');
|
|
323
|
+
console.log(' • ensure the ACL allows your conductor → tag:aiordie on the served ports (docs/mesh-acl.example.hujson).');
|
|
324
|
+
}
|
|
325
|
+
|
|
252
326
|
_printMissing(err) {
|
|
253
327
|
const code = err && err.code;
|
|
254
328
|
const map = {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// Sidecar installer — fetches the prebuilt
|
|
3
|
+
// Sidecar installer — fetches the prebuilt ai-or-die-mesh tsnet binary for the
|
|
4
4
|
// current platform and verifies it against a SHA-256 that ships INSIDE this npm
|
|
5
5
|
// package (mesh-sidecar.lock.json). Trust is therefore anchored by the signed
|
|
6
6
|
// npm artifact, not by a checksums file fetched from the same mutable GitHub
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// CI only rebuilds when the source — and thus the hash — changes.
|
|
13
13
|
//
|
|
14
14
|
// Asset convention (published by .github/workflows/release-on-main.yml):
|
|
15
|
-
//
|
|
15
|
+
// ai-or-die-mesh-<plat>-<arch>[.exe] plat: windows|linux|darwin arch: amd64|arm64
|
|
16
16
|
|
|
17
17
|
const fs = require('fs');
|
|
18
18
|
const fsp = require('fs').promises;
|
|
@@ -44,7 +44,7 @@ function assetName() {
|
|
|
44
44
|
const plat = PLAT[process.platform];
|
|
45
45
|
const arch = ARCH[process.arch];
|
|
46
46
|
if (!plat || !arch) return null;
|
|
47
|
-
return `
|
|
47
|
+
return `ai-or-die-mesh-${plat}-${arch}${process.platform === 'win32' ? '.exe' : ''}`;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
function baseDir() {
|
|
@@ -52,11 +52,32 @@ function baseDir() {
|
|
|
52
52
|
return process.platform === 'win32' ? path.join(localApp, 'ai-or-die') : path.join(os.homedir(), '.ai-or-die');
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
|
|
55
|
+
// Stable runnable path (NO content hash): the manager LAUNCHES this path, so a
|
|
56
|
+
// single-file WDAC/AppLocker allow-list rule matches the executed image across
|
|
57
|
+
// every version. This trades ADR-0035's install-alongside for that match; it is
|
|
58
|
+
// safe because the stable file is free at process start (the prior ai-or-die and
|
|
59
|
+
// its sidecar have exited), and the MOTW/quarantine mark is stripped after the
|
|
60
|
+
// SHA-256 verify so a re-download is not re-gated. See ADR-0036.
|
|
61
|
+
function stableSidecarPath() {
|
|
58
62
|
const ext = process.platform === 'win32' ? '.exe' : '';
|
|
59
|
-
return path.join(baseDir(), 'bin', `
|
|
63
|
+
return path.join(baseDir(), 'bin', `ai-or-die-mesh${ext}`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Best-effort provenance strip AFTER the SHA-256 verify: remove Windows
|
|
67
|
+
// mark-of-the-web (Zone.Identifier ADS) / macOS Gatekeeper quarantine so the
|
|
68
|
+
// freshly-verified binary is not re-gated by SmartScreen/Gatekeeper on each new
|
|
69
|
+
// version. We verified the bytes against the checksum shipped in the signed npm
|
|
70
|
+
// package, so this de-gates only a binary we already trust. Never throws.
|
|
71
|
+
function stripProvenance(file) {
|
|
72
|
+
try {
|
|
73
|
+
if (process.platform === 'win32') {
|
|
74
|
+
fs.rmSync(`${file}:Zone.Identifier`, { force: true });
|
|
75
|
+
} else if (process.platform === 'darwin') {
|
|
76
|
+
try {
|
|
77
|
+
require('child_process').execFileSync('xattr', ['-d', 'com.apple.quarantine', file], { stdio: 'ignore' });
|
|
78
|
+
} catch (_) { /* xattr absent or attribute not set */ }
|
|
79
|
+
}
|
|
80
|
+
} catch (_) { /* best-effort */ }
|
|
60
81
|
}
|
|
61
82
|
|
|
62
83
|
// The release ref to fetch from. Defaults to mesh-<contentHash>; an explicit
|
|
@@ -127,11 +148,11 @@ async function ensureSidecar(dest, opts = {}) {
|
|
|
127
148
|
throw new SidecarError('lock-unfinalized', `mesh-sidecar.lock.json has no checksum for ${name} (built without the mesh asset pipeline?)`);
|
|
128
149
|
}
|
|
129
150
|
|
|
130
|
-
dest = dest ||
|
|
151
|
+
dest = dest || stableSidecarPath();
|
|
131
152
|
|
|
132
153
|
// Existing file: trust only if it matches; otherwise replace it.
|
|
133
154
|
if (fs.existsSync(dest)) {
|
|
134
|
-
if ((await _sha256(dest)).toLowerCase() === want) return dest;
|
|
155
|
+
if ((await _sha256(dest)).toLowerCase() === want) { stripProvenance(dest); return dest; }
|
|
135
156
|
try { await fsp.unlink(dest); }
|
|
136
157
|
catch (e) {
|
|
137
158
|
if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
|
|
@@ -157,7 +178,7 @@ async function ensureSidecar(dest, opts = {}) {
|
|
|
157
178
|
} catch (e) {
|
|
158
179
|
// Lost a race to a concurrent installer (or the file is locked). If the
|
|
159
180
|
// destination is now present and valid, accept it; otherwise surface it.
|
|
160
|
-
if (fs.existsSync(dest) && (await _sha256(dest)).toLowerCase() === want) return dest;
|
|
181
|
+
if (fs.existsSync(dest) && (await _sha256(dest)).toLowerCase() === want) { stripProvenance(dest); return dest; }
|
|
161
182
|
if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
|
|
162
183
|
throw new SidecarError('locked-binary', `cannot install sidecar at ${dest} (${e.code})`);
|
|
163
184
|
}
|
|
@@ -166,7 +187,8 @@ async function ensureSidecar(dest, opts = {}) {
|
|
|
166
187
|
} finally {
|
|
167
188
|
try { await fsp.unlink(tmp); } catch (_) {}
|
|
168
189
|
}
|
|
190
|
+
stripProvenance(dest);
|
|
169
191
|
return dest;
|
|
170
192
|
}
|
|
171
193
|
|
|
172
|
-
module.exports = { ensureSidecar,
|
|
194
|
+
module.exports = { ensureSidecar, stableSidecarPath, assetName, loadLock, SidecarError };
|
package/mesh/main_test.go
DELETED
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
package main
|
|
2
|
-
|
|
3
|
-
import (
|
|
4
|
-
"io"
|
|
5
|
-
"net/http"
|
|
6
|
-
"net/http/httptest"
|
|
7
|
-
"net/url"
|
|
8
|
-
"strings"
|
|
9
|
-
"testing"
|
|
10
|
-
)
|
|
11
|
-
|
|
12
|
-
func TestResolveBackend(t *testing.T) {
|
|
13
|
-
cases := []struct {
|
|
14
|
-
name string
|
|
15
|
-
backend string
|
|
16
|
-
port string
|
|
17
|
-
wantErr bool
|
|
18
|
-
wantHost string // expected resolved u.Host when no error
|
|
19
|
-
}{
|
|
20
|
-
{"default port → loopback", "", "7777", false, "127.0.0.1:7777"},
|
|
21
|
-
{"explicit loopback v4", "http://127.0.0.1:8080", "0", false, "127.0.0.1:8080"},
|
|
22
|
-
{"loopback v6", "http://[::1]:8080", "0", false, "[::1]:8080"},
|
|
23
|
-
{"ipv4-mapped loopback", "http://[::ffff:127.0.0.1]:8080", "0", false, "[::ffff:127.0.0.1]:8080"},
|
|
24
|
-
{"localhost normalized to 127.0.0.1", "http://localhost:9000", "0", false, "127.0.0.1:9000"},
|
|
25
|
-
{"https loopback allowed", "https://127.0.0.1:8443", "0", false, "127.0.0.1:8443"},
|
|
26
|
-
{"off-host private ip rejected", "http://10.0.0.5:80", "0", true, ""},
|
|
27
|
-
{"0.0.0.0 rejected", "http://0.0.0.0:80", "0", true, ""},
|
|
28
|
-
{"public ip rejected", "http://93.184.216.34:80", "0", true, ""},
|
|
29
|
-
{"arbitrary dns rejected", "http://evil.example.com:80", "0", true, ""},
|
|
30
|
-
{"non-loopback v6 rejected", "http://[2001:db8::1]:80", "0", true, ""},
|
|
31
|
-
{"bad scheme rejected", "ftp://127.0.0.1:80", "0", true, ""},
|
|
32
|
-
{"file scheme rejected", "file:///etc/passwd", "0", true, ""},
|
|
33
|
-
{"garbage rejected", "://nope", "0", true, ""},
|
|
34
|
-
}
|
|
35
|
-
for _, c := range cases {
|
|
36
|
-
t.Run(c.name, func(t *testing.T) {
|
|
37
|
-
u, err := resolveBackend(c.backend, c.port)
|
|
38
|
-
if c.wantErr {
|
|
39
|
-
if err == nil {
|
|
40
|
-
t.Fatalf("expected error for backend=%q, got host=%q", c.backend, u.Host)
|
|
41
|
-
}
|
|
42
|
-
return
|
|
43
|
-
}
|
|
44
|
-
if err != nil {
|
|
45
|
-
t.Fatalf("unexpected error for backend=%q: %v", c.backend, err)
|
|
46
|
-
}
|
|
47
|
-
if u.Host != c.wantHost {
|
|
48
|
-
t.Fatalf("backend=%q → host=%q, want %q", c.backend, u.Host, c.wantHost)
|
|
49
|
-
}
|
|
50
|
-
})
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
func TestIsLoopbackHost(t *testing.T) {
|
|
55
|
-
loop := []string{"localhost", "127.0.0.1", "127.0.0.2", "::1", "::ffff:127.0.0.1"}
|
|
56
|
-
notLoop := []string{"0.0.0.0", "10.0.0.1", "192.168.1.1", "93.184.216.34", "2001:db8::1", "example.com", ""}
|
|
57
|
-
for _, h := range loop {
|
|
58
|
-
if !isLoopbackHost(h) {
|
|
59
|
-
t.Errorf("isLoopbackHost(%q) = false, want true", h)
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
for _, h := range notLoop {
|
|
63
|
-
if isLoopbackHost(h) {
|
|
64
|
-
t.Errorf("isLoopbackHost(%q) = true, want false", h)
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// End-to-end through the actual reverse proxy (no tailnet): a real loopback
|
|
70
|
-
// backend must receive the X-Forwarded-Proto/Host the edge stamps, the app
|
|
71
|
-
// bearer, and the pointer-captured proto must reflect the post-TLS decision.
|
|
72
|
-
func TestEdgeProxyForwardsProtoHostAndAuth(t *testing.T) {
|
|
73
|
-
var gotProto, gotHost, gotAuth, gotBody string
|
|
74
|
-
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
75
|
-
gotProto = r.Header.Get("X-Forwarded-Proto")
|
|
76
|
-
gotHost = r.Header.Get("X-Forwarded-Host")
|
|
77
|
-
gotAuth = r.Header.Get("Authorization")
|
|
78
|
-
io.WriteString(w, "backend-ok")
|
|
79
|
-
}))
|
|
80
|
-
defer backend.Close()
|
|
81
|
-
|
|
82
|
-
target, _ := url.Parse(backend.URL) // 127.0.0.1 loopback
|
|
83
|
-
proto := "http" // default before the TLS decision
|
|
84
|
-
rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto, "app-token")
|
|
85
|
-
proto = "https" // edge decided real TLS AFTER constructing the proxy
|
|
86
|
-
|
|
87
|
-
front := httptest.NewServer(rp)
|
|
88
|
-
defer front.Close()
|
|
89
|
-
|
|
90
|
-
res, err := http.Get(front.URL + "/app")
|
|
91
|
-
if err != nil {
|
|
92
|
-
t.Fatalf("proxy request failed: %v", err)
|
|
93
|
-
}
|
|
94
|
-
defer res.Body.Close()
|
|
95
|
-
b, _ := io.ReadAll(res.Body)
|
|
96
|
-
gotBody = string(b)
|
|
97
|
-
|
|
98
|
-
if gotProto != "https" {
|
|
99
|
-
t.Errorf("backend saw X-Forwarded-Proto=%q, want https (pointer-captured decision)", gotProto)
|
|
100
|
-
}
|
|
101
|
-
if gotHost != "node.tailnet.ts.net" {
|
|
102
|
-
t.Errorf("backend saw X-Forwarded-Host=%q, want node.tailnet.ts.net", gotHost)
|
|
103
|
-
}
|
|
104
|
-
if gotAuth != "Bearer app-token" {
|
|
105
|
-
t.Errorf("backend saw Authorization=%q, want Bearer app-token", gotAuth)
|
|
106
|
-
}
|
|
107
|
-
if gotBody != "backend-ok" {
|
|
108
|
-
t.Errorf("proxy body=%q, want backend-ok", gotBody)
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
func TestRedirectToHTTPS(t *testing.T) {
|
|
113
|
-
rec := httptest.NewRecorder()
|
|
114
|
-
req := httptest.NewRequest("GET", "http://anything/path?q=1", nil)
|
|
115
|
-
redirectToHTTPS("node.tailnet.ts.net")(rec, req)
|
|
116
|
-
if rec.Code != http.StatusPermanentRedirect {
|
|
117
|
-
t.Fatalf("status=%d, want 308", rec.Code)
|
|
118
|
-
}
|
|
119
|
-
loc := rec.Header().Get("Location")
|
|
120
|
-
if !strings.HasPrefix(loc, "https://node.tailnet.ts.net/path") || !strings.Contains(loc, "q=1") {
|
|
121
|
-
t.Fatalf("Location=%q, want https://node.tailnet.ts.net/path?q=1", loc)
|
|
122
|
-
}
|
|
123
|
-
}
|