ai-or-die 0.1.87 → 0.1.89
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/bin/ai-or-die.js +15 -4
- package/mesh/main.go +288 -12
- package/mesh/main_test.go +123 -0
- package/mesh-sidecar.lock.json +12 -0
- package/package.json +3 -1
- package/src/control/routes.js +4 -0
- package/src/mesh-manager.js +165 -29
- package/src/server.js +1 -0
- package/src/utils/sidecar-installer.js +110 -47
package/bin/ai-or-die.js
CHANGED
|
@@ -106,7 +106,12 @@ async function main() {
|
|
|
106
106
|
port,
|
|
107
107
|
auth: authToken,
|
|
108
108
|
noAuth: noAuth,
|
|
109
|
-
|
|
109
|
+
// Mesh terminates real TLS at the tailnet edge (the sidecar serves the
|
|
110
|
+
// <host>.ts.net cert) and reverse-proxies to a loopback backend, so in
|
|
111
|
+
// mesh mode the local server stays plain HTTP on 127.0.0.1 regardless of
|
|
112
|
+
// --https. (Mesh forces a loopback-only bind anyway, so --https's LAN
|
|
113
|
+
// secure-context role does not apply.) --https WITHOUT --mesh is unchanged.
|
|
114
|
+
https: options.mesh ? false : options.https,
|
|
110
115
|
cert: options.cert,
|
|
111
116
|
key: options.key,
|
|
112
117
|
dev: options.dev,
|
|
@@ -158,7 +163,7 @@ async function main() {
|
|
|
158
163
|
const app = new ClaudeCodeWebServer(serverOptions);
|
|
159
164
|
await app.start();
|
|
160
165
|
|
|
161
|
-
const protocol =
|
|
166
|
+
const protocol = serverOptions.https ? 'https' : 'http';
|
|
162
167
|
const baseUrl = `${protocol}://localhost:${port}`;
|
|
163
168
|
// For localhost with auth, embed token in URL so user can just click it
|
|
164
169
|
const url = authToken ? `${baseUrl}?token=${authToken}` : baseUrl;
|
|
@@ -168,8 +173,10 @@ async function main() {
|
|
|
168
173
|
console.log(` Auth token: \x1b[1m\x1b[33m${authToken}\x1b[0m`);
|
|
169
174
|
}
|
|
170
175
|
|
|
171
|
-
// Warn if STT is enabled without HTTPS or tunnel
|
|
172
|
-
|
|
176
|
+
// Warn if STT is enabled without HTTPS or tunnel. Skip in mesh mode: it
|
|
177
|
+
// binds loopback-only (no LAN), the mic works on http://localhost locally,
|
|
178
|
+
// and remotely via the mesh edge's real TLS.
|
|
179
|
+
if ((serverOptions.stt || serverOptions.sttEndpoint) && !options.https && !options.tunnel && !options.mesh) {
|
|
173
180
|
console.log('\n\x1b[33m⚠ STT enabled over plain HTTP \u2014 microphone only works on localhost.\x1b[0m');
|
|
174
181
|
console.log(' For LAN access, restart with \x1b[1m--https\x1b[0m or \x1b[1m--tunnel\x1b[0m.');
|
|
175
182
|
}
|
|
@@ -202,10 +209,14 @@ async function main() {
|
|
|
202
209
|
// Mesh: permanent Tailscale reachability. Coexists with --tunnel (mesh for
|
|
203
210
|
// owned devices, tunnel fallback for borrowed ones). Auth token stays ON.
|
|
204
211
|
if (options.mesh) {
|
|
212
|
+
if (options.https) {
|
|
213
|
+
console.log('\n \x1b[33mNote: --https is handled by the mesh edge (real .ts.net TLS); the local server stays HTTP on loopback.\x1b[0m');
|
|
214
|
+
}
|
|
205
215
|
const { MeshManager } = require('../src/mesh-manager');
|
|
206
216
|
const mesh = new MeshManager({
|
|
207
217
|
port,
|
|
208
218
|
dev: options.dev,
|
|
219
|
+
authToken,
|
|
209
220
|
onUrl: (meshUrl) => {
|
|
210
221
|
const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
|
|
211
222
|
console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
|
package/mesh/main.go
CHANGED
|
@@ -5,32 +5,76 @@
|
|
|
5
5
|
// rest of the machine never joins. Enrollment is one-time via TS_AUTHKEY; the
|
|
6
6
|
// node identity persists in --statedir so the key is never needed again.
|
|
7
7
|
//
|
|
8
|
+
// TLS: when the tailnet has HTTPS certificates enabled, the sidecar terminates
|
|
9
|
+
// real `<host>.ts.net` TLS at the edge (so the advertised https:// URL is a
|
|
10
|
+
// genuine browser secure context — remote mic/PWA work) and reverse-proxies to
|
|
11
|
+
// the loopback backend. When certs are unavailable it degrades to plain http on
|
|
12
|
+
// :80 and says so. The scheme is decided BEFORE the URL is advertised, never
|
|
13
|
+
// after, because tsnet provisions the cert lazily inside the TLS handshake — a
|
|
14
|
+
// post-advertise failure would otherwise hang the browser.
|
|
15
|
+
//
|
|
8
16
|
// stdout protocol (parsed by src/mesh-manager.js):
|
|
9
|
-
// MESH-URL https://<name> node is up
|
|
17
|
+
// MESH-URL https://<name> node is up, serving real TLS at the edge
|
|
18
|
+
// MESH-URL http://<name> node is up, but TLS certs unavailable (degraded)
|
|
19
|
+
// MESH-NOCERT hint: enable HTTPS Certificates in the tailnet
|
|
20
|
+
// MESH-PEERS {"self":...,"peers":[...]} tagged fleet peers snapshot
|
|
10
21
|
// MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
|
|
11
|
-
// MESH-ERR <msg>
|
|
22
|
+
// MESH-ERR <msg> fatal
|
|
12
23
|
package main
|
|
13
24
|
|
|
14
25
|
import (
|
|
15
26
|
"context"
|
|
27
|
+
"crypto/tls"
|
|
28
|
+
"encoding/json"
|
|
16
29
|
"flag"
|
|
17
30
|
"fmt"
|
|
31
|
+
"net"
|
|
18
32
|
"net/http"
|
|
19
33
|
"net/http/httputil"
|
|
20
34
|
"net/url"
|
|
21
35
|
"os"
|
|
36
|
+
"sort"
|
|
22
37
|
"strings"
|
|
23
38
|
"time"
|
|
24
39
|
|
|
40
|
+
"tailscale.com/ipn/ipnstate"
|
|
25
41
|
"tailscale.com/tsnet"
|
|
26
42
|
)
|
|
27
43
|
|
|
44
|
+
// version is stamped at build time via -ldflags "-X main.version=<contentHash>".
|
|
45
|
+
var version = "dev"
|
|
46
|
+
|
|
47
|
+
const (
|
|
48
|
+
certWaitTimeout = 20 * time.Second
|
|
49
|
+
meshPeersInterval = 20 * time.Second
|
|
50
|
+
meshPeersMaxPeers = 512
|
|
51
|
+
meshPeerTag = "tag:aiordie"
|
|
52
|
+
)
|
|
53
|
+
|
|
28
54
|
func main() {
|
|
29
|
-
port := flag.String("port", "7777", "local ai-or-die port to expose")
|
|
55
|
+
port := flag.String("port", "7777", "local ai-or-die port to expose (loopback)")
|
|
56
|
+
backend := flag.String("backend", "", "full backend URL to proxy to (overrides --port); must be loopback")
|
|
30
57
|
host := flag.String("hostname", "aiordie", "tailnet hostname")
|
|
31
58
|
dir := flag.String("statedir", "./ts-state", "persistent node state dir")
|
|
59
|
+
showVersion := flag.Bool("version", false, "print the build version (content hash) and exit")
|
|
32
60
|
flag.Parse()
|
|
33
61
|
|
|
62
|
+
if *showVersion {
|
|
63
|
+
fmt.Println(version)
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
proxyBearer := os.Getenv("AIORDIE_PROXY_BEARER")
|
|
68
|
+
|
|
69
|
+
// Resolve + validate the backend target. It MUST be loopback: the sidecar is
|
|
70
|
+
// a tailnet-facing reverse proxy, and pointing it off-host would expose an
|
|
71
|
+
// arbitrary origin to the whole tailnet.
|
|
72
|
+
target, err := resolveBackend(*backend, *port)
|
|
73
|
+
if err != nil {
|
|
74
|
+
fmt.Printf("MESH-ERR %v\n", err)
|
|
75
|
+
os.Exit(1)
|
|
76
|
+
}
|
|
77
|
+
|
|
34
78
|
s := &tsnet.Server{Dir: *dir, Hostname: *host}
|
|
35
79
|
// Surface the login URL exactly once instead of tsnet's default stderr spam.
|
|
36
80
|
s.AuthKey = os.Getenv("TS_AUTHKEY")
|
|
@@ -39,27 +83,259 @@ func main() {
|
|
|
39
83
|
// Bring the node up; report the enroll URL if it needs login.
|
|
40
84
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
41
85
|
defer cancel()
|
|
42
|
-
|
|
86
|
+
st, err := s.Up(ctx)
|
|
87
|
+
if err != nil {
|
|
43
88
|
// Up blocks until authed; on timeout it's almost always missing/!key.
|
|
44
89
|
fmt.Printf("MESH-NEEDLOGIN https://login.tailscale.com/admin/settings/keys\n")
|
|
45
90
|
fmt.Printf("MESH-ERR %v\n", err)
|
|
46
91
|
os.Exit(1)
|
|
47
92
|
}
|
|
48
93
|
|
|
94
|
+
if lc, err := s.LocalClient(); err == nil {
|
|
95
|
+
go emitMeshPeersLoop(context.Background(), lc)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
name := *host
|
|
99
|
+
if st != nil && st.Self != nil && st.Self.DNSName != "" {
|
|
100
|
+
name = strings.TrimSuffix(st.Self.DNSName, ".")
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// One reverse proxy; the edge scheme it advertises to the backend is decided
|
|
104
|
+
// just below and captured by pointer so the X-Forwarded-Proto header is
|
|
105
|
+
// always accurate. The terminal client derives ws/wss from window.location,
|
|
106
|
+
// so wss works regardless; this header is hygiene for any absolute-URL or
|
|
107
|
+
// redirect the app generates (no mixed content).
|
|
108
|
+
edgeProto := "http"
|
|
109
|
+
rp := newEdgeProxy(target, name, &edgeProto, proxyBearer)
|
|
110
|
+
|
|
111
|
+
// Decide the scheme BEFORE advertising. tsnet's ListenTLS returns a listener
|
|
112
|
+
// without provisioning the cert; the ACME work (and any failure) happens
|
|
113
|
+
// lazily inside the first TLS handshake. So we probe cert readiness up front
|
|
114
|
+
// and only advertise https:// when we are confident the handshake will work.
|
|
115
|
+
if tlsLn := tryListenTLS(ctx, s, name); tlsLn != nil {
|
|
116
|
+
edgeProto = "https"
|
|
117
|
+
// Best-effort :80 -> https redirect so a bare http:// hit is upgraded.
|
|
118
|
+
if httpLn, e := s.Listen("tcp", ":80"); e == nil {
|
|
119
|
+
go http.Serve(httpLn, redirectToHTTPS(name))
|
|
120
|
+
}
|
|
121
|
+
fmt.Printf("MESH-URL https://%s\n", name)
|
|
122
|
+
// http.Serve only returns on failure; exit non-zero so the manager
|
|
123
|
+
// (which restarts on a non-zero exit) brings the sidecar back.
|
|
124
|
+
err := http.Serve(tlsLn, rp)
|
|
125
|
+
fmt.Printf("MESH-ERR %v\n", err)
|
|
126
|
+
os.Exit(1)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Degraded: no usable cert. Serve plain http and say so. http://<name>.ts.net
|
|
130
|
+
// is NOT a browser secure context, so remote mic/PWA will not work until the
|
|
131
|
+
// operator enables HTTPS Certificates in the tailnet admin console.
|
|
49
132
|
ln, err := s.Listen("tcp", ":80")
|
|
50
133
|
if err != nil {
|
|
51
134
|
fmt.Printf("MESH-ERR %v\n", err)
|
|
52
135
|
os.Exit(1)
|
|
53
136
|
}
|
|
54
|
-
|
|
55
|
-
|
|
137
|
+
fmt.Printf("MESH-NOCERT\n")
|
|
138
|
+
fmt.Printf("MESH-URL http://%s\n", name)
|
|
139
|
+
serveErr := http.Serve(ln, rp)
|
|
140
|
+
fmt.Printf("MESH-ERR %v\n", serveErr)
|
|
141
|
+
os.Exit(1)
|
|
142
|
+
}
|
|
56
143
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
144
|
+
// resolveBackend builds the proxy target from --backend (preferred) or --port,
|
|
145
|
+
// and rejects anything that is not an http(s) loopback origin.
|
|
146
|
+
func resolveBackend(backend, port string) (*url.URL, error) {
|
|
147
|
+
raw := backend
|
|
148
|
+
if raw == "" {
|
|
149
|
+
raw = "http://127.0.0.1:" + port
|
|
150
|
+
}
|
|
151
|
+
u, err := url.Parse(raw)
|
|
152
|
+
if err != nil {
|
|
153
|
+
return nil, fmt.Errorf("invalid --backend %q: %v", raw, err)
|
|
154
|
+
}
|
|
155
|
+
if u.Scheme != "http" && u.Scheme != "https" {
|
|
156
|
+
return nil, fmt.Errorf("backend scheme must be http/https, got %q", u.Scheme)
|
|
157
|
+
}
|
|
158
|
+
if !isLoopbackHost(u.Hostname()) {
|
|
159
|
+
return nil, fmt.Errorf("backend host must be loopback (127.0.0.1/::1/localhost), got %q", u.Hostname())
|
|
160
|
+
}
|
|
161
|
+
// Pin "localhost" to a numeric loopback so the later proxy dial cannot be
|
|
162
|
+
// redirected off-host by a poisoned resolver / hosts file.
|
|
163
|
+
if u.Hostname() == "localhost" {
|
|
164
|
+
if p := u.Port(); p != "" {
|
|
165
|
+
u.Host = "127.0.0.1:" + p
|
|
166
|
+
} else {
|
|
167
|
+
u.Host = "127.0.0.1"
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return u, nil
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
func isLoopbackHost(h string) bool {
|
|
174
|
+
if h == "localhost" {
|
|
175
|
+
return true
|
|
176
|
+
}
|
|
177
|
+
ip := net.ParseIP(h)
|
|
178
|
+
return ip != nil && ip.IsLoopback()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type statusClient interface {
|
|
182
|
+
Status(context.Context) (*ipnstate.Status, error)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
type meshPeersSnapshot struct {
|
|
186
|
+
Self meshPeerSelf `json:"self"`
|
|
187
|
+
Peers []meshPeer `json:"peers"`
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
type meshPeerSelf struct {
|
|
191
|
+
Hostname string `json:"hostname"`
|
|
192
|
+
DNSName string `json:"dnsName"`
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
type meshPeer struct {
|
|
196
|
+
Hostname string `json:"hostname"`
|
|
197
|
+
DNSName string `json:"dnsName"`
|
|
198
|
+
Online bool `json:"online"`
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
func emitMeshPeersLoop(ctx context.Context, lc statusClient) {
|
|
202
|
+
emitMeshPeers(ctx, lc)
|
|
203
|
+
ticker := time.NewTicker(meshPeersInterval)
|
|
204
|
+
defer ticker.Stop()
|
|
205
|
+
for {
|
|
206
|
+
select {
|
|
207
|
+
case <-ctx.Done():
|
|
208
|
+
return
|
|
209
|
+
case <-ticker.C:
|
|
210
|
+
emitMeshPeers(ctx, lc)
|
|
211
|
+
}
|
|
61
212
|
}
|
|
62
|
-
|
|
213
|
+
}
|
|
63
214
|
|
|
64
|
-
|
|
215
|
+
func emitMeshPeers(ctx context.Context, lc statusClient) {
|
|
216
|
+
status, err := lc.Status(ctx)
|
|
217
|
+
if err != nil || status == nil {
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
line := meshPeersFromStatus(status)
|
|
221
|
+
b, err := json.Marshal(line)
|
|
222
|
+
if err != nil {
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
fmt.Printf("MESH-PEERS %s\n", b)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
func meshPeersFromStatus(status *ipnstate.Status) meshPeersSnapshot {
|
|
229
|
+
var self meshPeerSelf
|
|
230
|
+
if status.Self != nil {
|
|
231
|
+
self = meshPeerSelf{Hostname: status.Self.HostName, DNSName: normalizeDNSName(status.Self.DNSName)}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
peers := make([]meshPeer, 0)
|
|
235
|
+
for _, ps := range status.Peer {
|
|
236
|
+
if ps == nil || !peerHasTag(ps, meshPeerTag) {
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
peers = append(peers, meshPeer{Hostname: ps.HostName, DNSName: normalizeDNSName(ps.DNSName), Online: ps.Online})
|
|
240
|
+
}
|
|
241
|
+
sort.Slice(peers, func(i, j int) bool {
|
|
242
|
+
if peers[i].DNSName != peers[j].DNSName {
|
|
243
|
+
return peers[i].DNSName < peers[j].DNSName
|
|
244
|
+
}
|
|
245
|
+
return peers[i].Hostname < peers[j].Hostname
|
|
246
|
+
})
|
|
247
|
+
if len(peers) > meshPeersMaxPeers {
|
|
248
|
+
peers = peers[:meshPeersMaxPeers]
|
|
249
|
+
}
|
|
250
|
+
return meshPeersSnapshot{Self: self, Peers: peers}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func peerHasTag(ps *ipnstate.PeerStatus, tag string) bool {
|
|
254
|
+
if ps.Tags == nil {
|
|
255
|
+
return false
|
|
256
|
+
}
|
|
257
|
+
for i := 0; i < ps.Tags.Len(); i++ {
|
|
258
|
+
if ps.Tags.At(i) == tag {
|
|
259
|
+
return true
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return false
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
func normalizeDNSName(s string) string {
|
|
266
|
+
return strings.ToLower(strings.TrimSuffix(s, "."))
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// tryListenTLS returns a TLS listener on :443 only when a cert for `name` looks
|
|
270
|
+
// obtainable; otherwise nil. It first checks the tailnet's advertised cert
|
|
271
|
+
// domains, then proactively warms the cert with a bounded timeout so a failure
|
|
272
|
+
// surfaces here (returning nil) instead of mid-handshake later.
|
|
273
|
+
func tryListenTLS(ctx context.Context, s *tsnet.Server, name string) net.Listener {
|
|
274
|
+
lc, err := s.LocalClient()
|
|
275
|
+
if err != nil {
|
|
276
|
+
return nil
|
|
277
|
+
}
|
|
278
|
+
status, err := lc.Status(ctx)
|
|
279
|
+
if err != nil || status == nil {
|
|
280
|
+
return nil
|
|
281
|
+
}
|
|
282
|
+
eligible := false
|
|
283
|
+
for _, d := range status.CertDomains {
|
|
284
|
+
if d == name {
|
|
285
|
+
eligible = true
|
|
286
|
+
break
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if !eligible {
|
|
290
|
+
return nil
|
|
291
|
+
}
|
|
292
|
+
// Proactively obtain the cert with a bounded timeout. If the tailnet says the
|
|
293
|
+
// domain is eligible but issuance still fails (HTTPS not actually enabled,
|
|
294
|
+
// ACME hiccup), we learn it now and fall back to http rather than hanging the
|
|
295
|
+
// first browser request.
|
|
296
|
+
warmCtx, cancel := context.WithTimeout(ctx, certWaitTimeout)
|
|
297
|
+
defer cancel()
|
|
298
|
+
if _, _, err := lc.CertPair(warmCtx, name); err != nil {
|
|
299
|
+
return nil
|
|
300
|
+
}
|
|
301
|
+
ln, err := s.ListenTLS("tcp", ":443")
|
|
302
|
+
if err != nil {
|
|
303
|
+
return nil
|
|
304
|
+
}
|
|
305
|
+
return ln
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
func redirectToHTTPS(name string) http.HandlerFunc {
|
|
309
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
310
|
+
u := "https://" + name + r.URL.RequestURI()
|
|
311
|
+
http.Redirect(w, r, u, http.StatusPermanentRedirect)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// newEdgeProxy builds the reverse proxy to the loopback backend. It stamps
|
|
316
|
+
// X-Forwarded-Proto (read through *proto, set after the TLS decision) and
|
|
317
|
+
// X-Forwarded-Host so the app never emits mixed-content links. For an https
|
|
318
|
+
// loopback backend (a future self-signed local listener) it skips verification —
|
|
319
|
+
// the host is already validated as loopback, so this is not a trust boundary.
|
|
320
|
+
func newEdgeProxy(target *url.URL, name string, proto *string, proxyBearer string) *httputil.ReverseProxy {
|
|
321
|
+
rp := httputil.NewSingleHostReverseProxy(target)
|
|
322
|
+
orig := rp.Director
|
|
323
|
+
rp.Director = func(r *http.Request) {
|
|
324
|
+
orig(r)
|
|
325
|
+
r.Header.Set("X-Forwarded-Proto", *proto)
|
|
326
|
+
r.Header.Set("X-Forwarded-Host", name)
|
|
327
|
+
// Authenticate tailnet->loopback traffic to the app's bearer middleware.
|
|
328
|
+
// Set REPLACES any client-supplied Authorization (no spoofing). When no
|
|
329
|
+
// token is configured, DELETE a client-supplied Authorization so a tailnet
|
|
330
|
+
// caller can never smuggle one through to the loopback app.
|
|
331
|
+
if proxyBearer != "" {
|
|
332
|
+
r.Header.Set("Authorization", "Bearer "+proxyBearer)
|
|
333
|
+
} else {
|
|
334
|
+
r.Header.Del("Authorization")
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
if target.Scheme == "https" {
|
|
338
|
+
rp.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
|
339
|
+
}
|
|
340
|
+
return rp
|
|
65
341
|
}
|
|
@@ -0,0 +1,123 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"contentHash": "dbf60b5215ebb0b0947edf8fa47906280dd1fea12ef7e1181918fecfe7d9bcc0",
|
|
4
|
+
"assets": {
|
|
5
|
+
"aiordie-mesh-windows-amd64.exe": "3be314f27ddf69560cb34b40b70302609f28fe6feba7600580e68ce04a811a71",
|
|
6
|
+
"aiordie-mesh-windows-arm64.exe": "3169f41dc835522c42dc4766dcd9a608a65430fb3797120bcd1ae9e58014942c",
|
|
7
|
+
"aiordie-mesh-linux-amd64": "7fb0d9c624c85759fb20e789886d1f4e0e2b5621f8ee6105634c7e73fd44d049",
|
|
8
|
+
"aiordie-mesh-linux-arm64": "7d23eb2d8c4c9632f37ad8f11f8c8523ce7d6ce58d0607b4c73fb9ccbc261239",
|
|
9
|
+
"aiordie-mesh-darwin-amd64": "69336be9137a98d8a3409ea87702502fa82d573875041111ea46f65b2fd22442",
|
|
10
|
+
"aiordie-mesh-darwin-arm64": "f73bcb5a6107ac3c0da8a39f06af6620e2db6e66b4e3c135740d9081703fd938"
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ai-or-die",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.89",
|
|
4
4
|
"description": "Universal AI coding terminal — Claude, Copilot, Gemini & more in your browser",
|
|
5
5
|
"main": "src/server.js",
|
|
6
6
|
"bin": {
|
|
@@ -22,6 +22,8 @@
|
|
|
22
22
|
"test:longevity": "npm run test:longevity:server && npm run test:longevity:browser",
|
|
23
23
|
"build:bundle": "node scripts/build-sea.js bundle",
|
|
24
24
|
"build:sea": "node scripts/build-sea.js",
|
|
25
|
+
"mesh:lock": "node scripts/mesh-lock.js",
|
|
26
|
+
"mesh:lock:check": "node scripts/mesh-lock.js --check",
|
|
25
27
|
"release:pr": "bash scripts/release-pr.sh"
|
|
26
28
|
},
|
|
27
29
|
"keywords": [
|
package/src/control/routes.js
CHANGED
|
@@ -187,6 +187,10 @@ function createControlRouter(deps) {
|
|
|
187
187
|
}
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
router.get('/mesh/peers', (req, res) => {
|
|
191
|
+
res.json({ peers: deps.getMeshPeers ? deps.getMeshPeers() : [] });
|
|
192
|
+
});
|
|
193
|
+
|
|
190
194
|
// GET /snapshot — F15 atomic batch resync. Returns every session's derived
|
|
191
195
|
// status PLUS the event cursor, captured atomically (cursor first, then
|
|
192
196
|
// statuses), so after a gap/overflow the controller resyncs in ONE call and
|
package/src/mesh-manager.js
CHANGED
|
@@ -17,29 +17,39 @@ const STABILITY_THRESHOLD_MS = 60000; // 60s up = reset retry budget
|
|
|
17
17
|
const MIN_RESTART_DELAY_MS = 1000;
|
|
18
18
|
const MAX_RESTART_DELAY_MS = 30000;
|
|
19
19
|
const MAX_RETRIES = 10;
|
|
20
|
+
const MESH_PEERS_JSON_MAX = 256 * 1024;
|
|
20
21
|
|
|
21
22
|
class MeshManager {
|
|
22
23
|
constructor(options = {}) {
|
|
23
24
|
this.port = options.port || 7777; // the ai-or-die port to expose
|
|
24
25
|
this.dev = options.dev || false;
|
|
25
26
|
this.onUrl = options.onUrl || (() => {});
|
|
26
|
-
|
|
27
|
+
this._proxyBearer = options.authToken || null;
|
|
28
|
+
// Consume the key once and scrub secrets everywhere they could leak.
|
|
27
29
|
this._authKey = options.authKey || process.env.AIORDIE_TS_AUTHKEY || null;
|
|
28
30
|
delete process.env.AIORDIE_TS_AUTHKEY;
|
|
31
|
+
delete process.env.AIORDIE_PROXY_BEARER;
|
|
29
32
|
this._childEnv = { ...process.env };
|
|
30
33
|
delete this._childEnv.AIORDIE_TS_AUTHKEY;
|
|
34
|
+
delete this._childEnv.AIORDIE_PROXY_BEARER;
|
|
31
35
|
|
|
32
36
|
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
33
|
-
|
|
37
|
+
this._appBase = process.platform === 'win32'
|
|
34
38
|
? path.join(localApp, 'ai-or-die')
|
|
35
39
|
: path.join(os.homedir(), '.ai-or-die');
|
|
36
|
-
this.stateDir = options.stateDir || path.join(
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
this.stateDir = options.stateDir || path.join(this._appBase, 'ts-state');
|
|
41
|
+
// The sidecar binary is content-addressed: its path embeds the lock's
|
|
42
|
+
// contentHash so a new build installs alongside the old one (no overwrite of
|
|
43
|
+
// a running .exe). Derived from the installer's lock + path helpers.
|
|
44
|
+
this._installer = options._installer || require('./utils/sidecar-installer');
|
|
45
|
+
this.sidecar = options.sidecar || this._sidecarPathFromLock(this._appBase);
|
|
39
46
|
this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
40
47
|
|
|
41
48
|
this.proc = null;
|
|
42
49
|
this.dnsName = null;
|
|
50
|
+
this.scheme = null; // 'https' (edge TLS) or 'http' (degraded)
|
|
51
|
+
this.backend = options.backend || `http://127.0.0.1:${this.port}`; // mesh serves plaintext loopback
|
|
52
|
+
this._lastError = null;
|
|
43
53
|
this.stopping = false;
|
|
44
54
|
this.retryCount = 0;
|
|
45
55
|
this._totalRestarts = 0;
|
|
@@ -47,6 +57,8 @@ class MeshManager {
|
|
|
47
57
|
this._restartDelayTimer = null;
|
|
48
58
|
this._restartDelayResolve = null;
|
|
49
59
|
this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
|
|
60
|
+
this._stdoutBuffer = '';
|
|
61
|
+
this.peers = null;
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
/** Never throws — degrades to localhost/devtunnel on any failure. */
|
|
@@ -54,34 +66,53 @@ class MeshManager {
|
|
|
54
66
|
console.log('\n Connecting mesh (Tailscale userspace)...');
|
|
55
67
|
this.stopping = false;
|
|
56
68
|
if (!fs.existsSync(this.sidecar)) {
|
|
57
|
-
if (!(await this._ensureSidecar())) { this._printMissing(); return; }
|
|
69
|
+
if (!(await this._ensureSidecar())) { this._printMissing(this._lastError); return; }
|
|
58
70
|
}
|
|
59
71
|
if (!this._authKey && !this._enrolled()) { this._printNotEnrolled(); return; }
|
|
60
72
|
try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch (_) {}
|
|
61
73
|
await this._spawn();
|
|
62
74
|
}
|
|
63
75
|
|
|
76
|
+
/** Content-addressed sidecar path from the committed lock; safe fallback. */
|
|
77
|
+
_sidecarPathFromLock(base) {
|
|
78
|
+
try {
|
|
79
|
+
const lock = this._installer.loadLock();
|
|
80
|
+
return this._installer.sidecarPath(lock.contentHash);
|
|
81
|
+
} catch (_) {
|
|
82
|
+
const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
|
|
83
|
+
return path.join(base, 'bin', exe);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
64
87
|
/** Download + verify the sidecar from the matching release. Best-effort. */
|
|
65
88
|
async _ensureSidecar() {
|
|
66
89
|
try {
|
|
67
|
-
const { ensureSidecar } = require('./utils/sidecar-installer');
|
|
68
|
-
let version = '0.0.0';
|
|
69
|
-
try { version = require('../package.json').version; } catch (_) {}
|
|
70
90
|
console.log(' [mesh] fetching sidecar binary...');
|
|
71
|
-
await ensureSidecar(
|
|
91
|
+
await this._installer.ensureSidecar(this.sidecar);
|
|
92
|
+
this._lastError = null;
|
|
72
93
|
return fs.existsSync(this.sidecar);
|
|
73
94
|
} catch (e) {
|
|
95
|
+
this._lastError = e;
|
|
74
96
|
if (this.dev) console.error(' [mesh] sidecar fetch failed:', e.message);
|
|
75
97
|
return false;
|
|
76
98
|
}
|
|
77
99
|
}
|
|
78
100
|
|
|
79
101
|
getStatus() {
|
|
80
|
-
return {
|
|
102
|
+
return {
|
|
103
|
+
running: this.proc !== null && !this.stopping && !!this.dnsName,
|
|
104
|
+
publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null,
|
|
105
|
+
peers: this.peers ? this.peers.peers : [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
peersFilePath() {
|
|
110
|
+
return path.join(this._appBase, 'mesh', 'peers.json');
|
|
81
111
|
}
|
|
82
112
|
|
|
83
113
|
async stop() {
|
|
84
114
|
this.stopping = true;
|
|
115
|
+
this._deletePeersFile();
|
|
85
116
|
this._clearStabilityTimer();
|
|
86
117
|
clearTimeout(this._restartDelayTimer);
|
|
87
118
|
if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
|
|
@@ -100,40 +131,145 @@ class MeshManager {
|
|
|
100
131
|
|
|
101
132
|
_spawn() {
|
|
102
133
|
return new Promise((resolve) => {
|
|
103
|
-
const args = ['--port', String(this.port), '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
104
|
-
// Pass
|
|
105
|
-
//
|
|
106
|
-
const env =
|
|
134
|
+
const args = ['--port', String(this.port), '--backend', this.backend, '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
135
|
+
// Pass secrets only to the sidecar child; both are stripped from this
|
|
136
|
+
// process + base child env before spawning.
|
|
137
|
+
const env = { ...this._childEnv };
|
|
138
|
+
if (this._authKey) env.TS_AUTHKEY = this._authKey;
|
|
139
|
+
if (this._proxyBearer) env.AIORDIE_PROXY_BEARER = this._proxyBearer;
|
|
140
|
+
this._stdoutBuffer = '';
|
|
107
141
|
this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
108
142
|
let done = false;
|
|
109
143
|
const timer = setTimeout(() => { if (!done) { done = true; console.warn(' \x1b[33mMesh: no URL within 60s — check key/connectivity.\x1b[0m'); resolve(); } }, URL_TIMEOUT_MS);
|
|
110
144
|
this.proc.stdout.on('data', (d) => {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.dnsName
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
145
|
+
if (this.dev) process.stdout.write(` [mesh] ${d}`);
|
|
146
|
+
this._handleStdoutData(d, (line) => {
|
|
147
|
+
if (/^MESH-NOCERT\b/.test(line)) this._printNoCert();
|
|
148
|
+
const url = line.match(/^MESH-URL (https?):\/\/(\S+)/);
|
|
149
|
+
if (url && !this.dnsName) {
|
|
150
|
+
this.scheme = url[1];
|
|
151
|
+
this.dnsName = url[2];
|
|
152
|
+
this._authKey = null;
|
|
153
|
+
this._startStabilityTimer();
|
|
154
|
+
this.onUrl(`${this.scheme}://${this.dnsName}`);
|
|
155
|
+
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
156
|
+
}
|
|
157
|
+
if (/^MESH-NEEDLOGIN\b/.test(line)) {
|
|
158
|
+
this._deletePeersFile();
|
|
159
|
+
this._printNotEnrolled();
|
|
160
|
+
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
161
|
+
}
|
|
162
|
+
});
|
|
122
163
|
});
|
|
123
164
|
this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
|
|
124
165
|
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(); } });
|
|
125
166
|
this.proc.on('exit', (code) => {
|
|
126
|
-
this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
|
|
167
|
+
this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
|
|
127
168
|
if (!this.stopping && code !== 0) this._restart();
|
|
128
169
|
});
|
|
129
170
|
});
|
|
130
171
|
}
|
|
131
172
|
|
|
132
|
-
|
|
133
|
-
|
|
173
|
+
_handleStdoutData(chunk, onLine) {
|
|
174
|
+
try {
|
|
175
|
+
this._stdoutBuffer += chunk.toString();
|
|
176
|
+
const lines = this._stdoutBuffer.split('\n');
|
|
177
|
+
this._stdoutBuffer = lines.pop() || '';
|
|
178
|
+
// Bound the carry-over: a newline-less run longer than any legitimate line
|
|
179
|
+
// (MESH-PEERS is capped well under this) is junk/log-spam — drop it so a
|
|
180
|
+
// misbehaving child can't grow the buffer without limit.
|
|
181
|
+
if (this._stdoutBuffer.length > MESH_PEERS_JSON_MAX) this._stdoutBuffer = '';
|
|
182
|
+
for (const rawLine of lines) {
|
|
183
|
+
const line = rawLine.replace(/\r$/, '');
|
|
184
|
+
this._handleStdoutLine(line);
|
|
185
|
+
if (onLine) onLine(line);
|
|
186
|
+
}
|
|
187
|
+
} catch (_) {}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
_handleStdoutLine(line) {
|
|
191
|
+
try {
|
|
192
|
+
if (/^MESH-NEEDLOGIN\b/.test(line)) this._deletePeersFile();
|
|
193
|
+
const match = /^MESH-PEERS\s+(.+)$/.exec(line);
|
|
194
|
+
if (!match) return;
|
|
195
|
+
const raw = match[1];
|
|
196
|
+
if (Buffer.byteLength(raw, 'utf8') > MESH_PEERS_JSON_MAX) {
|
|
197
|
+
if (this.dev) console.debug(' [mesh] ignoring oversized MESH-PEERS payload');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
let parsed;
|
|
201
|
+
try {
|
|
202
|
+
parsed = JSON.parse(raw);
|
|
203
|
+
} catch (_) {
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const normalized = this._validatePeersPayload(parsed);
|
|
207
|
+
if (!normalized) return;
|
|
208
|
+
this.peers = normalized;
|
|
209
|
+
this._writePeersFile(normalized);
|
|
210
|
+
} catch (_) {}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
_validatePeersPayload(payload) {
|
|
214
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null;
|
|
215
|
+
const self = payload.self;
|
|
216
|
+
if (!self || typeof self !== 'object' || Array.isArray(self)) return null;
|
|
217
|
+
if (typeof self.hostname !== 'string' || typeof self.dnsName !== 'string') return null;
|
|
218
|
+
if (!Array.isArray(payload.peers)) return null;
|
|
219
|
+
const peers = [];
|
|
220
|
+
for (const p of payload.peers) {
|
|
221
|
+
if (!p || typeof p !== 'object' || Array.isArray(p)) continue;
|
|
222
|
+
if (typeof p.hostname !== 'string' || typeof p.dnsName !== 'string' || typeof p.online !== 'boolean') continue;
|
|
223
|
+
peers.push({ hostname: p.hostname, dnsName: p.dnsName, online: p.online });
|
|
224
|
+
}
|
|
225
|
+
return { self: { hostname: self.hostname, dnsName: self.dnsName }, peers };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_writePeersFile(snapshot) {
|
|
229
|
+
const file = this.peersFilePath();
|
|
230
|
+
const dir = path.dirname(file);
|
|
231
|
+
const tmp = path.join(dir, `peers.json.${process.pid}.tmp`);
|
|
232
|
+
try {
|
|
233
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
234
|
+
fs.writeFileSync(tmp, JSON.stringify({
|
|
235
|
+
version: 1,
|
|
236
|
+
updatedAt: Date.now(),
|
|
237
|
+
self: snapshot.self,
|
|
238
|
+
peers: snapshot.peers,
|
|
239
|
+
}), { mode: 0o600 });
|
|
240
|
+
fs.chmodSync(tmp, 0o600);
|
|
241
|
+
fs.renameSync(tmp, file);
|
|
242
|
+
} catch (_) {
|
|
243
|
+
try { fs.rmSync(tmp, { force: true }); } catch (_) {}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_deletePeersFile() {
|
|
248
|
+
this.peers = null;
|
|
249
|
+
try { fs.rmSync(this.peersFilePath(), { force: true }); } catch (_) {}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
_printMissing(err) {
|
|
253
|
+
const code = err && err.code;
|
|
254
|
+
const map = {
|
|
255
|
+
'unsupported-platform': `Mesh: no sidecar build for this platform (${err && err.message}).`,
|
|
256
|
+
'lock-unfinalized': 'Mesh: sidecar checksum missing from this build — upgrade ai-or-die.',
|
|
257
|
+
'assets-missing': 'Mesh: sidecar binaries for this build are not published yet — try again shortly.',
|
|
258
|
+
'network': `Mesh: could not download the sidecar — ${err && err.message}.`,
|
|
259
|
+
'checksum-mismatch': 'Mesh: sidecar checksum mismatch — refusing to run an unverified binary.',
|
|
260
|
+
'locked-binary': `Mesh: ${err && err.message} — stop any running ai-or-die mesh and retry.`,
|
|
261
|
+
};
|
|
262
|
+
const line = (code && map[code]) || 'Mesh: sidecar not installed.';
|
|
263
|
+
console.log(`\n \x1b[33m${line}\x1b[0m See docs/specs/mesh.md.`);
|
|
134
264
|
console.log(' Continuing on localhost/devtunnel.\n');
|
|
135
265
|
}
|
|
136
266
|
|
|
267
|
+
_printNoCert() {
|
|
268
|
+
console.log('\n \x1b[33mMesh: tailnet HTTPS certificates are not enabled — serving plain http.\x1b[0m');
|
|
269
|
+
console.log(' Remote microphone (voice) and PWA install need a secure context (https).');
|
|
270
|
+
console.log(' Enable it once: \x1b[1mhttps://login.tailscale.com/admin/dns\x1b[0m → HTTPS Certificates.');
|
|
271
|
+
}
|
|
272
|
+
|
|
137
273
|
_printNotEnrolled() {
|
|
138
274
|
const ex = process.platform === 'win32' ? '$env:AIORDIE_TS_AUTHKEY="<key>"; ai-or-die --mesh' : 'AIORDIE_TS_AUTHKEY=<key> ai-or-die --mesh';
|
|
139
275
|
console.log('\n \x1b[1m\x1b[33mMesh: NOT ENROLLED\x1b[0m — enroll once:');
|
package/src/server.js
CHANGED
|
@@ -4105,6 +4105,7 @@ class ClaudeCodeWebServer {
|
|
|
4105
4105
|
return {
|
|
4106
4106
|
sessions: this.claudeSessions,
|
|
4107
4107
|
eventBus: this.controlEventBus,
|
|
4108
|
+
getMeshPeers: () => this.meshManager ? this.meshManager.getStatus().peers : [],
|
|
4108
4109
|
getStatusSignal: (id) => this._controlStatusSignal(id),
|
|
4109
4110
|
readTail: async (id, lines) => this._controlReadTail(id, lines),
|
|
4110
4111
|
createSession: async (opts) => this._controlCreateSession(opts),
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
// Sidecar installer — fetches the prebuilt aiordie-mesh tsnet binary for the
|
|
4
|
-
// current platform
|
|
5
|
-
//
|
|
6
|
-
//
|
|
4
|
+
// current platform and verifies it against a SHA-256 that ships INSIDE this npm
|
|
5
|
+
// package (mesh-sidecar.lock.json). Trust is therefore anchored by the signed
|
|
6
|
+
// npm artifact, not by a checksums file fetched from the same mutable GitHub
|
|
7
|
+
// release (which a tampered release could swap alongside the binary).
|
|
8
|
+
//
|
|
9
|
+
// Identity is content-addressed: the binary lives at the release tag
|
|
10
|
+
// `mesh-<contentHash>` where contentHash is derived from the sidecar source
|
|
11
|
+
// (see scripts/mesh-lock.js). Many ai-or-die versions can pin the same sidecar;
|
|
12
|
+
// CI only rebuilds when the source — and thus the hash — changes.
|
|
7
13
|
//
|
|
8
14
|
// Asset convention (published by .github/workflows/release-on-main.yml):
|
|
9
15
|
// aiordie-mesh-<plat>-<arch>[.exe] plat: windows|linux|darwin arch: amd64|arm64
|
|
10
|
-
// aiordie-mesh-checksums.txt "<sha256> <assetname>" per line
|
|
11
16
|
|
|
12
17
|
const fs = require('fs');
|
|
13
18
|
const fsp = require('fs').promises;
|
|
@@ -19,6 +24,22 @@ const REPO = 'animeshkundu/ai-or-die';
|
|
|
19
24
|
const PLAT = { win32: 'windows', linux: 'linux', darwin: 'darwin' };
|
|
20
25
|
const ARCH = { x64: 'amd64', arm64: 'arm64' };
|
|
21
26
|
|
|
27
|
+
// Typed failure so the manager can print an accurate cause instead of a blanket
|
|
28
|
+
// "fetched on next release build".
|
|
29
|
+
class SidecarError extends Error {
|
|
30
|
+
constructor(code, message) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = 'SidecarError';
|
|
33
|
+
this.code = code; // unsupported-platform | lock-unfinalized | assets-missing | network | checksum-mismatch | locked-binary
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function loadLock() {
|
|
38
|
+
// The lock ships at the package root, two levels up from src/utils/.
|
|
39
|
+
const p = path.join(__dirname, '..', '..', 'mesh-sidecar.lock.json');
|
|
40
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
41
|
+
}
|
|
42
|
+
|
|
22
43
|
function assetName() {
|
|
23
44
|
const plat = PLAT[process.platform];
|
|
24
45
|
const arch = ARCH[process.arch];
|
|
@@ -26,10 +47,33 @@ function assetName() {
|
|
|
26
47
|
return `aiordie-mesh-${plat}-${arch}${process.platform === 'win32' ? '.exe' : ''}`;
|
|
27
48
|
}
|
|
28
49
|
|
|
29
|
-
function
|
|
50
|
+
function baseDir() {
|
|
30
51
|
const localApp = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
|
|
31
|
-
|
|
32
|
-
|
|
52
|
+
return process.platform === 'win32' ? path.join(localApp, 'ai-or-die') : path.join(os.homedir(), '.ai-or-die');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Versioned path: a new contentHash installs alongside the old one, so we never
|
|
56
|
+
// rename over a binary that a running sidecar still has open (Windows EPERM).
|
|
57
|
+
function sidecarPath(contentHash) {
|
|
58
|
+
const ext = process.platform === 'win32' ? '.exe' : '';
|
|
59
|
+
return path.join(baseDir(), 'bin', `aiordie-mesh-${contentHash}${ext}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// The release ref to fetch from. Defaults to mesh-<contentHash>; an explicit
|
|
63
|
+
// override is a dev/testing escape hatch and is announced loudly.
|
|
64
|
+
function meshRef(lock) {
|
|
65
|
+
const override = process.env.AIORDIE_MESH_REF || process.env.AIORDIE_MESH_VERSION;
|
|
66
|
+
if (override) {
|
|
67
|
+
// Constrain to a safe tag shape — no path segments / traversal — even though
|
|
68
|
+
// the host is fixed. This is a dev/testing escape hatch, announced loudly.
|
|
69
|
+
const ref = /^mesh-/.test(override) ? override : `mesh-${override}`;
|
|
70
|
+
if (!/^mesh-[A-Za-z0-9._-]+$/.test(ref)) {
|
|
71
|
+
throw new SidecarError('unsupported-platform', `invalid AIORDIE_MESH_REF ${JSON.stringify(override)}`);
|
|
72
|
+
}
|
|
73
|
+
console.warn(` \x1b[33m[mesh] overriding sidecar ref → ${ref} (AIORDIE_MESH_REF/VERSION)\x1b[0m`);
|
|
74
|
+
return ref;
|
|
75
|
+
}
|
|
76
|
+
return `mesh-${lock.contentHash}`;
|
|
33
77
|
}
|
|
34
78
|
|
|
35
79
|
async function _sha256(file) {
|
|
@@ -42,68 +86,87 @@ async function _sha256(file) {
|
|
|
42
86
|
});
|
|
43
87
|
}
|
|
44
88
|
|
|
45
|
-
async function
|
|
89
|
+
async function _download(url, tmp, ms = 120000) {
|
|
46
90
|
const ac = new AbortController();
|
|
47
91
|
const t = setTimeout(() => ac.abort(), ms);
|
|
92
|
+
let r;
|
|
48
93
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} finally {
|
|
94
|
+
r = await fetch(url, { signal: ac.signal });
|
|
95
|
+
} catch (e) {
|
|
96
|
+
throw new SidecarError('network', `could not reach ${hostOf(url)} (${e.message})`);
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(t);
|
|
99
|
+
}
|
|
100
|
+
if (r.status === 404) throw new SidecarError('assets-missing', `HTTP 404 for ${url}`);
|
|
101
|
+
if (!r.ok || !r.body) throw new SidecarError('network', `HTTP ${r.status} for ${url}`);
|
|
102
|
+
// Exclusive create — never follow/overwrite a pre-planted file or symlink.
|
|
103
|
+
const out = fs.createWriteStream(tmp, { flags: 'wx' });
|
|
104
|
+
const reader = r.body.getReader();
|
|
105
|
+
for (;;) {
|
|
106
|
+
const { done, value } = await reader.read();
|
|
107
|
+
if (done) break;
|
|
108
|
+
await new Promise((res, rej) => out.write(value, (e) => (e ? rej(e) : res())));
|
|
109
|
+
}
|
|
110
|
+
await new Promise((res) => out.end(res));
|
|
53
111
|
}
|
|
54
112
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const t = setTimeout(() => ac.abort(), ms);
|
|
58
|
-
try {
|
|
59
|
-
const r = await fetch(url, { signal: ac.signal });
|
|
60
|
-
if (!r.ok || !r.body) throw new Error(`HTTP ${r.status}`);
|
|
61
|
-
// Exclusive create — never follow/overwrite a pre-planted file or symlink.
|
|
62
|
-
const out = fs.createWriteStream(tmp, { flags: 'wx' });
|
|
63
|
-
const reader = r.body.getReader();
|
|
64
|
-
for (;;) {
|
|
65
|
-
const { done, value } = await reader.read();
|
|
66
|
-
if (done) break;
|
|
67
|
-
await new Promise((res, rej) => out.write(value, (e) => (e ? rej(e) : res())));
|
|
68
|
-
}
|
|
69
|
-
await new Promise((res) => out.end(res));
|
|
70
|
-
} finally { clearTimeout(t); }
|
|
113
|
+
function hostOf(url) {
|
|
114
|
+
try { return new URL(url).host; } catch (_) { return 'github.com'; }
|
|
71
115
|
}
|
|
72
116
|
|
|
73
|
-
// Ensure the sidecar binary exists locally AND matches the
|
|
74
|
-
// download + verify if missing
|
|
75
|
-
|
|
117
|
+
// Ensure the sidecar binary exists locally AND matches the lock's checksum;
|
|
118
|
+
// download + verify if missing. Returns the path; throws SidecarError on failure.
|
|
119
|
+
// `opts.lock` injects a lock object (tests); production reads the committed lock.
|
|
120
|
+
async function ensureSidecar(dest, opts = {}) {
|
|
121
|
+
const lock = opts.lock || loadLock();
|
|
76
122
|
const name = assetName();
|
|
77
|
-
if (!name) throw new
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
});
|
|
86
|
-
if (!line) throw new Error(`no checksum for ${name} in release v${version}`);
|
|
87
|
-
const want = line.trim().split(/\s+/)[0].toLowerCase();
|
|
123
|
+
if (!name) throw new SidecarError('unsupported-platform', `unsupported platform ${process.platform}/${process.arch}`);
|
|
124
|
+
|
|
125
|
+
const want = (lock.assets && lock.assets[name] || '').toLowerCase();
|
|
126
|
+
if (!want) {
|
|
127
|
+
throw new SidecarError('lock-unfinalized', `mesh-sidecar.lock.json has no checksum for ${name} (built without the mesh asset pipeline?)`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
dest = dest || sidecarPath(lock.contentHash);
|
|
88
131
|
|
|
89
132
|
// Existing file: trust only if it matches; otherwise replace it.
|
|
90
133
|
if (fs.existsSync(dest)) {
|
|
91
134
|
if ((await _sha256(dest)).toLowerCase() === want) return dest;
|
|
92
|
-
try { await fsp.unlink(dest); }
|
|
135
|
+
try { await fsp.unlink(dest); }
|
|
136
|
+
catch (e) {
|
|
137
|
+
if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
|
|
138
|
+
throw new SidecarError('locked-binary', `cannot replace in-use sidecar at ${dest} (${e.code})`);
|
|
139
|
+
}
|
|
140
|
+
throw e; // surface real filesystem faults instead of masking them
|
|
141
|
+
}
|
|
93
142
|
}
|
|
94
143
|
|
|
95
144
|
await fsp.mkdir(path.dirname(dest), { recursive: true });
|
|
145
|
+
const ref = meshRef(lock);
|
|
146
|
+
const url = `https://github.com/${REPO}/releases/download/${ref}/${name}`;
|
|
96
147
|
const tmp = `${dest}.${crypto.randomBytes(8).toString('hex')}.incomplete`;
|
|
97
148
|
try {
|
|
98
|
-
await _download(
|
|
149
|
+
await _download(url, tmp);
|
|
99
150
|
// Verify BEFORE the bytes ever reach the runnable path (no TOCTOU window).
|
|
100
|
-
if ((await _sha256(tmp)).toLowerCase() !== want)
|
|
151
|
+
if ((await _sha256(tmp)).toLowerCase() !== want) {
|
|
152
|
+
throw new SidecarError('checksum-mismatch', `checksum mismatch for ${name} from ${ref}`);
|
|
153
|
+
}
|
|
101
154
|
if (process.platform !== 'win32') { try { await fsp.chmod(tmp, 0o755); } catch (_) {} }
|
|
102
|
-
|
|
155
|
+
try {
|
|
156
|
+
await fsp.rename(tmp, dest);
|
|
157
|
+
} catch (e) {
|
|
158
|
+
// Lost a race to a concurrent installer (or the file is locked). If the
|
|
159
|
+
// destination is now present and valid, accept it; otherwise surface it.
|
|
160
|
+
if (fs.existsSync(dest) && (await _sha256(dest)).toLowerCase() === want) return dest;
|
|
161
|
+
if (e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES') {
|
|
162
|
+
throw new SidecarError('locked-binary', `cannot install sidecar at ${dest} (${e.code})`);
|
|
163
|
+
}
|
|
164
|
+
throw e;
|
|
165
|
+
}
|
|
103
166
|
} finally {
|
|
104
167
|
try { await fsp.unlink(tmp); } catch (_) {}
|
|
105
168
|
}
|
|
106
169
|
return dest;
|
|
107
170
|
}
|
|
108
171
|
|
|
109
|
-
module.exports = { ensureSidecar, sidecarPath, assetName };
|
|
172
|
+
module.exports = { ensureSidecar, sidecarPath, assetName, loadLock, SidecarError };
|