@tsconfigdotjson/vibe-os 0.0.0-stage → 0.2.0-rc.1
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/LICENSE +21 -0
- package/README.md +988 -2
- package/THIRD_PARTY_NOTICES.md +2467 -0
- package/bin/vibe-os.mjs +13 -0
- package/dist/web/apple-touch-icon.png +0 -0
- package/dist/web/assets/index-B77Qk8B5.js +35 -0
- package/dist/web/assets/index-B77Qk8B5.js.br +0 -0
- package/dist/web/assets/index-B77Qk8B5.js.gz +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css +1 -0
- package/dist/web/assets/index-CH5Em-Mo.css.br +0 -0
- package/dist/web/assets/index-CH5Em-Mo.css.gz +0 -0
- package/dist/web/icon-192.png +0 -0
- package/dist/web/icon-512.png +0 -0
- package/dist/web/icon-app.svg +19 -0
- package/dist/web/icon-maskable-512.png +0 -0
- package/dist/web/icon-maskable.svg +13 -0
- package/dist/web/icon.svg +15 -0
- package/dist/web/index.html +36 -0
- package/dist/web/ssh.wasm +0 -0
- package/dist/web/ssh.wasm.br +0 -0
- package/dist/web/ssh.wasm.gz +0 -0
- package/dist/web/sw.js +29 -0
- package/dist/web/wasm_exec.js +575 -0
- package/dist/web/wasm_exec.js.br +0 -0
- package/dist/web/wasm_exec.js.gz +0 -0
- package/package.json +82 -4
- package/scripts/compile.ts +62 -0
- package/scripts/compress.mjs +54 -0
- package/scripts/fetch-wasm.mjs +242 -0
- package/scripts/gen-assets.ts +76 -0
- package/scripts/lib/walk.ts +18 -0
- package/scripts/notices.ts +189 -0
- package/server/acme.ts +182 -0
- package/server/api.ts +515 -0
- package/server/assets.generated.ts +53 -0
- package/server/attach.ts +524 -0
- package/server/auth.ts +194 -0
- package/server/bridge.ts +145 -0
- package/server/browser.ts +519 -0
- package/server/cli.ts +857 -0
- package/server/config.ts +385 -0
- package/server/db.ts +262 -0
- package/server/doctor.ts +977 -0
- package/server/fsx.ts +88 -0
- package/server/harness.ts +655 -0
- package/server/http.ts +53 -0
- package/server/index.ts +469 -0
- package/server/log.ts +40 -0
- package/server/main.ts +7 -0
- package/server/mcp.ts +291 -0
- package/server/preflight.ts +87 -0
- package/server/profiles.ts +238 -0
- package/server/projects.ts +721 -0
- package/server/reaper.ts +303 -0
- package/server/runtime.ts +64 -0
- package/server/session.ts +330 -0
- package/server/setup.ts +1151 -0
- package/server/ssh-ca.ts +368 -0
- package/server/static.ts +147 -0
- package/server/wallpapers.ts +248 -0
- package/server/windows.ts +237 -0
- package/shared/args.ts +91 -0
- package/shared/grid.ts +32 -0
- package/shared/wire.ts +226 -0
package/server/auth.ts
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// The front door.
|
|
2
|
+
//
|
|
3
|
+
// vibe-os hands out shell access to the machine it runs on, so "who may load
|
|
4
|
+
// the page" and "who may open a shell" are the same question — there is no
|
|
5
|
+
// point protecting one and not the other. Every entry point (the app, the API,
|
|
6
|
+
// the certificate signer, the wallpaper upload, the byte pipe) goes through
|
|
7
|
+
// this one gate.
|
|
8
|
+
//
|
|
9
|
+
// It is on by default. --no-token turns it off, and startup prints a warning
|
|
10
|
+
// that says so.
|
|
11
|
+
//
|
|
12
|
+
// Two checks sit in front of the token. Origin applies to every request: a
|
|
13
|
+
// page on another site can make the browser send a request here, and a foreign
|
|
14
|
+
// Origin is how to tell. Host applies only when there is no token, because
|
|
15
|
+
// that is when DNS rebinding pays: a page that points its own name at this box
|
|
16
|
+
// becomes same-origin with it, and with no token there is nothing else to
|
|
17
|
+
// stop it. With a token, the rebound page has no cookie and no token to send.
|
|
18
|
+
|
|
19
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
20
|
+
import { isIP } from "node:net";
|
|
21
|
+
|
|
22
|
+
const COOKIE = "vibe_os_session";
|
|
23
|
+
|
|
24
|
+
function safeEqual(a: string, b: string): boolean {
|
|
25
|
+
// Hash first so the comparison is over fixed-length buffers and cannot leak
|
|
26
|
+
// length through timingSafeEqual's own length check.
|
|
27
|
+
const ha = createHash("sha256").update(a).digest();
|
|
28
|
+
const hb = createHash("sha256").update(b).digest();
|
|
29
|
+
return timingSafeEqual(ha, hb);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readCookie(req: Request, name: string): string | null {
|
|
33
|
+
const header = req.headers.get("cookie");
|
|
34
|
+
if (!header) return null;
|
|
35
|
+
for (const part of header.split(";")) {
|
|
36
|
+
const idx = part.indexOf("=");
|
|
37
|
+
if (idx === -1) continue;
|
|
38
|
+
if (part.slice(0, idx).trim() === name) {
|
|
39
|
+
try {
|
|
40
|
+
return decodeURIComponent(part.slice(idx + 1).trim());
|
|
41
|
+
} catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Was the browser talking TLS, even if we were not?
|
|
51
|
+
*
|
|
52
|
+
* `secure` is whether this process's own listener is TLS, and behind a proxy it
|
|
53
|
+
* is always false: `tailscale serve` — the deployment this project recommends —
|
|
54
|
+
* terminates TLS and forwards plain HTTP to 127.0.0.1. So the cookie was set
|
|
55
|
+
* without `Secure` on exactly the setup that has the best TLS of any of them.
|
|
56
|
+
*
|
|
57
|
+
* The header is only ever allowed to *upgrade* the answer, never downgrade it.
|
|
58
|
+
* That matters because anyone can send `X-Forwarded-Proto`, and the rule keeps
|
|
59
|
+
* a forged one from doing anything an attacker would want: claiming `https`
|
|
60
|
+
* only makes the cookie stricter, and claiming `http` on a real TLS listener is
|
|
61
|
+
* ignored. Nor can a forgery be aimed at someone else — browsers do not send
|
|
62
|
+
* this header, so the only request it can appear on is the sender's own.
|
|
63
|
+
*/
|
|
64
|
+
function isSecureRequest(req: Request, secure: boolean): boolean {
|
|
65
|
+
if (secure) return true;
|
|
66
|
+
const proto = req.headers.get("x-forwarded-proto");
|
|
67
|
+
// A chain of proxies appends, and the client's protocol is the first entry.
|
|
68
|
+
return proto?.split(",")[0].trim().toLowerCase() === "https";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface Gate {
|
|
72
|
+
/** null when allowed, otherwise a reason string. */
|
|
73
|
+
check(req: Request): string | null;
|
|
74
|
+
/**
|
|
75
|
+
* Handles `?token=…` on a page load: sets the session cookie and redirects to
|
|
76
|
+
* the same URL without the token, so it does not linger in history or leak
|
|
77
|
+
* through a Referer header.
|
|
78
|
+
*
|
|
79
|
+
* `secure` is whether *this* listener is TLS. A proxy in front may have
|
|
80
|
+
* terminated TLS itself, which `isSecureRequest` accounts for.
|
|
81
|
+
*/
|
|
82
|
+
consumeTokenParam(req: Request, url: URL, secure: boolean): Response | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createGate(token: string | null): Gate {
|
|
86
|
+
if (!token) {
|
|
87
|
+
return {
|
|
88
|
+
check: () => null,
|
|
89
|
+
consumeTokenParam: () => null,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const gate = {
|
|
94
|
+
check(req: Request): string | null {
|
|
95
|
+
const cookie = readCookie(req, COOKIE);
|
|
96
|
+
if (cookie && safeEqual(cookie, token)) return null;
|
|
97
|
+
|
|
98
|
+
const auth = req.headers.get("authorization");
|
|
99
|
+
if (auth?.startsWith("Bearer ") && safeEqual(auth.slice(7), token))
|
|
100
|
+
return null;
|
|
101
|
+
|
|
102
|
+
return "missing or invalid session token";
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
consumeTokenParam(
|
|
106
|
+
req: Request,
|
|
107
|
+
url: URL,
|
|
108
|
+
secure: boolean,
|
|
109
|
+
): Response | null {
|
|
110
|
+
const supplied = url.searchParams.get("token");
|
|
111
|
+
if (!supplied) return null;
|
|
112
|
+
if (!safeEqual(supplied, token)) {
|
|
113
|
+
return new Response("invalid token\n", {
|
|
114
|
+
status: 403,
|
|
115
|
+
headers: { "content-type": "text/plain; charset=utf-8" },
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
url.searchParams.delete("token");
|
|
119
|
+
const attrs = [
|
|
120
|
+
`${COOKIE}=${encodeURIComponent(token)}`,
|
|
121
|
+
"Path=/",
|
|
122
|
+
"HttpOnly",
|
|
123
|
+
"SameSite=Lax",
|
|
124
|
+
"Max-Age=31536000",
|
|
125
|
+
isSecureRequest(req, secure) ? "Secure" : "",
|
|
126
|
+
].filter(Boolean);
|
|
127
|
+
return new Response(null, {
|
|
128
|
+
status: 302,
|
|
129
|
+
headers: {
|
|
130
|
+
"set-cookie": attrs.join("; "),
|
|
131
|
+
location: `${url.pathname}${url.search}`,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
},
|
|
135
|
+
} satisfies Gate;
|
|
136
|
+
|
|
137
|
+
return gate;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Rejects requests a page on another origin made the browser send.
|
|
142
|
+
*
|
|
143
|
+
* Browsers send Origin on every cross-origin request and on every POST, so a
|
|
144
|
+
* foreign one is either a mistake or an attack. It covers the routes a CORS
|
|
145
|
+
* preflight cannot: a `text/plain` POST, like the certificate request, and a
|
|
146
|
+
* WebSocket upgrade, neither of which is preflighted. No Origin means a client
|
|
147
|
+
* that is not a browser, or a same-origin GET.
|
|
148
|
+
*/
|
|
149
|
+
export function originAllowed(req: Request): boolean {
|
|
150
|
+
const origin = req.headers.get("origin");
|
|
151
|
+
if (origin === null) return true;
|
|
152
|
+
const host = req.headers.get("host");
|
|
153
|
+
if (!host) return false;
|
|
154
|
+
try {
|
|
155
|
+
// An opaque origin ("null", from a sandboxed frame or a file) parses to a
|
|
156
|
+
// URL with an empty host, which never matches.
|
|
157
|
+
return new URL(origin).host === host;
|
|
158
|
+
} catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The name part of a Host header: no port, no IPv6 brackets, lower case. */
|
|
164
|
+
export function hostName(header: string): string {
|
|
165
|
+
const bracketed = /^\[([^\]]+)\]/.exec(header);
|
|
166
|
+
if (bracketed) return bracketed[1].toLowerCase();
|
|
167
|
+
// One colon is a port. More than one is a bare IPv6 address, which a
|
|
168
|
+
// browser never sends but curl will.
|
|
169
|
+
const colons = header.split(":").length - 1;
|
|
170
|
+
return (colons === 1 ? header.slice(0, header.indexOf(":")) : header)
|
|
171
|
+
.toLowerCase()
|
|
172
|
+
.replace(/\.$/, "");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Whether a request names this server, for a server with no token.
|
|
177
|
+
*
|
|
178
|
+
* An address is always allowed: rebinding needs a name the attacker owns, and
|
|
179
|
+
* a browser that typed an IP sends that IP. So is `localhost` and anything
|
|
180
|
+
* under it. Any other name has to be one the server was told about, which
|
|
181
|
+
* `knownHosts` in index.ts collects: the machine's hostname, `--domain`,
|
|
182
|
+
* `--ssh-advertise`, the Tailscale name and `--allowed-host`.
|
|
183
|
+
*/
|
|
184
|
+
export function hostAllowed(req: Request, names: ReadonlySet<string>): boolean {
|
|
185
|
+
const header = req.headers.get("host");
|
|
186
|
+
if (!header) return false;
|
|
187
|
+
const name = hostName(header);
|
|
188
|
+
return (
|
|
189
|
+
isIP(name) !== 0 ||
|
|
190
|
+
name === "localhost" ||
|
|
191
|
+
name.endsWith(".localhost") ||
|
|
192
|
+
names.has(name)
|
|
193
|
+
);
|
|
194
|
+
}
|
package/server/bridge.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// The WebSocket ↔ TCP byte pipe.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately dumb: it moves bytes between the browser's WebSocket and a TCP
|
|
4
|
+
// socket and understands nothing about SSH. Key exchange, auth and channel
|
|
5
|
+
// multiplexing all happen inside the browser's WASM sandbox, so this process
|
|
6
|
+
// never sees plaintext and holds no SSH credentials for the session.
|
|
7
|
+
//
|
|
8
|
+
// ── Backpressure ─────────────────────────────────────────────────────────────
|
|
9
|
+
// This is the part worth reading twice. SSH is encrypted and MAC'd, so a single
|
|
10
|
+
// dropped or reordered byte does not degrade a session, it kills it. Bun's
|
|
11
|
+
// ServerWebSocket.send() has three outcomes:
|
|
12
|
+
//
|
|
13
|
+
// > 0 bytes handed to the socket
|
|
14
|
+
// -1 enqueued because we are already in backpressure — safe
|
|
15
|
+
// 0 DROPPED because the backpressure limit was exceeded — fatal here
|
|
16
|
+
//
|
|
17
|
+
// The rule that keeps us out of the fatal case: the instant send() returns -1,
|
|
18
|
+
// pause the TCP source, and only resume from the drain() callback. That way we
|
|
19
|
+
// never keep pushing into a full queue and never earn a 0. Measured on Bun
|
|
20
|
+
// 1.3.10 with 125MB through a deliberately slow reader: byte-exact, in order,
|
|
21
|
+
// zero drops.
|
|
22
|
+
//
|
|
23
|
+
// The browser→sshd direction needs no equivalent dance; it carries keystrokes.
|
|
24
|
+
// It is still capped defensively, because "the peer is malicious" is a
|
|
25
|
+
// different question from "the peer is slow".
|
|
26
|
+
|
|
27
|
+
import net from "node:net";
|
|
28
|
+
import type { ServerWebSocket } from "bun";
|
|
29
|
+
import { log } from "./log.ts";
|
|
30
|
+
|
|
31
|
+
/** Cap on bytes buffered toward sshd before we treat the client as hostile. */
|
|
32
|
+
const MAX_PENDING_TO_SSHD = 8 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export interface BridgeData {
|
|
35
|
+
socket: net.Socket | null;
|
|
36
|
+
peer: string;
|
|
37
|
+
closed: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface BridgeTarget {
|
|
41
|
+
host: string;
|
|
42
|
+
port: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let open = 0;
|
|
46
|
+
|
|
47
|
+
export function bridgeConnections(): number {
|
|
48
|
+
return open;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createBridgeHandlers(
|
|
52
|
+
target: BridgeTarget,
|
|
53
|
+
maxConnections = 64,
|
|
54
|
+
) {
|
|
55
|
+
const shutdown = (ws: ServerWebSocket<BridgeData>, why: string) => {
|
|
56
|
+
if (ws.data.closed) return;
|
|
57
|
+
ws.data.closed = true;
|
|
58
|
+
open -= 1;
|
|
59
|
+
log.debug(`bridge ${ws.data.peer} closed (${why}); ${open} open`);
|
|
60
|
+
ws.data.socket?.destroy();
|
|
61
|
+
ws.data.socket = null;
|
|
62
|
+
try {
|
|
63
|
+
ws.close();
|
|
64
|
+
} catch {
|
|
65
|
+
// already gone
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
atCapacity: () => open >= maxConnections,
|
|
71
|
+
|
|
72
|
+
handlers: {
|
|
73
|
+
// Generous ceiling. We should never reach it — pausing on -1 keeps the
|
|
74
|
+
// queue short — but closing the connection is far better than silently
|
|
75
|
+
// dropping bytes if the assumption ever breaks.
|
|
76
|
+
backpressureLimit: 16 * 1024 * 1024,
|
|
77
|
+
closeOnBackpressureLimit: true,
|
|
78
|
+
|
|
79
|
+
open(ws: ServerWebSocket<BridgeData>) {
|
|
80
|
+
open += 1;
|
|
81
|
+
const socket = net.connect(target.port, target.host);
|
|
82
|
+
ws.data.socket = socket;
|
|
83
|
+
socket.setNoDelay(true);
|
|
84
|
+
|
|
85
|
+
// Without this an unreachable target leaves the browser staring at a
|
|
86
|
+
// connected WebSocket until the OS gives up, which can be minutes.
|
|
87
|
+
socket.setTimeout(15_000, () => {
|
|
88
|
+
if (socket.connecting) {
|
|
89
|
+
log.warn(
|
|
90
|
+
`bridge ${ws.data.peer}: ${target.host}:${target.port} did not answer`,
|
|
91
|
+
);
|
|
92
|
+
shutdown(ws, "connect timeout");
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
socket.on("connect", () => {
|
|
97
|
+
socket.setTimeout(0);
|
|
98
|
+
log.debug(`bridge ${ws.data.peer} → ${target.host}:${target.port}`);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
socket.on("data", (chunk: Buffer) => {
|
|
102
|
+
const rc = ws.send(chunk);
|
|
103
|
+
if (rc === 0) {
|
|
104
|
+
// Should be unreachable given the pause below. If it ever happens
|
|
105
|
+
// the stream is already corrupt, so fail loudly rather than let a
|
|
106
|
+
// half-broken SSH session limp along.
|
|
107
|
+
log.warn(
|
|
108
|
+
`bridge ${ws.data.peer}: websocket dropped a frame — closing`,
|
|
109
|
+
);
|
|
110
|
+
shutdown(ws, "backpressure drop");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (rc === -1) socket.pause();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
socket.on("error", (err) => {
|
|
117
|
+
log.debug(`bridge ${ws.data.peer} tcp error: ${err.message}`);
|
|
118
|
+
shutdown(ws, "tcp error");
|
|
119
|
+
});
|
|
120
|
+
socket.on("close", () => shutdown(ws, "tcp closed"));
|
|
121
|
+
},
|
|
122
|
+
|
|
123
|
+
message(ws: ServerWebSocket<BridgeData>, message: string | Buffer) {
|
|
124
|
+
const socket = ws.data.socket;
|
|
125
|
+
if (!socket || socket.destroyed) return;
|
|
126
|
+
if (socket.writableLength > MAX_PENDING_TO_SSHD) {
|
|
127
|
+
log.warn(`bridge ${ws.data.peer}: client outran sshd — closing`);
|
|
128
|
+
shutdown(ws, "write buffer full");
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
socket.write(
|
|
132
|
+
typeof message === "string" ? Buffer.from(message) : message,
|
|
133
|
+
);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
drain(ws: ServerWebSocket<BridgeData>) {
|
|
137
|
+
ws.data.socket?.resume();
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
close(ws: ServerWebSocket<BridgeData>) {
|
|
141
|
+
shutdown(ws, "ws closed");
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|