ai-or-die 0.1.88 → 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 +1 -0
- package/mesh/main.go +115 -3
- package/mesh/main_test.go +9 -5
- package/mesh-sidecar.lock.json +7 -7
- package/package.json +1 -1
- package/src/control/routes.js +4 -0
- package/src/mesh-manager.js +124 -22
- package/src/server.js +1 -0
package/bin/ai-or-die.js
CHANGED
|
@@ -216,6 +216,7 @@ async function main() {
|
|
|
216
216
|
const mesh = new MeshManager({
|
|
217
217
|
port,
|
|
218
218
|
dev: options.dev,
|
|
219
|
+
authToken,
|
|
219
220
|
onUrl: (meshUrl) => {
|
|
220
221
|
const t = authToken ? `${meshUrl}?token=${authToken}` : meshUrl;
|
|
221
222
|
console.log(`\n \x1b[1m\x1b[32mMesh ready:\x1b[0m \x1b[1m\x1b[4m${t}\x1b[0m\n`);
|
package/mesh/main.go
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// MESH-URL https://<name> node is up, serving real TLS at the edge
|
|
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
|
+
// MESH-PEERS {"self":...,"peers":[...]} tagged fleet peers snapshot
|
|
20
21
|
// MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
|
|
21
22
|
// MESH-ERR <msg> fatal
|
|
22
23
|
package main
|
|
@@ -24,6 +25,7 @@ package main
|
|
|
24
25
|
import (
|
|
25
26
|
"context"
|
|
26
27
|
"crypto/tls"
|
|
28
|
+
"encoding/json"
|
|
27
29
|
"flag"
|
|
28
30
|
"fmt"
|
|
29
31
|
"net"
|
|
@@ -31,16 +33,23 @@ import (
|
|
|
31
33
|
"net/http/httputil"
|
|
32
34
|
"net/url"
|
|
33
35
|
"os"
|
|
36
|
+
"sort"
|
|
34
37
|
"strings"
|
|
35
38
|
"time"
|
|
36
39
|
|
|
40
|
+
"tailscale.com/ipn/ipnstate"
|
|
37
41
|
"tailscale.com/tsnet"
|
|
38
42
|
)
|
|
39
43
|
|
|
40
44
|
// version is stamped at build time via -ldflags "-X main.version=<contentHash>".
|
|
41
45
|
var version = "dev"
|
|
42
46
|
|
|
43
|
-
const
|
|
47
|
+
const (
|
|
48
|
+
certWaitTimeout = 20 * time.Second
|
|
49
|
+
meshPeersInterval = 20 * time.Second
|
|
50
|
+
meshPeersMaxPeers = 512
|
|
51
|
+
meshPeerTag = "tag:aiordie"
|
|
52
|
+
)
|
|
44
53
|
|
|
45
54
|
func main() {
|
|
46
55
|
port := flag.String("port", "7777", "local ai-or-die port to expose (loopback)")
|
|
@@ -55,6 +64,8 @@ func main() {
|
|
|
55
64
|
return
|
|
56
65
|
}
|
|
57
66
|
|
|
67
|
+
proxyBearer := os.Getenv("AIORDIE_PROXY_BEARER")
|
|
68
|
+
|
|
58
69
|
// Resolve + validate the backend target. It MUST be loopback: the sidecar is
|
|
59
70
|
// a tailnet-facing reverse proxy, and pointing it off-host would expose an
|
|
60
71
|
// arbitrary origin to the whole tailnet.
|
|
@@ -80,6 +91,10 @@ func main() {
|
|
|
80
91
|
os.Exit(1)
|
|
81
92
|
}
|
|
82
93
|
|
|
94
|
+
if lc, err := s.LocalClient(); err == nil {
|
|
95
|
+
go emitMeshPeersLoop(context.Background(), lc)
|
|
96
|
+
}
|
|
97
|
+
|
|
83
98
|
name := *host
|
|
84
99
|
if st != nil && st.Self != nil && st.Self.DNSName != "" {
|
|
85
100
|
name = strings.TrimSuffix(st.Self.DNSName, ".")
|
|
@@ -91,7 +106,7 @@ func main() {
|
|
|
91
106
|
// so wss works regardless; this header is hygiene for any absolute-URL or
|
|
92
107
|
// redirect the app generates (no mixed content).
|
|
93
108
|
edgeProto := "http"
|
|
94
|
-
rp := newEdgeProxy(target, name, &edgeProto)
|
|
109
|
+
rp := newEdgeProxy(target, name, &edgeProto, proxyBearer)
|
|
95
110
|
|
|
96
111
|
// Decide the scheme BEFORE advertising. tsnet's ListenTLS returns a listener
|
|
97
112
|
// without provisioning the cert; the ACME work (and any failure) happens
|
|
@@ -163,6 +178,94 @@ func isLoopbackHost(h string) bool {
|
|
|
163
178
|
return ip != nil && ip.IsLoopback()
|
|
164
179
|
}
|
|
165
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
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
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
|
+
|
|
166
269
|
// tryListenTLS returns a TLS listener on :443 only when a cert for `name` looks
|
|
167
270
|
// obtainable; otherwise nil. It first checks the tailnet's advertised cert
|
|
168
271
|
// domains, then proactively warms the cert with a bounded timeout so a failure
|
|
@@ -214,13 +317,22 @@ func redirectToHTTPS(name string) http.HandlerFunc {
|
|
|
214
317
|
// X-Forwarded-Host so the app never emits mixed-content links. For an https
|
|
215
318
|
// loopback backend (a future self-signed local listener) it skips verification —
|
|
216
319
|
// the host is already validated as loopback, so this is not a trust boundary.
|
|
217
|
-
func newEdgeProxy(target *url.URL, name string, proto *string) *httputil.ReverseProxy {
|
|
320
|
+
func newEdgeProxy(target *url.URL, name string, proto *string, proxyBearer string) *httputil.ReverseProxy {
|
|
218
321
|
rp := httputil.NewSingleHostReverseProxy(target)
|
|
219
322
|
orig := rp.Director
|
|
220
323
|
rp.Director = func(r *http.Request) {
|
|
221
324
|
orig(r)
|
|
222
325
|
r.Header.Set("X-Forwarded-Proto", *proto)
|
|
223
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
|
+
}
|
|
224
336
|
}
|
|
225
337
|
if target.Scheme == "https" {
|
|
226
338
|
rp.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
package/mesh/main_test.go
CHANGED
|
@@ -67,20 +67,21 @@ func TestIsLoopbackHost(t *testing.T) {
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
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,
|
|
71
|
-
// pointer-captured proto must reflect the post-TLS decision.
|
|
72
|
-
func
|
|
73
|
-
var gotProto, gotHost, gotBody string
|
|
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
74
|
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
75
75
|
gotProto = r.Header.Get("X-Forwarded-Proto")
|
|
76
76
|
gotHost = r.Header.Get("X-Forwarded-Host")
|
|
77
|
+
gotAuth = r.Header.Get("Authorization")
|
|
77
78
|
io.WriteString(w, "backend-ok")
|
|
78
79
|
}))
|
|
79
80
|
defer backend.Close()
|
|
80
81
|
|
|
81
82
|
target, _ := url.Parse(backend.URL) // 127.0.0.1 loopback
|
|
82
83
|
proto := "http" // default before the TLS decision
|
|
83
|
-
rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto)
|
|
84
|
+
rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto, "app-token")
|
|
84
85
|
proto = "https" // edge decided real TLS AFTER constructing the proxy
|
|
85
86
|
|
|
86
87
|
front := httptest.NewServer(rp)
|
|
@@ -100,6 +101,9 @@ func TestEdgeProxyForwardsProtoAndHost(t *testing.T) {
|
|
|
100
101
|
if gotHost != "node.tailnet.ts.net" {
|
|
101
102
|
t.Errorf("backend saw X-Forwarded-Host=%q, want node.tailnet.ts.net", gotHost)
|
|
102
103
|
}
|
|
104
|
+
if gotAuth != "Bearer app-token" {
|
|
105
|
+
t.Errorf("backend saw Authorization=%q, want Bearer app-token", gotAuth)
|
|
106
|
+
}
|
|
103
107
|
if gotBody != "backend-ok" {
|
|
104
108
|
t.Errorf("proxy body=%q, want backend-ok", gotBody)
|
|
105
109
|
}
|
package/mesh-sidecar.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": "1.0.0",
|
|
3
|
-
"contentHash": "
|
|
3
|
+
"contentHash": "dbf60b5215ebb0b0947edf8fa47906280dd1fea12ef7e1181918fecfe7d9bcc0",
|
|
4
4
|
"assets": {
|
|
5
|
-
"aiordie-mesh-windows-amd64.exe": "
|
|
6
|
-
"aiordie-mesh-windows-arm64.exe": "
|
|
7
|
-
"aiordie-mesh-linux-amd64": "
|
|
8
|
-
"aiordie-mesh-linux-arm64": "
|
|
9
|
-
"aiordie-mesh-darwin-amd64": "
|
|
10
|
-
"aiordie-mesh-darwin-arm64": "
|
|
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
11
|
}
|
|
12
12
|
}
|
package/package.json
CHANGED
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,28 +17,32 @@ 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(
|
|
40
|
+
this.stateDir = options.stateDir || path.join(this._appBase, 'ts-state');
|
|
37
41
|
// The sidecar binary is content-addressed: its path embeds the lock's
|
|
38
42
|
// contentHash so a new build installs alongside the old one (no overwrite of
|
|
39
43
|
// a running .exe). Derived from the installer's lock + path helpers.
|
|
40
44
|
this._installer = options._installer || require('./utils/sidecar-installer');
|
|
41
|
-
this.sidecar = options.sidecar || this._sidecarPathFromLock(
|
|
45
|
+
this.sidecar = options.sidecar || this._sidecarPathFromLock(this._appBase);
|
|
42
46
|
this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
43
47
|
|
|
44
48
|
this.proc = null;
|
|
@@ -53,6 +57,8 @@ class MeshManager {
|
|
|
53
57
|
this._restartDelayTimer = null;
|
|
54
58
|
this._restartDelayResolve = null;
|
|
55
59
|
this._stabilityThresholdMs = options._stabilityThresholdMs || STABILITY_THRESHOLD_MS;
|
|
60
|
+
this._stdoutBuffer = '';
|
|
61
|
+
this.peers = null;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
/** Never throws — degrades to localhost/devtunnel on any failure. */
|
|
@@ -93,11 +99,20 @@ class MeshManager {
|
|
|
93
99
|
}
|
|
94
100
|
|
|
95
101
|
getStatus() {
|
|
96
|
-
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');
|
|
97
111
|
}
|
|
98
112
|
|
|
99
113
|
async stop() {
|
|
100
114
|
this.stopping = true;
|
|
115
|
+
this._deletePeersFile();
|
|
101
116
|
this._clearStabilityTimer();
|
|
102
117
|
clearTimeout(this._restartDelayTimer);
|
|
103
118
|
if (this._restartDelayResolve) { this._restartDelayResolve(); this._restartDelayResolve = null; }
|
|
@@ -117,36 +132,123 @@ class MeshManager {
|
|
|
117
132
|
_spawn() {
|
|
118
133
|
return new Promise((resolve) => {
|
|
119
134
|
const args = ['--port', String(this.port), '--backend', this.backend, '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
120
|
-
// Pass
|
|
121
|
-
//
|
|
122
|
-
const env =
|
|
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 = '';
|
|
123
141
|
this.proc = spawn(this.sidecar, args, { stdio: ['ignore', 'pipe', 'pipe'], env });
|
|
124
142
|
let done = false;
|
|
125
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);
|
|
126
144
|
this.proc.stdout.on('data', (d) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
+
});
|
|
140
163
|
});
|
|
141
164
|
this.proc.stderr.on('data', (d) => { if (this.dev) process.stderr.write(` [mesh] ${d}`); });
|
|
142
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(); } });
|
|
143
166
|
this.proc.on('exit', (code) => {
|
|
144
|
-
this._clearStabilityTimer(); this.proc = null; this.dnsName = null;
|
|
167
|
+
this._clearStabilityTimer(); this.proc = null; this.dnsName = null; this._deletePeersFile();
|
|
145
168
|
if (!this.stopping && code !== 0) this._restart();
|
|
146
169
|
});
|
|
147
170
|
});
|
|
148
171
|
}
|
|
149
172
|
|
|
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
|
+
|
|
150
252
|
_printMissing(err) {
|
|
151
253
|
const code = err && err.code;
|
|
152
254
|
const map = {
|
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),
|