ai-or-die 0.1.86 → 0.1.88
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 +14 -4
- package/mesh/main.go +176 -12
- package/mesh/main_test.go +119 -0
- package/mesh-sidecar.lock.json +12 -0
- package/package.json +3 -1
- package/src/mesh-manager.js +48 -14
- package/src/public/app.js +138 -19
- package/src/public/index.html +12 -0
- package/src/public/join-repaint.js +36 -0
- package/src/public/session-manager.js +33 -0
- package/src/public/terminal-snapshot-cache.js +295 -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,6 +209,9 @@ 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,
|
package/mesh/main.go
CHANGED
|
@@ -5,16 +5,28 @@
|
|
|
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
|
|
10
20
|
// MESH-NEEDLOGIN <url> no/!valid key — operator must enroll
|
|
11
|
-
// MESH-ERR <msg>
|
|
21
|
+
// MESH-ERR <msg> fatal
|
|
12
22
|
package main
|
|
13
23
|
|
|
14
24
|
import (
|
|
15
25
|
"context"
|
|
26
|
+
"crypto/tls"
|
|
16
27
|
"flag"
|
|
17
28
|
"fmt"
|
|
29
|
+
"net"
|
|
18
30
|
"net/http"
|
|
19
31
|
"net/http/httputil"
|
|
20
32
|
"net/url"
|
|
@@ -25,12 +37,33 @@ import (
|
|
|
25
37
|
"tailscale.com/tsnet"
|
|
26
38
|
)
|
|
27
39
|
|
|
40
|
+
// version is stamped at build time via -ldflags "-X main.version=<contentHash>".
|
|
41
|
+
var version = "dev"
|
|
42
|
+
|
|
43
|
+
const certWaitTimeout = 20 * time.Second
|
|
44
|
+
|
|
28
45
|
func main() {
|
|
29
|
-
port := flag.String("port", "7777", "local ai-or-die port to expose")
|
|
46
|
+
port := flag.String("port", "7777", "local ai-or-die port to expose (loopback)")
|
|
47
|
+
backend := flag.String("backend", "", "full backend URL to proxy to (overrides --port); must be loopback")
|
|
30
48
|
host := flag.String("hostname", "aiordie", "tailnet hostname")
|
|
31
49
|
dir := flag.String("statedir", "./ts-state", "persistent node state dir")
|
|
50
|
+
showVersion := flag.Bool("version", false, "print the build version (content hash) and exit")
|
|
32
51
|
flag.Parse()
|
|
33
52
|
|
|
53
|
+
if *showVersion {
|
|
54
|
+
fmt.Println(version)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve + validate the backend target. It MUST be loopback: the sidecar is
|
|
59
|
+
// a tailnet-facing reverse proxy, and pointing it off-host would expose an
|
|
60
|
+
// arbitrary origin to the whole tailnet.
|
|
61
|
+
target, err := resolveBackend(*backend, *port)
|
|
62
|
+
if err != nil {
|
|
63
|
+
fmt.Printf("MESH-ERR %v\n", err)
|
|
64
|
+
os.Exit(1)
|
|
65
|
+
}
|
|
66
|
+
|
|
34
67
|
s := &tsnet.Server{Dir: *dir, Hostname: *host}
|
|
35
68
|
// Surface the login URL exactly once instead of tsnet's default stderr spam.
|
|
36
69
|
s.AuthKey = os.Getenv("TS_AUTHKEY")
|
|
@@ -39,27 +72,158 @@ func main() {
|
|
|
39
72
|
// Bring the node up; report the enroll URL if it needs login.
|
|
40
73
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
|
41
74
|
defer cancel()
|
|
42
|
-
|
|
75
|
+
st, err := s.Up(ctx)
|
|
76
|
+
if err != nil {
|
|
43
77
|
// Up blocks until authed; on timeout it's almost always missing/!key.
|
|
44
78
|
fmt.Printf("MESH-NEEDLOGIN https://login.tailscale.com/admin/settings/keys\n")
|
|
45
79
|
fmt.Printf("MESH-ERR %v\n", err)
|
|
46
80
|
os.Exit(1)
|
|
47
81
|
}
|
|
48
82
|
|
|
83
|
+
name := *host
|
|
84
|
+
if st != nil && st.Self != nil && st.Self.DNSName != "" {
|
|
85
|
+
name = strings.TrimSuffix(st.Self.DNSName, ".")
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One reverse proxy; the edge scheme it advertises to the backend is decided
|
|
89
|
+
// just below and captured by pointer so the X-Forwarded-Proto header is
|
|
90
|
+
// always accurate. The terminal client derives ws/wss from window.location,
|
|
91
|
+
// so wss works regardless; this header is hygiene for any absolute-URL or
|
|
92
|
+
// redirect the app generates (no mixed content).
|
|
93
|
+
edgeProto := "http"
|
|
94
|
+
rp := newEdgeProxy(target, name, &edgeProto)
|
|
95
|
+
|
|
96
|
+
// Decide the scheme BEFORE advertising. tsnet's ListenTLS returns a listener
|
|
97
|
+
// without provisioning the cert; the ACME work (and any failure) happens
|
|
98
|
+
// lazily inside the first TLS handshake. So we probe cert readiness up front
|
|
99
|
+
// and only advertise https:// when we are confident the handshake will work.
|
|
100
|
+
if tlsLn := tryListenTLS(ctx, s, name); tlsLn != nil {
|
|
101
|
+
edgeProto = "https"
|
|
102
|
+
// Best-effort :80 -> https redirect so a bare http:// hit is upgraded.
|
|
103
|
+
if httpLn, e := s.Listen("tcp", ":80"); e == nil {
|
|
104
|
+
go http.Serve(httpLn, redirectToHTTPS(name))
|
|
105
|
+
}
|
|
106
|
+
fmt.Printf("MESH-URL https://%s\n", name)
|
|
107
|
+
// http.Serve only returns on failure; exit non-zero so the manager
|
|
108
|
+
// (which restarts on a non-zero exit) brings the sidecar back.
|
|
109
|
+
err := http.Serve(tlsLn, rp)
|
|
110
|
+
fmt.Printf("MESH-ERR %v\n", err)
|
|
111
|
+
os.Exit(1)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Degraded: no usable cert. Serve plain http and say so. http://<name>.ts.net
|
|
115
|
+
// is NOT a browser secure context, so remote mic/PWA will not work until the
|
|
116
|
+
// operator enables HTTPS Certificates in the tailnet admin console.
|
|
49
117
|
ln, err := s.Listen("tcp", ":80")
|
|
50
118
|
if err != nil {
|
|
51
119
|
fmt.Printf("MESH-ERR %v\n", err)
|
|
52
120
|
os.Exit(1)
|
|
53
121
|
}
|
|
54
|
-
|
|
55
|
-
|
|
122
|
+
fmt.Printf("MESH-NOCERT\n")
|
|
123
|
+
fmt.Printf("MESH-URL http://%s\n", name)
|
|
124
|
+
serveErr := http.Serve(ln, rp)
|
|
125
|
+
fmt.Printf("MESH-ERR %v\n", serveErr)
|
|
126
|
+
os.Exit(1)
|
|
127
|
+
}
|
|
56
128
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
129
|
+
// resolveBackend builds the proxy target from --backend (preferred) or --port,
|
|
130
|
+
// and rejects anything that is not an http(s) loopback origin.
|
|
131
|
+
func resolveBackend(backend, port string) (*url.URL, error) {
|
|
132
|
+
raw := backend
|
|
133
|
+
if raw == "" {
|
|
134
|
+
raw = "http://127.0.0.1:" + port
|
|
61
135
|
}
|
|
62
|
-
|
|
136
|
+
u, err := url.Parse(raw)
|
|
137
|
+
if err != nil {
|
|
138
|
+
return nil, fmt.Errorf("invalid --backend %q: %v", raw, err)
|
|
139
|
+
}
|
|
140
|
+
if u.Scheme != "http" && u.Scheme != "https" {
|
|
141
|
+
return nil, fmt.Errorf("backend scheme must be http/https, got %q", u.Scheme)
|
|
142
|
+
}
|
|
143
|
+
if !isLoopbackHost(u.Hostname()) {
|
|
144
|
+
return nil, fmt.Errorf("backend host must be loopback (127.0.0.1/::1/localhost), got %q", u.Hostname())
|
|
145
|
+
}
|
|
146
|
+
// Pin "localhost" to a numeric loopback so the later proxy dial cannot be
|
|
147
|
+
// redirected off-host by a poisoned resolver / hosts file.
|
|
148
|
+
if u.Hostname() == "localhost" {
|
|
149
|
+
if p := u.Port(); p != "" {
|
|
150
|
+
u.Host = "127.0.0.1:" + p
|
|
151
|
+
} else {
|
|
152
|
+
u.Host = "127.0.0.1"
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return u, nil
|
|
156
|
+
}
|
|
63
157
|
|
|
64
|
-
|
|
158
|
+
func isLoopbackHost(h string) bool {
|
|
159
|
+
if h == "localhost" {
|
|
160
|
+
return true
|
|
161
|
+
}
|
|
162
|
+
ip := net.ParseIP(h)
|
|
163
|
+
return ip != nil && ip.IsLoopback()
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// tryListenTLS returns a TLS listener on :443 only when a cert for `name` looks
|
|
167
|
+
// obtainable; otherwise nil. It first checks the tailnet's advertised cert
|
|
168
|
+
// domains, then proactively warms the cert with a bounded timeout so a failure
|
|
169
|
+
// surfaces here (returning nil) instead of mid-handshake later.
|
|
170
|
+
func tryListenTLS(ctx context.Context, s *tsnet.Server, name string) net.Listener {
|
|
171
|
+
lc, err := s.LocalClient()
|
|
172
|
+
if err != nil {
|
|
173
|
+
return nil
|
|
174
|
+
}
|
|
175
|
+
status, err := lc.Status(ctx)
|
|
176
|
+
if err != nil || status == nil {
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
eligible := false
|
|
180
|
+
for _, d := range status.CertDomains {
|
|
181
|
+
if d == name {
|
|
182
|
+
eligible = true
|
|
183
|
+
break
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if !eligible {
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
// Proactively obtain the cert with a bounded timeout. If the tailnet says the
|
|
190
|
+
// domain is eligible but issuance still fails (HTTPS not actually enabled,
|
|
191
|
+
// ACME hiccup), we learn it now and fall back to http rather than hanging the
|
|
192
|
+
// first browser request.
|
|
193
|
+
warmCtx, cancel := context.WithTimeout(ctx, certWaitTimeout)
|
|
194
|
+
defer cancel()
|
|
195
|
+
if _, _, err := lc.CertPair(warmCtx, name); err != nil {
|
|
196
|
+
return nil
|
|
197
|
+
}
|
|
198
|
+
ln, err := s.ListenTLS("tcp", ":443")
|
|
199
|
+
if err != nil {
|
|
200
|
+
return nil
|
|
201
|
+
}
|
|
202
|
+
return ln
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
func redirectToHTTPS(name string) http.HandlerFunc {
|
|
206
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
207
|
+
u := "https://" + name + r.URL.RequestURI()
|
|
208
|
+
http.Redirect(w, r, u, http.StatusPermanentRedirect)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// newEdgeProxy builds the reverse proxy to the loopback backend. It stamps
|
|
213
|
+
// X-Forwarded-Proto (read through *proto, set after the TLS decision) and
|
|
214
|
+
// X-Forwarded-Host so the app never emits mixed-content links. For an https
|
|
215
|
+
// loopback backend (a future self-signed local listener) it skips verification —
|
|
216
|
+
// 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 {
|
|
218
|
+
rp := httputil.NewSingleHostReverseProxy(target)
|
|
219
|
+
orig := rp.Director
|
|
220
|
+
rp.Director = func(r *http.Request) {
|
|
221
|
+
orig(r)
|
|
222
|
+
r.Header.Set("X-Forwarded-Proto", *proto)
|
|
223
|
+
r.Header.Set("X-Forwarded-Host", name)
|
|
224
|
+
}
|
|
225
|
+
if target.Scheme == "https" {
|
|
226
|
+
rp.Transport = &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
|
|
227
|
+
}
|
|
228
|
+
return rp
|
|
65
229
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
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, and the
|
|
71
|
+
// pointer-captured proto must reflect the post-TLS decision.
|
|
72
|
+
func TestEdgeProxyForwardsProtoAndHost(t *testing.T) {
|
|
73
|
+
var gotProto, gotHost, 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
|
+
io.WriteString(w, "backend-ok")
|
|
78
|
+
}))
|
|
79
|
+
defer backend.Close()
|
|
80
|
+
|
|
81
|
+
target, _ := url.Parse(backend.URL) // 127.0.0.1 loopback
|
|
82
|
+
proto := "http" // default before the TLS decision
|
|
83
|
+
rp := newEdgeProxy(target, "node.tailnet.ts.net", &proto)
|
|
84
|
+
proto = "https" // edge decided real TLS AFTER constructing the proxy
|
|
85
|
+
|
|
86
|
+
front := httptest.NewServer(rp)
|
|
87
|
+
defer front.Close()
|
|
88
|
+
|
|
89
|
+
res, err := http.Get(front.URL + "/app")
|
|
90
|
+
if err != nil {
|
|
91
|
+
t.Fatalf("proxy request failed: %v", err)
|
|
92
|
+
}
|
|
93
|
+
defer res.Body.Close()
|
|
94
|
+
b, _ := io.ReadAll(res.Body)
|
|
95
|
+
gotBody = string(b)
|
|
96
|
+
|
|
97
|
+
if gotProto != "https" {
|
|
98
|
+
t.Errorf("backend saw X-Forwarded-Proto=%q, want https (pointer-captured decision)", gotProto)
|
|
99
|
+
}
|
|
100
|
+
if gotHost != "node.tailnet.ts.net" {
|
|
101
|
+
t.Errorf("backend saw X-Forwarded-Host=%q, want node.tailnet.ts.net", gotHost)
|
|
102
|
+
}
|
|
103
|
+
if gotBody != "backend-ok" {
|
|
104
|
+
t.Errorf("proxy body=%q, want backend-ok", gotBody)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
func TestRedirectToHTTPS(t *testing.T) {
|
|
109
|
+
rec := httptest.NewRecorder()
|
|
110
|
+
req := httptest.NewRequest("GET", "http://anything/path?q=1", nil)
|
|
111
|
+
redirectToHTTPS("node.tailnet.ts.net")(rec, req)
|
|
112
|
+
if rec.Code != http.StatusPermanentRedirect {
|
|
113
|
+
t.Fatalf("status=%d, want 308", rec.Code)
|
|
114
|
+
}
|
|
115
|
+
loc := rec.Header().Get("Location")
|
|
116
|
+
if !strings.HasPrefix(loc, "https://node.tailnet.ts.net/path") || !strings.Contains(loc, "q=1") {
|
|
117
|
+
t.Fatalf("Location=%q, want https://node.tailnet.ts.net/path?q=1", loc)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0.0",
|
|
3
|
+
"contentHash": "fb010e2cd6a930ef7eeac0de0adaf903cdb7252815650c38eac2d72f15da6e3d",
|
|
4
|
+
"assets": {
|
|
5
|
+
"aiordie-mesh-windows-amd64.exe": "7a30a635b726e5cae694e3f48b37b329d470e8cf493ac5f025b99ea4a0fbded0",
|
|
6
|
+
"aiordie-mesh-windows-arm64.exe": "6d056c561538a40991a975e410b61b7ba4e076c456ee280744ae9ade65202dda",
|
|
7
|
+
"aiordie-mesh-linux-amd64": "2e5fd52842b67ec56647efb59ce8944bb211055b7630a19206a67b2c80b328fd",
|
|
8
|
+
"aiordie-mesh-linux-arm64": "7508a98a2762feb3405df54150a34c53028b5588b44eac504835c56a5df82822",
|
|
9
|
+
"aiordie-mesh-darwin-amd64": "d095c85d01036f3a9a529179c9ef5ca67231150bd49fc5adf1c4fb4e0d0da11b",
|
|
10
|
+
"aiordie-mesh-darwin-arm64": "8d115b8233697995dc0d7bab8f45f881bba2d37896c86cd70ea626965b1e4cb0"
|
|
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.88",
|
|
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/mesh-manager.js
CHANGED
|
@@ -34,12 +34,18 @@ class MeshManager {
|
|
|
34
34
|
? path.join(localApp, 'ai-or-die')
|
|
35
35
|
: path.join(os.homedir(), '.ai-or-die');
|
|
36
36
|
this.stateDir = options.stateDir || path.join(base, 'ts-state');
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// The sidecar binary is content-addressed: its path embeds the lock's
|
|
38
|
+
// contentHash so a new build installs alongside the old one (no overwrite of
|
|
39
|
+
// a running .exe). Derived from the installer's lock + path helpers.
|
|
40
|
+
this._installer = options._installer || require('./utils/sidecar-installer');
|
|
41
|
+
this.sidecar = options.sidecar || this._sidecarPathFromLock(base);
|
|
39
42
|
this.hostname = (options.hostname || `aiordie-${os.hostname()}`).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
40
43
|
|
|
41
44
|
this.proc = null;
|
|
42
45
|
this.dnsName = null;
|
|
46
|
+
this.scheme = null; // 'https' (edge TLS) or 'http' (degraded)
|
|
47
|
+
this.backend = options.backend || `http://127.0.0.1:${this.port}`; // mesh serves plaintext loopback
|
|
48
|
+
this._lastError = null;
|
|
43
49
|
this.stopping = false;
|
|
44
50
|
this.retryCount = 0;
|
|
45
51
|
this._totalRestarts = 0;
|
|
@@ -54,30 +60,40 @@ class MeshManager {
|
|
|
54
60
|
console.log('\n Connecting mesh (Tailscale userspace)...');
|
|
55
61
|
this.stopping = false;
|
|
56
62
|
if (!fs.existsSync(this.sidecar)) {
|
|
57
|
-
if (!(await this._ensureSidecar())) { this._printMissing(); return; }
|
|
63
|
+
if (!(await this._ensureSidecar())) { this._printMissing(this._lastError); return; }
|
|
58
64
|
}
|
|
59
65
|
if (!this._authKey && !this._enrolled()) { this._printNotEnrolled(); return; }
|
|
60
66
|
try { fs.mkdirSync(this.stateDir, { recursive: true }); } catch (_) {}
|
|
61
67
|
await this._spawn();
|
|
62
68
|
}
|
|
63
69
|
|
|
70
|
+
/** Content-addressed sidecar path from the committed lock; safe fallback. */
|
|
71
|
+
_sidecarPathFromLock(base) {
|
|
72
|
+
try {
|
|
73
|
+
const lock = this._installer.loadLock();
|
|
74
|
+
return this._installer.sidecarPath(lock.contentHash);
|
|
75
|
+
} catch (_) {
|
|
76
|
+
const exe = process.platform === 'win32' ? 'aiordie-mesh.exe' : 'aiordie-mesh';
|
|
77
|
+
return path.join(base, 'bin', exe);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
64
81
|
/** Download + verify the sidecar from the matching release. Best-effort. */
|
|
65
82
|
async _ensureSidecar() {
|
|
66
83
|
try {
|
|
67
|
-
const { ensureSidecar } = require('./utils/sidecar-installer');
|
|
68
|
-
let version = '0.0.0';
|
|
69
|
-
try { version = require('../package.json').version; } catch (_) {}
|
|
70
84
|
console.log(' [mesh] fetching sidecar binary...');
|
|
71
|
-
await ensureSidecar(
|
|
85
|
+
await this._installer.ensureSidecar(this.sidecar);
|
|
86
|
+
this._lastError = null;
|
|
72
87
|
return fs.existsSync(this.sidecar);
|
|
73
88
|
} catch (e) {
|
|
89
|
+
this._lastError = e;
|
|
74
90
|
if (this.dev) console.error(' [mesh] sidecar fetch failed:', e.message);
|
|
75
91
|
return false;
|
|
76
92
|
}
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
getStatus() {
|
|
80
|
-
return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ?
|
|
96
|
+
return { running: this.proc !== null && !this.stopping && !!this.dnsName, publicUrl: this.dnsName ? `${this.scheme || 'https'}://${this.dnsName}` : null };
|
|
81
97
|
}
|
|
82
98
|
|
|
83
99
|
async stop() {
|
|
@@ -100,7 +116,7 @@ class MeshManager {
|
|
|
100
116
|
|
|
101
117
|
_spawn() {
|
|
102
118
|
return new Promise((resolve) => {
|
|
103
|
-
const args = ['--port', String(this.port), '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
119
|
+
const args = ['--port', String(this.port), '--backend', this.backend, '--hostname', this.hostname, '--statedir', this.stateDir];
|
|
104
120
|
// Pass the key to the sidecar via TS_AUTHKEY (enroll only); it's already
|
|
105
121
|
// stripped from this process + base child env, so it reaches only this child.
|
|
106
122
|
const env = this._authKey ? { ...this._childEnv, TS_AUTHKEY: this._authKey } : this._childEnv;
|
|
@@ -110,12 +126,14 @@ class MeshManager {
|
|
|
110
126
|
this.proc.stdout.on('data', (d) => {
|
|
111
127
|
const out = d.toString();
|
|
112
128
|
if (this.dev) process.stdout.write(` [mesh] ${out}`);
|
|
113
|
-
|
|
129
|
+
if (/MESH-NOCERT/.test(out)) this._printNoCert();
|
|
130
|
+
const url = out.match(/MESH-URL (https?):\/\/(\S+)/);
|
|
114
131
|
if (url && !this.dnsName) {
|
|
115
|
-
this.
|
|
132
|
+
this.scheme = url[1];
|
|
133
|
+
this.dnsName = url[2];
|
|
116
134
|
this._authKey = null;
|
|
117
135
|
this._startStabilityTimer();
|
|
118
|
-
this.onUrl(
|
|
136
|
+
this.onUrl(`${this.scheme}://${this.dnsName}`);
|
|
119
137
|
if (!done) { done = true; clearTimeout(timer); resolve(); }
|
|
120
138
|
}
|
|
121
139
|
if (/MESH-NEEDLOGIN/.test(out)) { this._printNotEnrolled(); if (!done) { done = true; clearTimeout(timer); resolve(); } }
|
|
@@ -129,11 +147,27 @@ class MeshManager {
|
|
|
129
147
|
});
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
_printMissing() {
|
|
133
|
-
|
|
150
|
+
_printMissing(err) {
|
|
151
|
+
const code = err && err.code;
|
|
152
|
+
const map = {
|
|
153
|
+
'unsupported-platform': `Mesh: no sidecar build for this platform (${err && err.message}).`,
|
|
154
|
+
'lock-unfinalized': 'Mesh: sidecar checksum missing from this build — upgrade ai-or-die.',
|
|
155
|
+
'assets-missing': 'Mesh: sidecar binaries for this build are not published yet — try again shortly.',
|
|
156
|
+
'network': `Mesh: could not download the sidecar — ${err && err.message}.`,
|
|
157
|
+
'checksum-mismatch': 'Mesh: sidecar checksum mismatch — refusing to run an unverified binary.',
|
|
158
|
+
'locked-binary': `Mesh: ${err && err.message} — stop any running ai-or-die mesh and retry.`,
|
|
159
|
+
};
|
|
160
|
+
const line = (code && map[code]) || 'Mesh: sidecar not installed.';
|
|
161
|
+
console.log(`\n \x1b[33m${line}\x1b[0m See docs/specs/mesh.md.`);
|
|
134
162
|
console.log(' Continuing on localhost/devtunnel.\n');
|
|
135
163
|
}
|
|
136
164
|
|
|
165
|
+
_printNoCert() {
|
|
166
|
+
console.log('\n \x1b[33mMesh: tailnet HTTPS certificates are not enabled — serving plain http.\x1b[0m');
|
|
167
|
+
console.log(' Remote microphone (voice) and PWA install need a secure context (https).');
|
|
168
|
+
console.log(' Enable it once: \x1b[1mhttps://login.tailscale.com/admin/dns\x1b[0m → HTTPS Certificates.');
|
|
169
|
+
}
|
|
170
|
+
|
|
137
171
|
_printNotEnrolled() {
|
|
138
172
|
const ex = process.platform === 'win32' ? '$env:AIORDIE_TS_AUTHKEY="<key>"; ai-or-die --mesh' : 'AIORDIE_TS_AUTHKEY=<key> ai-or-die --mesh';
|
|
139
173
|
console.log('\n \x1b[1m\x1b[33mMesh: NOT ENROLLED\x1b[0m — enroll once:');
|