opencode-webui 1.0.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/assets/Inter.ttf +0 -0
- package/dist/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/dist/assets/index-C5HRLW8j.js +122 -0
- package/dist/assets/index-DUtdz9a2.css +1 -0
- package/dist/assets/opencode.svg +7 -0
- package/dist/assets/report-R1enHhQU.js +2 -0
- package/dist/assets/runtime-status-CWjwBTFm.js +1 -0
- package/dist/index.html +14 -0
- package/package.json +72 -0
- package/server/auth.ts +524 -0
- package/server/index.ts +626 -0
- package/server/skillSync.ts +49 -0
- package/server/userExtensions.ts +88 -0
- package/skills/webui/SKILL.md +122 -0
- package/ui-extensions/README.md +271 -0
package/server/auth.ts
ADDED
|
@@ -0,0 +1,524 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access control for the webui proxy.
|
|
3
|
+
*
|
|
4
|
+
* One password (WEBUI_PASSWORD, or auto-generated on a loopback bind) → one
|
|
5
|
+
* session cookie. Design constraints:
|
|
6
|
+
*
|
|
7
|
+
* - The password is NEVER stored or compared in plaintext: only its SHA-256
|
|
8
|
+
* digest is kept in memory and verified via crypto.timingSafeEqual.
|
|
9
|
+
* - Session tokens are HMAC-signed (`payload.sig`, both base64url) with a
|
|
10
|
+
* 32-byte secret persisted at ~/.local/state/opencode-webui/secret.key
|
|
11
|
+
* (chmod 600) so sessions survive proxy restarts. No plaintext secret ever
|
|
12
|
+
* leaves this module, and no password or token is ever logged.
|
|
13
|
+
* - DNS-rebinding: the Host header is validated on EVERY request (loopback
|
|
14
|
+
* names + the configured bind host, plus the reverse-proxy-forwarded
|
|
15
|
+
* X-Forwarded-Host when present). State-changing methods additionally get
|
|
16
|
+
* an Origin check: same-origin or no Origin (curl/scripts) passes.
|
|
17
|
+
* - Login attempts are rate-limited per peer IP: 5 failures/min, then 429
|
|
18
|
+
* with Retry-After. A successful login clears the counter.
|
|
19
|
+
*
|
|
20
|
+
* This module is deliberately engine-agnostic: it never talks to the
|
|
21
|
+
* opencode service.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
25
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { join } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const SESSION_COOKIE = "webui_session";
|
|
30
|
+
/** 30 days — matches the Max-Age the login cookie advertises. */
|
|
31
|
+
const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60; // 2592000
|
|
32
|
+
const SECRET_STATE_DIR = "opencode-webui";
|
|
33
|
+
|
|
34
|
+
// Rate limiting: fixed 60s window that starts at the first failure.
|
|
35
|
+
const RATE_WINDOW_MS = 60_000;
|
|
36
|
+
const RATE_MAX_FAILURES = 5;
|
|
37
|
+
const rateBuckets = new Map<string, { count: number; windowStart: number }>();
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Secrets
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function stateBaseDir(): string {
|
|
44
|
+
return process.env.XDG_STATE_HOME ?? join(homedir(), ".local", "state");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The HMAC secret for session tokens, persisted across restarts so a login
|
|
49
|
+
* outlives `bun run --watch` (which restarts this process on every edit).
|
|
50
|
+
* Created on first boot with mode 600; a corrupt file is replaced.
|
|
51
|
+
*/
|
|
52
|
+
export function loadSecret(): Buffer {
|
|
53
|
+
const dir = join(stateBaseDir(), SECRET_STATE_DIR);
|
|
54
|
+
const file = join(dir, "secret.key");
|
|
55
|
+
try {
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
chmodSync(dir, 0o700);
|
|
58
|
+
} catch {
|
|
59
|
+
/* best-effort hardening */
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const hex = readFileSync(file, "utf8").trim();
|
|
63
|
+
if (/^[0-9a-f]{64}$/i.test(hex)) {
|
|
64
|
+
try {
|
|
65
|
+
chmodSync(file, 0o600);
|
|
66
|
+
} catch {
|
|
67
|
+
/* keep going with whatever perms exist */
|
|
68
|
+
}
|
|
69
|
+
return Buffer.from(hex, "hex");
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
/* ENOENT — first boot, fall through to create */
|
|
73
|
+
}
|
|
74
|
+
const key = randomBytes(32);
|
|
75
|
+
try {
|
|
76
|
+
writeFileSync(file, key.toString("hex") + "\n", { mode: 0o600 });
|
|
77
|
+
chmodSync(file, 0o600);
|
|
78
|
+
} catch (err) {
|
|
79
|
+
// A read-only state dir still yields a working server — sessions just
|
|
80
|
+
// won't survive restarts (a fresh secret invalidates old cookies).
|
|
81
|
+
console.error("[webui] could not persist session secret (sessions reset on restart):", err instanceof Error ? err.message : err);
|
|
82
|
+
}
|
|
83
|
+
return key;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Password
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
export type AuthPolicy = {
|
|
91
|
+
/** SHA-256 digest of the effective password — the ONLY thing kept. */
|
|
92
|
+
digest: Buffer;
|
|
93
|
+
/** Set when the password was generated here (printed once, then forgotten). */
|
|
94
|
+
generated?: string;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Resolve the password policy at boot. WEBUI_PASSWORD wins; on a wildcard
|
|
99
|
+
* bind with none set we REFUSE to start rather than expose an unauthenticated
|
|
100
|
+
* proxy to the network. Loopback binds get a generated passphrase that is
|
|
101
|
+
* GENERATED ONCE and then persisted (0600) next to the HMAC secret, so
|
|
102
|
+
* restarts keep working without a new password to hunt for in logs.
|
|
103
|
+
*/
|
|
104
|
+
export function resolveAuthPolicy(host: string): AuthPolicy {
|
|
105
|
+
const fromEnv = process.env.WEBUI_PASSWORD;
|
|
106
|
+
if (fromEnv && fromEnv.length > 0) {
|
|
107
|
+
return { digest: createHash("sha256").update(fromEnv, "utf8").digest() };
|
|
108
|
+
}
|
|
109
|
+
if (isWildcardHostname(hostnameOf(host))) {
|
|
110
|
+
console.error(`[webui] refusing ${host} without WEBUI_PASSWORD — set it or keep the loopback bind`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
const generated = loadOrGeneratePassword();
|
|
114
|
+
return { digest: createHash("sha256").update(generated, "utf8").digest(), generated };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** State dir shared with the HMAC secret (created by loadSecret). */
|
|
118
|
+
const STATE_DIR = join(stateBaseDir(), "opencode-webui");
|
|
119
|
+
const PASSWORD_FILE = join(STATE_DIR, "generated-password");
|
|
120
|
+
|
|
121
|
+
function loadOrGeneratePassword(): string {
|
|
122
|
+
try {
|
|
123
|
+
const existing = readFileSync(PASSWORD_FILE, "utf8").trim();
|
|
124
|
+
if (existing.length >= 16) return existing;
|
|
125
|
+
} catch {
|
|
126
|
+
/* first boot — generate below */
|
|
127
|
+
}
|
|
128
|
+
const password = generatePassphrase();
|
|
129
|
+
try {
|
|
130
|
+
mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 });
|
|
131
|
+
writeFileSync(PASSWORD_FILE, password + "\n", { mode: 0o600 });
|
|
132
|
+
} catch {
|
|
133
|
+
/* unwritable state dir — password still works for this boot */
|
|
134
|
+
}
|
|
135
|
+
return password;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function verifyPassword(input: string, digest: Buffer): boolean {
|
|
139
|
+
const candidate = createHash("sha256").update(input, "utf8").digest();
|
|
140
|
+
return timingSafeEqual(candidate, digest);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Diceware-style passphrase: 6 words from a 132-word list ≈ 42 bits, joined
|
|
145
|
+
* with "-". Combined with the per-IP rate limit (5 tries/min) that is far
|
|
146
|
+
* beyond brute-force reach for a local tool, and much friendlier to type
|
|
147
|
+
* than hex.
|
|
148
|
+
*/
|
|
149
|
+
const WORDS = (
|
|
150
|
+
"amber anchor apple arrow aspen atlas audio autumn " +
|
|
151
|
+
"basil beach birch bison bloom blush brave breeze " +
|
|
152
|
+
"cactus camel canyon cargo cedar chili cider cinder " +
|
|
153
|
+
"citrus clover cobalt comet coral cosmic cotton crane " +
|
|
154
|
+
"creek crimson cypress dahlia dawn delta dune ember " +
|
|
155
|
+
"emerald fable falcon fern fjord flint forest fossil " +
|
|
156
|
+
"galaxy garnet gecko ginger glacier granite grove harbor " +
|
|
157
|
+
"hazel heron hickory honey indigo ivory jasper jungle " +
|
|
158
|
+
"juniper kayak kernel lagoon lantern lemon lilac linen " +
|
|
159
|
+
"lotus lynx magma mango maple marble meadow mesa " +
|
|
160
|
+
"meteor mint mirage mosaic nebula nectar noble nomad " +
|
|
161
|
+
"oasis olive onyx orbit orchid otter paddle palm " +
|
|
162
|
+
"pearl pebble pine pixel plum polar prism quail " +
|
|
163
|
+
"quartz quill radish raven reef ridge river robin " +
|
|
164
|
+
"rocky rosemary rusty sable sage salmon sapphire shadow " +
|
|
165
|
+
"sierra silver slate solar spruce summit tulip umber " +
|
|
166
|
+
"velvet willow zephyr zenith"
|
|
167
|
+
).split(/\s+/);
|
|
168
|
+
|
|
169
|
+
export function generatePassphrase(): string {
|
|
170
|
+
const words: string[] = [];
|
|
171
|
+
for (let i = 0; i < 6; i++) {
|
|
172
|
+
words.push(WORDS[randomInt(WORDS.length)] ?? "opencode");
|
|
173
|
+
}
|
|
174
|
+
return words.join("-");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// Session tokens: base64url(payload) + "." + base64url(HMAC-SHA256(payload))
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
export function signToken(secret: Buffer): string {
|
|
182
|
+
const payload = Buffer.from(
|
|
183
|
+
JSON.stringify({ v: 1, exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS }),
|
|
184
|
+
).toString("base64url");
|
|
185
|
+
const sig = createHmac("sha256", secret).update(payload).digest("base64url");
|
|
186
|
+
return `${payload}.${sig}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function verifyToken(token: string, secret: Buffer): boolean {
|
|
190
|
+
const dot = token.lastIndexOf(".");
|
|
191
|
+
if (dot <= 0 || dot === token.length - 1) return false;
|
|
192
|
+
const payload = token.slice(0, dot);
|
|
193
|
+
const sig = token.slice(dot + 1);
|
|
194
|
+
const expected = createHmac("sha256", secret).update(payload).digest();
|
|
195
|
+
let sigBuf: Buffer;
|
|
196
|
+
try {
|
|
197
|
+
sigBuf = Buffer.from(sig, "base64url");
|
|
198
|
+
} catch {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
if (sigBuf.length !== expected.length || !timingSafeEqual(sigBuf, expected)) return false;
|
|
202
|
+
try {
|
|
203
|
+
const parsed = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as {
|
|
204
|
+
v?: number;
|
|
205
|
+
exp?: number;
|
|
206
|
+
};
|
|
207
|
+
return parsed.v === 1 && typeof parsed.exp === "number" && parsed.exp * 1000 > Date.now();
|
|
208
|
+
} catch {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function isAuthed(req: Request, secret: Buffer): boolean {
|
|
214
|
+
const token = getCookie(req, SESSION_COOKIE);
|
|
215
|
+
if (!token) return false;
|
|
216
|
+
return verifyToken(token, secret);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export function getCookie(req: Request, name: string): string | undefined {
|
|
220
|
+
const header = req.headers.get("cookie");
|
|
221
|
+
if (!header) return undefined;
|
|
222
|
+
for (const part of header.split(";")) {
|
|
223
|
+
const eq = part.indexOf("=");
|
|
224
|
+
if (eq === -1) continue;
|
|
225
|
+
if (part.slice(0, eq).trim() === name) return part.slice(eq + 1).trim();
|
|
226
|
+
}
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function sessionCookieHeader(token: string, secure: boolean): string {
|
|
231
|
+
return `${SESSION_COOKIE}=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_TTL_SECONDS}${secure ? "; Secure" : ""}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function clearSessionCookieHeader(): string {
|
|
235
|
+
return `${SESSION_COOKIE}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
// Rate limiting (per peer IP, in-memory)
|
|
240
|
+
// ---------------------------------------------------------------------------
|
|
241
|
+
|
|
242
|
+
export function rateLimitStatus(ip: string): { limited: boolean; retryAfter: number } {
|
|
243
|
+
const entry = rateBuckets.get(ip);
|
|
244
|
+
if (!entry || Date.now() - entry.windowStart >= RATE_WINDOW_MS) {
|
|
245
|
+
return { limited: false, retryAfter: 0 };
|
|
246
|
+
}
|
|
247
|
+
if (entry.count >= RATE_MAX_FAILURES) {
|
|
248
|
+
return {
|
|
249
|
+
limited: true,
|
|
250
|
+
retryAfter: Math.max(1, Math.ceil((entry.windowStart + RATE_WINDOW_MS - Date.now()) / 1000)),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
return { limited: false, retryAfter: 0 };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function recordAuthFailure(ip: string): void {
|
|
257
|
+
const now = Date.now();
|
|
258
|
+
const entry = rateBuckets.get(ip);
|
|
259
|
+
if (entry && now - entry.windowStart < RATE_WINDOW_MS) entry.count += 1;
|
|
260
|
+
else rateBuckets.set(ip, { count: 1, windowStart: now });
|
|
261
|
+
if (rateBuckets.size > 5000) {
|
|
262
|
+
for (const [key, value] of rateBuckets) {
|
|
263
|
+
if (now - value.windowStart >= RATE_WINDOW_MS) rateBuckets.delete(key);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function clearAuthFailures(ip: string): void {
|
|
269
|
+
rateBuckets.delete(ip);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Host / Origin guards (DNS rebinding + cross-origin state changes)
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
/** "localhost:4097" → "localhost"; "[::1]:80" → "::1"; "::1" → "::1". */
|
|
277
|
+
export function hostnameOf(hostHeader: string): string {
|
|
278
|
+
const h = hostHeader.trim().toLowerCase();
|
|
279
|
+
if (h.startsWith("[")) {
|
|
280
|
+
const end = h.indexOf("]");
|
|
281
|
+
return end === -1 ? h.slice(1) : h.slice(1, end);
|
|
282
|
+
}
|
|
283
|
+
const colons = (h.match(/:/g) ?? []).length;
|
|
284
|
+
if (colons === 0) return h;
|
|
285
|
+
if (colons === 1) return h.slice(0, h.indexOf(":"));
|
|
286
|
+
return h; // bare IPv6 (no port, no brackets)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export function isLoopbackHostname(hostname: string): boolean {
|
|
290
|
+
return hostname === "localhost" || hostname === "::1" || /^127(?:\.\d{1,3}){3}$/.test(hostname);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
export function isWildcardHostname(hostname: string): boolean {
|
|
294
|
+
return hostname === "0.0.0.0" || hostname === "::" || hostname === "*" || hostname === "";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function firstHeaderValue(req: Request, name: string): string | undefined {
|
|
298
|
+
const value = req.headers.get(name);
|
|
299
|
+
if (!value) return undefined;
|
|
300
|
+
const first = value.split(",")[0]?.trim();
|
|
301
|
+
return first || undefined;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function forbidden(reason: string): Response {
|
|
305
|
+
return Response.json({ error: "forbidden", reason }, { status: 403 });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Runs before EVERY route (login included). Returns a 403 Response to reject,
|
|
310
|
+
* or null to continue. X-Forwarded-Host (when present, e.g. behind a reverse
|
|
311
|
+
* proxy) names the public host and is allowed; otherwise the Host header must
|
|
312
|
+
* be a loopback name or the configured bind host — anything else is exactly
|
|
313
|
+
* what a DNS-rebinding attack looks like.
|
|
314
|
+
*/
|
|
315
|
+
export function guardRequest(req: Request, configuredHost: string): Response | null {
|
|
316
|
+
const forwardedHost = firstHeaderValue(req, "x-forwarded-host");
|
|
317
|
+
const hostHeader = req.headers.get("host") ?? "";
|
|
318
|
+
const effectiveHost = forwardedHost ?? hostHeader;
|
|
319
|
+
if (!effectiveHost) return forbidden("missing host header");
|
|
320
|
+
|
|
321
|
+
if (!forwardedHost) {
|
|
322
|
+
const hostname = hostnameOf(hostHeader);
|
|
323
|
+
const allowed =
|
|
324
|
+
isLoopbackHostname(hostname) || hostname === hostnameOf(configuredHost);
|
|
325
|
+
if (!allowed) return forbidden("untrusted host header");
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const method = req.method;
|
|
329
|
+
if (method === "POST" || method === "PUT" || method === "DELETE" || method === "PATCH") {
|
|
330
|
+
const origin = req.headers.get("origin");
|
|
331
|
+
// No Origin = curl/scripts/health checks — allowed. "null" = sandboxed
|
|
332
|
+
// context, treat as untrusted. Same-origin = pass.
|
|
333
|
+
if (origin && origin !== "null") {
|
|
334
|
+
const proto =
|
|
335
|
+
firstHeaderValue(req, "x-forwarded-proto") ??
|
|
336
|
+
new URL(req.url).protocol.replace(/:$/, "");
|
|
337
|
+
const expected = `${proto}://${effectiveHost}`.toLowerCase();
|
|
338
|
+
const actual = origin.replace(/\/+$/, "").toLowerCase();
|
|
339
|
+
if (actual !== expected) return forbidden("cross-origin request blocked");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// Login page + handlers
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
function escapeHtml(value: string): string {
|
|
350
|
+
const map: Record<string, string> = {
|
|
351
|
+
"&": "&",
|
|
352
|
+
"<": "<",
|
|
353
|
+
">": ">",
|
|
354
|
+
'"': """,
|
|
355
|
+
"'": "'",
|
|
356
|
+
};
|
|
357
|
+
return value.replace(/[&<>"']/g, (c) => map[c] ?? c);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Where to land after login. Only in-app absolute paths — anything that
|
|
362
|
+
* could leave the origin ("//host", "/\host", non-"/") collapses to "/".
|
|
363
|
+
*/
|
|
364
|
+
export function safeNext(raw: string | null | undefined): string {
|
|
365
|
+
if (!raw) return "/";
|
|
366
|
+
let value = raw;
|
|
367
|
+
try {
|
|
368
|
+
value = decodeURIComponent(value);
|
|
369
|
+
} catch {
|
|
370
|
+
/* keep raw — malformed escapes are not a path we honor anyway */
|
|
371
|
+
}
|
|
372
|
+
if (
|
|
373
|
+
!value.startsWith("/") ||
|
|
374
|
+
value.startsWith("//") ||
|
|
375
|
+
value.includes("\\") ||
|
|
376
|
+
/[\r\n\0]/.test(value) ||
|
|
377
|
+
value.length > 512
|
|
378
|
+
) {
|
|
379
|
+
return "/";
|
|
380
|
+
}
|
|
381
|
+
return value;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const LOGIN_CSP = "default-src 'self'; style-src 'unsafe-inline'";
|
|
385
|
+
|
|
386
|
+
function loginHtmlResponse(opts: { error?: string; next?: string | null; status: number }): Response {
|
|
387
|
+
const next = escapeHtml(safeNext(opts.next));
|
|
388
|
+
const error = opts.error
|
|
389
|
+
? ` <div class="err">${escapeHtml(opts.error)}</div>\n`
|
|
390
|
+
: "";
|
|
391
|
+
const html = `<!doctype html>
|
|
392
|
+
<html lang="en">
|
|
393
|
+
<head>
|
|
394
|
+
<meta charset="utf-8">
|
|
395
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
396
|
+
<meta name="robots" content="noindex">
|
|
397
|
+
<title>opencode webui — sign in</title>
|
|
398
|
+
<style>
|
|
399
|
+
:root{color-scheme:dark}
|
|
400
|
+
*{box-sizing:border-box}
|
|
401
|
+
body{margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0d10;color:#e6e8eb;font:15px/1.5 ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
|
|
402
|
+
.card{width:min(360px,90vw);background:#15181d;border:1px solid #262b33;border-radius:12px;padding:28px;box-shadow:0 8px 30px rgba(0,0,0,.4)}
|
|
403
|
+
h1{font-size:16px;margin:0 0 4px;font-weight:600;letter-spacing:.01em}
|
|
404
|
+
p.sub{margin:0 0 20px;color:#8b93a1;font-size:13px}
|
|
405
|
+
label{display:block;font-size:12px;color:#8b93a1;margin-bottom:6px}
|
|
406
|
+
input[type=password]{width:100%;padding:10px 12px;background:#0b0d10;border:1px solid #2c333d;border-radius:8px;color:#e6e8eb;font-size:14px;outline:none}
|
|
407
|
+
input[type=password]:focus{border-color:#4a7dff}
|
|
408
|
+
button{margin-top:14px;width:100%;padding:10px;background:#4a7dff;border:0;border-radius:8px;color:#fff;font-size:14px;font-weight:600;cursor:pointer}
|
|
409
|
+
button:hover{background:#3d6ef0}
|
|
410
|
+
.err{margin-bottom:14px;padding:9px 12px;background:#3a1d20;border:1px solid #6e2f36;color:#f2a6ad;border-radius:8px;font-size:13px}
|
|
411
|
+
</style>
|
|
412
|
+
</head>
|
|
413
|
+
<body>
|
|
414
|
+
<main class="card">
|
|
415
|
+
<h1>opencode webui</h1>
|
|
416
|
+
<p class="sub">Enter your access password.</p>
|
|
417
|
+
<form method="POST" action="/api/auth/login">
|
|
418
|
+
<input type="hidden" name="next" value="${next}">
|
|
419
|
+
<label for="password">Password</label>
|
|
420
|
+
<input id="password" name="password" type="password" autofocus autocomplete="current-password" required>
|
|
421
|
+
${error} <button type="submit">Sign in</button>
|
|
422
|
+
</form>
|
|
423
|
+
</main>
|
|
424
|
+
</body>
|
|
425
|
+
</html>
|
|
426
|
+
`;
|
|
427
|
+
return new Response(html, {
|
|
428
|
+
status: opts.status,
|
|
429
|
+
headers: {
|
|
430
|
+
"content-type": "text/html; charset=utf-8",
|
|
431
|
+
"x-frame-options": "DENY",
|
|
432
|
+
"content-security-policy": LOGIN_CSP,
|
|
433
|
+
"cache-control": "no-store",
|
|
434
|
+
},
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function loginPageResponse(url: URL): Response {
|
|
439
|
+
return loginHtmlResponse({ next: url.searchParams.get("next"), status: 200 });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export function logoutResponse(): Response {
|
|
443
|
+
return new Response(null, {
|
|
444
|
+
status: 302,
|
|
445
|
+
headers: { location: "/login", "set-cookie": clearSessionCookieHeader() },
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 401 JSON for /api/*, 302 → /login?next=… for pages (dist/, /, dev root). */
|
|
450
|
+
export function unauthorizedResponse(url: URL): Response {
|
|
451
|
+
if (url.pathname.startsWith("/api")) {
|
|
452
|
+
return Response.json({ error: "unauthorized" }, { status: 401 });
|
|
453
|
+
}
|
|
454
|
+
const next = encodeURIComponent(safeNext(url.pathname + url.search));
|
|
455
|
+
return new Response(null, { status: 302, headers: { location: `/login?next=${next}` } });
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* POST /api/auth/login — accepts JSON `{password, next?}` (programmatic
|
|
460
|
+
* clients) or the login page's form-encoded POST (keeps the page JS-free
|
|
461
|
+
* under its strict CSP). Failures: 401 (HTML with an inline error for form
|
|
462
|
+
* posts), 429 + Retry-After once an IP burns its 5 failures.
|
|
463
|
+
*/
|
|
464
|
+
export async function handleLogin(
|
|
465
|
+
req: Request,
|
|
466
|
+
ip: string,
|
|
467
|
+
secret: Buffer,
|
|
468
|
+
passwordDigest: Buffer,
|
|
469
|
+
): Promise<Response> {
|
|
470
|
+
let wantsHtml = (req.headers.get("content-type") ?? "").includes("form");
|
|
471
|
+
const limit = rateLimitStatus(ip);
|
|
472
|
+
if (limit.limited) {
|
|
473
|
+
const message = `Too many attempts — try again in ${limit.retryAfter}s.`;
|
|
474
|
+
if (wantsHtml) return loginHtmlResponse({ error: message, status: 429 });
|
|
475
|
+
return Response.json(
|
|
476
|
+
{ error: message },
|
|
477
|
+
{ status: 429, headers: { "retry-after": String(limit.retryAfter) } },
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
let password = "";
|
|
482
|
+
let next = "";
|
|
483
|
+
try {
|
|
484
|
+
if ((req.headers.get("content-type") ?? "").includes("application/json")) {
|
|
485
|
+
const body = (await req.json()) as { password?: unknown; next?: unknown };
|
|
486
|
+
password = typeof body.password === "string" ? body.password : "";
|
|
487
|
+
next = typeof body.next === "string" ? body.next : "";
|
|
488
|
+
} else {
|
|
489
|
+
const form = await req.formData();
|
|
490
|
+
password = String(form.get("password") ?? "");
|
|
491
|
+
next = String(form.get("next") ?? "");
|
|
492
|
+
wantsHtml = true;
|
|
493
|
+
}
|
|
494
|
+
} catch {
|
|
495
|
+
// Generic on purpose — request bodies never reach the log.
|
|
496
|
+
console.error("[webui] login request could not be parsed");
|
|
497
|
+
if (wantsHtml) return loginHtmlResponse({ error: "Bad request.", status: 400 });
|
|
498
|
+
return Response.json({ error: "invalid request body" }, { status: 400 });
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (!verifyPassword(password, passwordDigest)) {
|
|
502
|
+
recordAuthFailure(ip);
|
|
503
|
+
if (wantsHtml) return loginHtmlResponse({ error: "Incorrect password.", next, status: 401 });
|
|
504
|
+
return Response.json({ error: "incorrect password" }, { status: 401 });
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
clearAuthFailures(ip);
|
|
508
|
+
const secure = firstHeaderValue(req, "x-forwarded-proto") === "https";
|
|
509
|
+
const cookie = sessionCookieHeader(signToken(secret), secure);
|
|
510
|
+
const target = safeNext(next);
|
|
511
|
+
if (wantsHtml) {
|
|
512
|
+
return new Response(null, { status: 303, headers: { location: target, "set-cookie": cookie } });
|
|
513
|
+
}
|
|
514
|
+
return Response.json({ ok: true, next: target }, { headers: { "set-cookie": cookie } });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** Peer IP of a request (the honest TCP peer — never a spoofable header). */
|
|
518
|
+
export function peerIP(
|
|
519
|
+
req: Request,
|
|
520
|
+
// Structural: avoids depending on Bun's generic Server type here.
|
|
521
|
+
server: { requestIP(req: Request): { address: string } | null },
|
|
522
|
+
): string {
|
|
523
|
+
return server.requestIP(req)?.address ?? "unknown";
|
|
524
|
+
}
|