ai-or-die 0.1.90 → 0.1.92
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 +114 -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
|
|
@@ -18,6 +19,10 @@ const MIN_RESTART_DELAY_MS = 1000;
|
|
|
18
19
|
const MAX_RESTART_DELAY_MS = 30000;
|
|
19
20
|
const MAX_RETRIES = 10;
|
|
20
21
|
const MESH_PEERS_JSON_MAX = 256 * 1024;
|
|
22
|
+
// The sidecar announces MESH-EGRESS once at spawn, but a consumer treats
|
|
23
|
+
// egress.json stale past a short TTL (~120s). Re-stamp its updatedAt on this
|
|
24
|
+
// cadence while the sidecar lives so a healthy egress never looks stale.
|
|
25
|
+
const MESH_EGRESS_REFRESH_MS = 30000;
|
|
21
26
|
|
|
22
27
|
class MeshManager {
|
|
23
28
|
constructor(options = {}) {
|
|
@@ -38,11 +43,11 @@ class MeshManager {
|
|
|
38
43
|
? path.join(localApp, 'ai-or-die')
|
|
39
44
|
: path.join(os.homedir(), '.ai-or-die');
|
|
40
45
|
this.stateDir = options.stateDir || path.join(this._appBase, 'ts-state');
|
|
41
|
-
// The sidecar
|
|
42
|
-
//
|
|
43
|
-
//
|
|
46
|
+
// The sidecar is LAUNCHED from a stable, hash-free path (ai-or-die-mesh[.exe])
|
|
47
|
+
// so a single-file WDAC/AppLocker allow-list rule matches the executed image
|
|
48
|
+
// across versions (ADR-0036). The installer verifies + replaces it in place.
|
|
44
49
|
this._installer = options._installer || require('./utils/sidecar-installer');
|
|
45
|
-
this.sidecar = options.sidecar || this.
|
|
50
|
+
this.sidecar = options.sidecar || this._stableSidecarPath(this._appBase);
|
|
46
51
|
this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
47
52
|
|
|
48
53
|
this.proc = null;
|
|
@@ -59,12 +64,18 @@ class MeshManager {
|
|
|
59
64
|
this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
|
|
60
65
|
this._stdoutBuffer = '';
|
|
61
66
|
this.peers = null;
|
|
67
|
+
this._untaggedWarned = false;
|
|
68
|
+
this._egress = null;
|
|
69
|
+
this._egressRefreshTimer = null;
|
|
62
70
|
}
|
|
63
71
|
|
|
64
72
|
/** Never throws — degrades to localhost/devtunnel on any failure. */
|
|
65
73
|
async start() {
|
|
66
74
|
console.log('\n Connecting mesh (Tailscale userspace)...');
|
|
67
75
|
this.stopping = false;
|
|
76
|
+
// Clear any egress.json left by a crashed/kill -9'd prior run BEFORE (re)spawn,
|
|
77
|
+
// so a consumer can't route to a freed loopback port a local process squatted.
|
|
78
|
+
this._deleteEgressFile();
|
|
68
79
|
if (!fs.existsSync(this.sidecar)) {
|
|
69
80
|
if (!(await this._ensureSidecar())) { this._printMissing(this._lastError); return; }
|
|
70
81
|
}
|
|
@@ -73,13 +84,12 @@ class MeshManager {
|
|
|
73
84
|
await this._spawn();
|
|
74
85
|
}
|
|
75
86
|
|
|
76
|
-
/**
|
|
77
|
-
|
|
87
|
+
/** Stable, hash-free sidecar path from the installer; safe fallback. */
|
|
88
|
+
_stableSidecarPath(base) {
|
|
78
89
|
try {
|
|
79
|
-
|
|
80
|
-
return this._installer.sidecarPath(lock.contentHash);
|
|
90
|
+
return this._installer.stableSidecarPath();
|
|
81
91
|
} catch (_) {
|
|
82
|
-
const exe = process.platform === 'win32' ? '
|
|
92
|
+
const exe = process.platform === 'win32' ? 'ai-or-die-mesh.exe' : 'ai-or-die-mesh';
|
|
83
93
|
return path.join(base, 'bin', exe);
|
|
84
94
|
}
|
|
85
95
|
}
|
|
@@ -113,6 +123,7 @@ class MeshManager {
|
|
|
113
123
|
async stop() {
|
|
114
124
|
this.stopping = true;
|
|
115
125
|
this._deletePeersFile();
|
|
126
|
+
this._deleteEgressFile();
|
|
116
127
|
this._clearStabilityTimer();
|
|
117
128
|
clearTimeout(this._restartDelayTimer);
|
|
118
129
|
if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
|
|
@@ -156,6 +167,7 @@ class MeshManager {
|
|
|
156
167
|
}
|
|
157
168
|
if (/^MESH-NEEDLOGIN\b/.test(line)) {
|
|
158
169
|
this._deletePeersFile();
|
|
170
|
+
this._deleteEgressFile();
|
|
159
171
|
this._printNotEnrolled();
|
|
160
172
|
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
161
173
|
}
|
|
@@ -164,7 +176,7 @@ class MeshManager {
|
|
|
164
176
|
this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
|
|
165
177
|
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
178
|
this.proc.on('exit', (code) => {
|
|
167
|
-
this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
|
|
179
|
+
this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile(); this._deleteEgressFile();
|
|
168
180
|
if (!this.stopping && code !== 0) this._restart();
|
|
169
181
|
});
|
|
170
182
|
});
|
|
@@ -189,7 +201,9 @@ class MeshManager {
|
|
|
189
201
|
|
|
190
202
|
_handleStdoutLine(line) {
|
|
191
203
|
try {
|
|
192
|
-
if (/^MESH-NEEDLOGIN\b/.test(line)) this._deletePeersFile();
|
|
204
|
+
if (/^MESH-NEEDLOGIN\b/.test(line)) { this._deletePeersFile(); this._deleteEgressFile(); return; }
|
|
205
|
+
if (/^MESH-EGRESS\b/.test(line)) { this._handleEgressLine(line); return; }
|
|
206
|
+
if (/^MESH-UNTAGGED\b/.test(line)) { this._handleUntaggedLine(line); return; }
|
|
193
207
|
const match = /^MESH-PEERS\s+(.+)$/.exec(line);
|
|
194
208
|
if (!match) return;
|
|
195
209
|
const raw = match[1];
|
|
@@ -206,6 +220,8 @@ class MeshManager {
|
|
|
206
220
|
const normalized = this._validatePeersPayload(parsed);
|
|
207
221
|
if (!normalized) return;
|
|
208
222
|
this.peers = normalized;
|
|
223
|
+
// Recovered: tagged peers are visible again, so re-arm the untagged warning.
|
|
224
|
+
if (normalized.peers.length > 0) this._untaggedWarned = false;
|
|
209
225
|
this._writePeersFile(normalized);
|
|
210
226
|
} catch (_) {}
|
|
211
227
|
}
|
|
@@ -249,6 +265,92 @@ class MeshManager {
|
|
|
249
265
|
try { fs.rmSync(this.peersFilePath(), { force: true }); } catch (_) {}
|
|
250
266
|
}
|
|
251
267
|
|
|
268
|
+
egressFilePath() {
|
|
269
|
+
return path.join(this._appBase, 'mesh', 'egress.json');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Parse `MESH-EGRESS <url> <token>` and persist the loopback CONNECT-proxy
|
|
273
|
+
// endpoint + local secret for a same-box conductor (github-router) to consume.
|
|
274
|
+
// Only a loopback http URL is ever accepted; the token is a local credential.
|
|
275
|
+
_handleEgressLine(line) {
|
|
276
|
+
const m = /^MESH-EGRESS\s+(\S+)\s+(\S+)\s*$/.exec(line);
|
|
277
|
+
if (!m) return;
|
|
278
|
+
const url = m[1];
|
|
279
|
+
const token = m[2];
|
|
280
|
+
let u;
|
|
281
|
+
try { u = new URL(url); } catch (_) { return; }
|
|
282
|
+
if (u.protocol !== 'http:') return;
|
|
283
|
+
const host = u.hostname.replace(/^\[/, '').replace(/\]$/, '');
|
|
284
|
+
// Only a numeric loopback literal — never `localhost`, whose resolution a
|
|
285
|
+
// consumer's DNS/hosts config could rebind off-host.
|
|
286
|
+
if (host !== '127.0.0.1' && host !== '::1') return;
|
|
287
|
+
if (!token || /\s/.test(token)) return;
|
|
288
|
+
this._egress = { url, token };
|
|
289
|
+
this._writeEgressFile(url, token);
|
|
290
|
+
this._startEgressRefresh();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
_writeEgressFile(url, token) {
|
|
294
|
+
const file = this.egressFilePath();
|
|
295
|
+
const dir = path.dirname(file);
|
|
296
|
+
// Random, exclusive-create tmp so a pre-planted symlink at a predictable path
|
|
297
|
+
// can't redirect the write (TOCTOU / symlink clobber).
|
|
298
|
+
const tmp = path.join(dir, `egress.json.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
299
|
+
try {
|
|
300
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
301
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
302
|
+
version: 1,
|
|
303
|
+
pid: this.proc ? this.proc.pid : process.pid,
|
|
304
|
+
updatedAt: Date.now(),
|
|
305
|
+
url,
|
|
306
|
+
token,
|
|
307
|
+
}), { mode: 0o600, flag: 'wx' });
|
|
308
|
+
fs.chmodSync(tmp, 0o600);
|
|
309
|
+
fs.renameSync(tmp, file);
|
|
310
|
+
} catch (_) {
|
|
311
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) {}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Keep egress.json's updatedAt fresh while the sidecar is alive. Without this a
|
|
316
|
+
// consumer's freshness TTL rejects the (write-once) egress after ~2 min of
|
|
317
|
+
// uptime and the mesh silently fails closed. Stops itself once the sidecar exits.
|
|
318
|
+
_startEgressRefresh() {
|
|
319
|
+
if (this._egressRefreshTimer) return;
|
|
320
|
+
this._egressRefreshTimer = setInterval(() => this._refreshEgressTick(), MESH_EGRESS_REFRESH_MS);
|
|
321
|
+
if (this._egressRefreshTimer.unref) this._egressRefreshTimer.unref();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_refreshEgressTick() {
|
|
325
|
+
if (this.proc && this._egress) this._writeEgressFile(this._egress.url, this._egress.token);
|
|
326
|
+
else this._stopEgressRefresh();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
_stopEgressRefresh() {
|
|
330
|
+
if (this._egressRefreshTimer) { clearInterval(this._egressRefreshTimer); this._egressRefreshTimer = null; }
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
_deleteEgressFile() {
|
|
334
|
+
this._egress = null;
|
|
335
|
+
this._stopEgressRefresh();
|
|
336
|
+
try { fs.rmSync(this.egressFilePath(), { force: true }); } catch (_) {}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// Actionable one-shot hint: tailnet peers exist but none carry tag:aiordie, so
|
|
340
|
+
// fleet discovery will surface nothing until they are tagged. The exact failure
|
|
341
|
+
// this whole path guards against — surfaced, not silent.
|
|
342
|
+
_handleUntaggedLine(line) {
|
|
343
|
+
if (this._untaggedWarned) return;
|
|
344
|
+
const m = /^MESH-UNTAGGED\s+(\S+)\s+(\d+)\s+(\d+)\s*$/.exec(line);
|
|
345
|
+
if (!m) return;
|
|
346
|
+
this._untaggedWarned = true;
|
|
347
|
+
const total = m[2];
|
|
348
|
+
console.log(`\n \x1b[33mMesh: ${total} tailnet device(s) visible but \x1b[1m0 tagged tag:aiordie\x1b[0m\x1b[33m.\x1b[0m`);
|
|
349
|
+
console.log(' Fleet discovery stays EMPTY until instances carry the tag:');
|
|
350
|
+
console.log(' • enroll with a REUSABLE + TAGGED key (tag:aiordie), or retag each device in the admin console;');
|
|
351
|
+
console.log(' • ensure the ACL allows your conductor → tag:aiordie on the served ports (docs/mesh-acl.example.hujson).');
|
|
352
|
+
}
|
|
353
|
+
|
|
252
354
|
_printMissing(err) {
|
|
253
355
|
const code = err && err.code;
|
|
254
356
|
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
|
-
}
|