native-sim 0.1.0
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/README.md +395 -0
- package/bin/native-sim.js +8 -0
- package/package.json +31 -0
- package/src/cli.js +151 -0
- package/src/commands/doctor.js +54 -0
- package/src/commands/down.js +48 -0
- package/src/commands/init.js +64 -0
- package/src/commands/r2.js +62 -0
- package/src/commands/status.js +30 -0
- package/src/commands/turn.js +83 -0
- package/src/commands/up.js +288 -0
- package/src/commands/upload.js +60 -0
- package/src/lib/gh.js +170 -0
- package/src/lib/ghrelease.js +50 -0
- package/src/lib/git.js +68 -0
- package/src/lib/proc.js +37 -0
- package/src/lib/project.js +35 -0
- package/src/lib/r2.js +94 -0
- package/src/lib/session.js +24 -0
- package/src/lib/ui.js +44 -0
- package/templates/gate.cjs +184 -0
- package/templates/native-sim.yml +654 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// native-sim-template-version: 18
|
|
2
|
+
/**
|
|
3
|
+
* native-sim auth gate.
|
|
4
|
+
*
|
|
5
|
+
* serve-sim ships no authentication, so exposing port 3200 through a public
|
|
6
|
+
* tunnel would hand simulator control to anyone who guessed the URL. This is a
|
|
7
|
+
* dependency-free reverse proxy that requires `?k=<token>` once, trades it for
|
|
8
|
+
* an HttpOnly cookie, and forwards everything (including the MJPEG stream and
|
|
9
|
+
* the control WebSocket) to serve-sim on localhost.
|
|
10
|
+
*
|
|
11
|
+
* It also multiplexes a second upstream onto the same tunnel: when
|
|
12
|
+
* NATIVE_SIM_AGENT_PORT is set, `/agent-device/*` is routed to the local
|
|
13
|
+
* `agent-device proxy` instead of serve-sim, so one URL carries both the
|
|
14
|
+
* human-facing stream and the agent-facing control API.
|
|
15
|
+
*/
|
|
16
|
+
const http = require('node:http');
|
|
17
|
+
const net = require('node:net');
|
|
18
|
+
|
|
19
|
+
const TOKEN = process.env.NATIVE_SIM_GATE_TOKEN || '';
|
|
20
|
+
const TARGET_PORT = Number(process.env.NATIVE_SIM_TARGET_PORT || 3200);
|
|
21
|
+
// 0 disables the agent-device route entirely, so a session started without
|
|
22
|
+
// --agent exposes no extra surface at all.
|
|
23
|
+
const AGENT_PORT = Number(process.env.NATIVE_SIM_AGENT_PORT || 0);
|
|
24
|
+
const AGENT_PREFIX = '/agent-device';
|
|
25
|
+
const TARGET_HOST = '127.0.0.1';
|
|
26
|
+
const PORT = Number(process.env.NATIVE_SIM_GATE_PORT || 3199);
|
|
27
|
+
const COOKIE = 'native_sim_k';
|
|
28
|
+
|
|
29
|
+
if (!TOKEN) {
|
|
30
|
+
console.error('NATIVE_SIM_GATE_TOKEN is required — refusing to proxy an unauthenticated simulator');
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function timingSafeEqual(a, b) {
|
|
35
|
+
if (typeof a !== 'string' || a.length !== b.length) return false;
|
|
36
|
+
let diff = 0;
|
|
37
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
38
|
+
return diff === 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function cookieToken(req) {
|
|
42
|
+
const raw = req.headers.cookie || '';
|
|
43
|
+
for (const part of raw.split(';')) {
|
|
44
|
+
const [name, ...rest] = part.trim().split('=');
|
|
45
|
+
if (name === COOKIE) return rest.join('=');
|
|
46
|
+
}
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** agent-device authenticates with a bearer header; it never sends cookies. */
|
|
51
|
+
function bearerToken(req) {
|
|
52
|
+
const match = /^Bearer\s+(.+)$/i.exec((req.headers.authorization || '').trim());
|
|
53
|
+
return match ? match[1] : null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pathnameOf(req) {
|
|
57
|
+
return new URL(req.url, 'http://localhost').pathname;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** True when this request belongs to the agent-device proxy, not serve-sim. */
|
|
61
|
+
function isAgentRoute(req) {
|
|
62
|
+
if (!AGENT_PORT) return false;
|
|
63
|
+
const path = pathnameOf(req);
|
|
64
|
+
return path === AGENT_PREFIX || path.startsWith(`${AGENT_PREFIX}/`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Returns 'cookie' | 'bearer' | 'query' when authorised, or false. */
|
|
68
|
+
function authorize(req) {
|
|
69
|
+
if (timingSafeEqual(cookieToken(req), TOKEN)) return 'cookie';
|
|
70
|
+
if (timingSafeEqual(bearerToken(req), TOKEN)) return 'bearer';
|
|
71
|
+
const url = new URL(req.url, 'http://localhost');
|
|
72
|
+
if (timingSafeEqual(url.searchParams.get('k'), TOKEN)) return 'query';
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DENIED = `<!doctype html><meta charset=utf-8><title>native-sim</title>
|
|
77
|
+
<style>body{font:14px/1.6 -apple-system,system-ui,sans-serif;margin:15vh auto;max-width:34rem;padding:0 1.5rem;color:#111}
|
|
78
|
+
@media(prefers-color-scheme:dark){body{background:#111;color:#eee}}code{background:#8882;padding:.15em .4em;border-radius:4px}</style>
|
|
79
|
+
<h1>🔒 native-sim</h1>
|
|
80
|
+
<p>This simulator stream needs the access key from the link the CLI printed.</p>
|
|
81
|
+
<p>Ask whoever started the session for the full URL — the one ending in <code>?k=…</code>.</p>`;
|
|
82
|
+
|
|
83
|
+
const server = http.createServer((req, res) => {
|
|
84
|
+
if (req.url.startsWith('/__native-sim/healthz')) {
|
|
85
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify({ ok: true, target: TARGET_PORT, agent: AGENT_PORT || null }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const auth = authorize(req);
|
|
91
|
+
if (!auth) {
|
|
92
|
+
// agent-device leaves /health unauthenticated for reachability probes; the
|
|
93
|
+
// gate deliberately does not, so an unauthenticated request can never reach
|
|
94
|
+
// either upstream. `connect proxy` carries --daemon-auth-token on every
|
|
95
|
+
// request, including that probe, so it authenticates normally.
|
|
96
|
+
res.writeHead(403, { 'content-type': 'text/html; charset=utf-8' });
|
|
97
|
+
res.end(DENIED);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const agent = isAgentRoute(req);
|
|
102
|
+
|
|
103
|
+
// Trade the query token for a cookie so the key stops travelling in URLs
|
|
104
|
+
// (and so the preview's own fetches and WebSocket upgrades carry it). Never
|
|
105
|
+
// on the agent route: a 302 mid-RPC would break the client, which has no
|
|
106
|
+
// cookie jar and already authenticates per request.
|
|
107
|
+
if (auth === 'query' && !agent) {
|
|
108
|
+
const url = new URL(req.url, 'http://localhost');
|
|
109
|
+
url.searchParams.delete('k');
|
|
110
|
+
res.writeHead(302, {
|
|
111
|
+
'set-cookie': `${COOKIE}=${TOKEN}; Path=/; HttpOnly; SameSite=Lax; Max-Age=43200`,
|
|
112
|
+
location: url.pathname + url.search,
|
|
113
|
+
});
|
|
114
|
+
res.end();
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const upstream = http.request(
|
|
119
|
+
{
|
|
120
|
+
host: TARGET_HOST,
|
|
121
|
+
port: agent ? AGENT_PORT : TARGET_PORT,
|
|
122
|
+
method: req.method,
|
|
123
|
+
// The agent-device proxy serves these routes under /agent-device/* itself,
|
|
124
|
+
// so the path is forwarded verbatim rather than stripped.
|
|
125
|
+
path: req.url,
|
|
126
|
+
// Do NOT rewrite Host. serve-sim derives the URLs it advertises to the
|
|
127
|
+
// browser from these headers; pointing them at 127.0.0.1:3200 makes the
|
|
128
|
+
// page open its control WebSocket against the *viewer's* loopback, which
|
|
129
|
+
// fails as "control socket connect timeout". Forward the public origin so
|
|
130
|
+
// the helper and WebSocket URLs stay same-origin and route back through
|
|
131
|
+
// this gate.
|
|
132
|
+
headers: {
|
|
133
|
+
...req.headers,
|
|
134
|
+
'x-forwarded-proto': 'https',
|
|
135
|
+
'x-forwarded-host': req.headers.host,
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
(upRes) => {
|
|
139
|
+
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
|
140
|
+
upRes.pipe(res);
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
upstream.on('error', (err) => {
|
|
145
|
+
if (!res.headersSent) res.writeHead(502, { 'content-type': 'text/plain' });
|
|
146
|
+
res.end(`upstream error: ${err.message}`);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
req.pipe(upstream);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// WebSockets carry simulator input, so the upgrade path has to be proxied too.
|
|
153
|
+
server.on('upgrade', (req, socket, head) => {
|
|
154
|
+
if (!authorize(req)) {
|
|
155
|
+
socket.end('HTTP/1.1 403 Forbidden\r\n\r\n');
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const upstream = net.connect(isAgentRoute(req) ? AGENT_PORT : TARGET_PORT, TARGET_HOST, () => {
|
|
160
|
+
const forwarded = {
|
|
161
|
+
...req.headers,
|
|
162
|
+
'x-forwarded-proto': 'https',
|
|
163
|
+
'x-forwarded-host': req.headers.host,
|
|
164
|
+
};
|
|
165
|
+
const headers = Object.entries(forwarded)
|
|
166
|
+
.map(([k, v]) => (Array.isArray(v) ? v.map((x) => `${k}: ${x}`).join('\r\n') : `${k}: ${v}`))
|
|
167
|
+
.join('\r\n');
|
|
168
|
+
upstream.write(`${req.method} ${req.url} HTTP/1.1\r\n${headers}\r\n\r\n`);
|
|
169
|
+
if (head && head.length) upstream.write(head);
|
|
170
|
+
upstream.pipe(socket);
|
|
171
|
+
socket.pipe(upstream);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const drop = () => {
|
|
175
|
+
socket.destroy();
|
|
176
|
+
upstream.destroy();
|
|
177
|
+
};
|
|
178
|
+
upstream.on('error', drop);
|
|
179
|
+
socket.on('error', drop);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
server.listen(PORT, '127.0.0.1', () => {
|
|
183
|
+
console.log(`native-sim gate on :${PORT} -> :${TARGET_PORT}${AGENT_PORT ? ` (agent-device -> :${AGENT_PORT})` : ''}`);
|
|
184
|
+
});
|