coxpit 4.7.0 → 5.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/.env.example +5 -3
- package/README.md +17 -3
- package/bin/coxpit.js +30 -6
- package/package.json +1 -1
- package/src/auth.ts +43 -12
- package/src/authkey.ts +260 -0
- package/src/board.ts +510 -181
- package/src/icons.ts +46 -0
- package/src/index.ts +22 -6
- package/src/login.ts +131 -0
- package/src/server.ts +129 -7
package/.env.example
CHANGED
|
@@ -3,8 +3,10 @@ COXPIT_HOST=127.0.0.1
|
|
|
3
3
|
COXPIT_PORT=8210
|
|
4
4
|
COXPIT_DB=./coxpit.db
|
|
5
5
|
|
|
6
|
-
#
|
|
7
|
-
|
|
6
|
+
# 접근키(access-key) 인증. 인증은 노출 바인드일 때만 적용된다:
|
|
7
|
+
# - COXPIT_HOST=127.0.0.1 (기본) → 로컬 신뢰, 로그인 없음(무마찰).
|
|
8
|
+
# - COXPIT_HOST=0.0.0.0 등 노출 → 브랜디드 언락/셋업 페이지 + 세션 쿠키로 접근키 요구.
|
|
9
|
+
# COXPIT_AUTH_PASS = 접근키(back-compat, 유저명 없음). 비우면 저장된 키 또는 첫 실행 셋업.
|
|
8
10
|
COXPIT_AUTH_PASS=
|
|
9
|
-
#
|
|
11
|
+
# 인증을 완전히 끄고 앞단 게이트웨이(Cloudflare Access / Tailscale)에 위임:
|
|
10
12
|
# COXPIT_AUTH_DISABLED=1
|
package/README.md
CHANGED
|
@@ -74,8 +74,8 @@ Your keys and login never touch coxpit's config or database.
|
|
|
74
74
|
|---|---|---|
|
|
75
75
|
| `COXPIT_HOST` / `COXPIT_PORT` | `127.0.0.1` / `8210` | daemon bind |
|
|
76
76
|
| `COXPIT_DB` | `~/.coxpit/coxpit.db` | SQLite (libSQL) file (a legacy `./coxpit.db` in the cwd is still honored) |
|
|
77
|
-
| `COXPIT_AUTH_PASS`
|
|
78
|
-
| `COXPIT_AUTH_DISABLED` | — | `1`
|
|
77
|
+
| `COXPIT_AUTH_PASS` | — | access key (back-compat, key-only). If set on an **exposed** bind, the branded unlock page asks for this key — no username. Empty = use the stored key, or first-run setup |
|
|
78
|
+
| `COXPIT_AUTH_DISABLED` | — | `1` forces auth **off** (delegate to a front gateway like Cloudflare Access / Tailscale) |
|
|
79
79
|
| `COXPIT_SSH_KEY` | — | private key for remote machines (else ssh defaults/agent) |
|
|
80
80
|
| `COXPIT_AGENT_REAL` | — | `1` = real agent CLI by default (credits!) |
|
|
81
81
|
| `COXPIT_AGENT_BIN` | `claude` | Claude Code command |
|
|
@@ -86,7 +86,19 @@ Your keys and login never touch coxpit's config or database.
|
|
|
86
86
|
| `COXPIT_WEBHOOK_URL` | — | POSTs `{event:"run.settled",run:{...}}` when a run finishes — wire it to Telegram, Slack, anything |
|
|
87
87
|
| `COXPIT_PUBLIC_URL` | — | if set, the webhook payload adds `url: <base>/?run=<id>` — tap it on your phone and the board opens that run |
|
|
88
88
|
|
|
89
|
-
Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep
|
|
89
|
+
Running on the open internet? Put it behind your own front door (Tailscale, Cloudflare Access, a reverse proxy with TLS) and keep the access key on — it exposes shells.
|
|
90
|
+
|
|
91
|
+
## Access key & remote access
|
|
92
|
+
|
|
93
|
+
Coxpit gates itself with a single **access key** (one owner, no accounts, no username) — but only when it's **exposed**:
|
|
94
|
+
|
|
95
|
+
- **Loopback bind** (`COXPIT_HOST=127.0.0.1`, the default) is trusted-local: `npx coxpit` on your own machine is zero-friction, no login page, no key.
|
|
96
|
+
- **Exposed bind** (`COXPIT_HOST=0.0.0.0` or a routable IP) requires the key. First boot with no key opens a **branded setup page** ("Protect this coxpit — set an access key"). To prove you own the box, setup needs the **one-time setup token** printed to the daemon log (Jupyter-style) — *unless* the request is genuinely local (`http://127.0.0.1` with no proxy in front). A request arriving through a tunnel must use the token, so a stranger hitting a fresh public daemon can't claim it.
|
|
97
|
+
- After setup you **unlock once per device** via the same page (session cookie, "Remember this device" = 30 days). No native browser popup — unauthorized API calls just get `401` with no `WWW-Authenticate`.
|
|
98
|
+
- **Back-compat:** set `COXPIT_AUTH_PASS` and that value *is* the key — the resident keeps working, now entered on the branded page (no username).
|
|
99
|
+
- **Reset the key:** `coxpit reset-key` (or `rm ~/.coxpit/auth.json`) then restart → next boot is first-run setup again.
|
|
100
|
+
|
|
101
|
+
Front it with **Cloudflare Access** or **Tailscale** for identity on top (set `COXPIT_AUTH_DISABLED=1` to delegate auth entirely to the gateway). The board's Remote access card detects your Tailscale and can put it on `https://<machine>.<tailnet>.ts.net` in one click.
|
|
90
102
|
|
|
91
103
|
## Platform support
|
|
92
104
|
|
|
@@ -127,3 +139,5 @@ issues privately per **[SECURITY.md](SECURITY.md)** (Coxpit exposes shells).
|
|
|
127
139
|
## License
|
|
128
140
|
|
|
129
141
|
MIT
|
|
142
|
+
|
|
143
|
+
Icons — [Lucide](https://lucide.dev) (ISC). Paths are inlined as an SVG `<symbol>` sprite (no runtime dependency); the ISC notice is kept in [`licenses/lucide.txt`](licenses/lucide.txt).
|
package/bin/coxpit.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
-
import { dirname, join } from 'node:path';
|
|
8
|
-
import {
|
|
7
|
+
import { dirname, join, resolve } from 'node:path';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { readFileSync, existsSync, rmSync } from 'node:fs';
|
|
9
10
|
|
|
10
11
|
const root = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
11
12
|
const entry = join(root, 'src', 'index.ts');
|
|
@@ -23,17 +24,25 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
23
24
|
|
|
24
25
|
Usage:
|
|
25
26
|
coxpit start the daemon (board at http://<host>:<port>)
|
|
27
|
+
coxpit reset-key forget the stored access key (next boot = first-run setup)
|
|
26
28
|
coxpit --version, -v print version
|
|
27
29
|
coxpit --help, -h show this help
|
|
28
30
|
|
|
31
|
+
Access-key auth:
|
|
32
|
+
On first boot with no key, open the board to set an access key (a one-time
|
|
33
|
+
setup token is printed to the log — needed unless you visit http://127.0.0.1
|
|
34
|
+
directly). After that you unlock once per device via the branded page (no
|
|
35
|
+
username). To change or clear the key, run "coxpit reset-key" and restart, or
|
|
36
|
+
delete ~/.coxpit/auth.json. Precedence: COXPIT_AUTH_DISABLED > COXPIT_AUTH_PASS
|
|
37
|
+
env (back-compat, key-only) > stored key > first-run setup.
|
|
38
|
+
|
|
29
39
|
Configuration is env-only (a .env file in the cwd is loaded):
|
|
30
40
|
COXPIT_HOST bind host (default 127.0.0.1)
|
|
31
41
|
COXPIT_PORT bind port (default 8210)
|
|
32
42
|
COXPIT_DB SQLite (libSQL) file (default ~/.coxpit/coxpit.db)
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
COXPIT_AUTH_DISABLED 1 disables auth (local dev only)
|
|
43
|
+
COXPIT_AUTH_PASS access key (empty = stored key / first-run setup;
|
|
44
|
+
env-mode is back-compat, entered on the branded page)
|
|
45
|
+
COXPIT_AUTH_DISABLED 1 disables auth (delegate to a front gateway)
|
|
37
46
|
COXPIT_SSH_KEY private key for remote machines (else ssh defaults/agent)
|
|
38
47
|
COXPIT_AGENT_REAL 1 = real agent CLI by default (credits!)
|
|
39
48
|
COXPIT_AGENT_BIN agent command (default claude)
|
|
@@ -41,6 +50,21 @@ Configuration is env-only (a .env file in the cwd is loaded):
|
|
|
41
50
|
process.exit(0);
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
// reset-key — 저장된 접근키(auth.json)를 지운다. 다음 부팅은 다시 첫 실행 셋업.
|
|
54
|
+
// auth.json 은 DB 와 같은 폴더에 산다(COXPIT_DB 존중, 기본 ~/.coxpit).
|
|
55
|
+
if (args[0] === 'reset-key') {
|
|
56
|
+
const dbEnv = process.env.COXPIT_DB;
|
|
57
|
+
const dir = dbEnv ? dirname(resolve(dbEnv)) : join(homedir(), '.coxpit');
|
|
58
|
+
const authPath = join(dir, 'auth.json');
|
|
59
|
+
if (existsSync(authPath)) {
|
|
60
|
+
rmSync(authPath);
|
|
61
|
+
console.log(`[coxpit] removed ${authPath} — next boot is first-run setup (set a new access key).`);
|
|
62
|
+
} else {
|
|
63
|
+
console.log(`[coxpit] no stored key at ${authPath} (nothing to reset).`);
|
|
64
|
+
}
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
|
|
44
68
|
// --import 의 bare 'tsx' 는 사용자 cwd 기준으로 해석돼 npx 실행에서 깨진다 —
|
|
45
69
|
// 패키지 루트 기준으로 절대경로 해석해 넘긴다.
|
|
46
70
|
const require_ = createRequire(join(root, 'package.json'));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "coxpit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Self-hosted cockpit for running a fleet of AI coding agents across your own machines — parallel worktree runs, live board, compare & merge, web terminal, design capture.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/auth.ts
CHANGED
|
@@ -1,29 +1,60 @@
|
|
|
1
1
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
|
2
2
|
import { config } from './config';
|
|
3
|
+
import {
|
|
4
|
+
authMode, verifyKey, verifySession, readCookie, SESSION_COOKIE,
|
|
5
|
+
} from './authkey';
|
|
6
|
+
import { loginPageHTML } from './login';
|
|
3
7
|
|
|
4
8
|
// /api/design/capture · /design/bookmarklet.js 는 외부 앱(북마클릿)에서 오므로
|
|
5
|
-
//
|
|
9
|
+
// 헤더/쿠키를 못 싣는다 — 라우트 자체가 캡처 키(?k=)를 검증한다.
|
|
6
10
|
// /api/agent/subtasks 는 에이전트 Bearer 토큰(라우트 자체 검증), /share/* 는 토큰 URL 이 곧 능력.
|
|
7
|
-
|
|
11
|
+
// /api/auth/* 는 언락/셋업 자체라 게이트 앞이어야 한다(체험 전 인증 불가).
|
|
12
|
+
const EXEMPT = new Set([
|
|
13
|
+
'/api/health', '/api/design/capture', '/design/bookmarklet.js', '/api/agent/subtasks',
|
|
14
|
+
'/api/auth/setup', '/api/auth/unlock', '/api/auth/logout',
|
|
15
|
+
]);
|
|
8
16
|
const EXEMPT_PREFIX = ['/share/'];
|
|
9
17
|
|
|
18
|
+
/** 이 요청이 HTML 문서를 원하는 GET 인가(→ 팝업 대신 login/setup 페이지 서빙). */
|
|
19
|
+
function wantsHtml(req: FastifyRequest): boolean {
|
|
20
|
+
if (req.method !== 'GET') return false;
|
|
21
|
+
const accept = String(req.headers['accept'] ?? '');
|
|
22
|
+
return accept.includes('text/html');
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
/**
|
|
11
|
-
* 인증 게이트 —
|
|
12
|
-
*
|
|
26
|
+
* 인증 게이트 — 접근키(access-key) 기반.
|
|
27
|
+
* 순서: DISABLED → pass · EXEMPT → pass · 유효한 세션 쿠키 → pass ·
|
|
28
|
+
* 유효한 Basic 헤더(자동화 back-compat, 키만) → pass · else 거부.
|
|
29
|
+
* 거부 시 HTML GET 은 login/setup 페이지(200), 그 외는 401(WWW-Authenticate 없음 → 팝업 없음).
|
|
13
30
|
*/
|
|
14
31
|
export async function authGate(req: FastifyRequest, reply: FastifyReply): Promise<void> {
|
|
15
|
-
|
|
32
|
+
const m = authMode();
|
|
33
|
+
if (m.mode === 'disabled') return;
|
|
34
|
+
|
|
16
35
|
const path = req.url.split('?')[0] ?? '';
|
|
17
36
|
if (EXEMPT.has(path)) return;
|
|
18
37
|
if (EXEMPT_PREFIX.some((p) => path.startsWith(p))) return;
|
|
19
38
|
|
|
39
|
+
// 세션 쿠키(언락 완료 기기) — 무상태 서명 검증.
|
|
40
|
+
const sess = readCookie(req.headers.cookie, SESSION_COOKIE);
|
|
41
|
+
if (sess && verifySession(sess, m)) return;
|
|
42
|
+
|
|
43
|
+
// Basic 헤더 back-compat(자동화 전용) — 유저명은 무시, 비밀번호 자리를 키로 검증.
|
|
20
44
|
const h = req.headers.authorization ?? '';
|
|
21
|
-
if (h.startsWith('Basic ')
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
45
|
+
if (h.startsWith('Basic ')) {
|
|
46
|
+
try {
|
|
47
|
+
const decoded = Buffer.from(h.slice(6), 'base64').toString('utf8');
|
|
48
|
+
const idx = decoded.indexOf(':');
|
|
49
|
+
const p = idx >= 0 ? decoded.slice(idx + 1) : decoded;
|
|
50
|
+
if (verifyKey(p, m)) return;
|
|
51
|
+
} catch { /* malformed header → 아래에서 거부 */ }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 거부 — HTML GET 은 페이지, 나머지는 401(팝업 없음).
|
|
55
|
+
if (wantsHtml(req)) {
|
|
56
|
+
await reply.type('text/html').code(200).send(loginPageHTML(m.mode === 'setup'));
|
|
57
|
+
return;
|
|
27
58
|
}
|
|
28
|
-
await reply.
|
|
59
|
+
await reply.code(401).send({ error: 'unauthorized' });
|
|
29
60
|
}
|
package/src/authkey.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
// 접근키(access-key) 인증의 저장·해시·쿠키 서명·레이트리밋 — 전부 node 내장 crypto(신규 deps 0).
|
|
2
|
+
// 브라우저 basic-auth 팝업 대신 브랜디드 언락/셋업 페이지 + 서명된 세션 쿠키를 뒷받침한다.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { scryptSync, randomBytes, timingSafeEqual, createHmac } from 'node:crypto';
|
|
6
|
+
import { config } from './config';
|
|
7
|
+
|
|
8
|
+
// 저장 위치 = DB 와 같은 데이터 폴더(~/.coxpit). auth.json 은 해시만 담는다(평문 키 없음).
|
|
9
|
+
const AUTH_PATH = path.join(path.dirname(path.resolve(config.dbPath)), 'auth.json');
|
|
10
|
+
|
|
11
|
+
export interface StoredAuth {
|
|
12
|
+
algo: 'scrypt';
|
|
13
|
+
salt: string; // hex
|
|
14
|
+
hash: string; // hex (scrypt(key, salt))
|
|
15
|
+
cookieSecret: string; // hex — 세션 쿠키 HMAC 서명용
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const SCRYPT_N = 16384;
|
|
19
|
+
const SCRYPT_KEYLEN = 32;
|
|
20
|
+
|
|
21
|
+
function scryptHex(key: string, saltHex: string): string {
|
|
22
|
+
return scryptSync(key, Buffer.from(saltHex, 'hex'), SCRYPT_KEYLEN, { N: SCRYPT_N, r: 8, p: 1 }).toString('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** 두 hex 문자열을 상수시간 비교(길이 방어 포함). */
|
|
26
|
+
export function constantEqHex(aHex: string, bHex: string): boolean {
|
|
27
|
+
try {
|
|
28
|
+
const a = Buffer.from(aHex, 'hex');
|
|
29
|
+
const b = Buffer.from(bHex, 'hex');
|
|
30
|
+
if (a.length === 0 || a.length !== b.length) return false;
|
|
31
|
+
return timingSafeEqual(a, b);
|
|
32
|
+
} catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let cache: StoredAuth | null | undefined; // undefined = 미로드, null = 파일 없음
|
|
38
|
+
|
|
39
|
+
/** 저장된 인증(있으면). env-mode 여도 파일이 있을 수 있으나 precedence 는 authMode 가 결정. */
|
|
40
|
+
export function loadStored(): StoredAuth | null {
|
|
41
|
+
if (cache !== undefined) return cache;
|
|
42
|
+
try {
|
|
43
|
+
const raw = fs.readFileSync(AUTH_PATH, 'utf8');
|
|
44
|
+
const j = JSON.parse(raw) as Partial<StoredAuth>;
|
|
45
|
+
if (j && j.algo === 'scrypt' && typeof j.salt === 'string' && typeof j.hash === 'string' && typeof j.cookieSecret === 'string') {
|
|
46
|
+
cache = { algo: 'scrypt', salt: j.salt, hash: j.hash, cookieSecret: j.cookieSecret };
|
|
47
|
+
return cache;
|
|
48
|
+
}
|
|
49
|
+
cache = null;
|
|
50
|
+
return null;
|
|
51
|
+
} catch {
|
|
52
|
+
cache = null;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 키를 해시해서 저장(첫 실행 셋업). 새 cookieSecret 을 발급. 평문 키/시크릿은 로그 금지. */
|
|
58
|
+
export function storeKey(key: string): StoredAuth {
|
|
59
|
+
const salt = randomBytes(16).toString('hex');
|
|
60
|
+
const hash = scryptHex(key, salt);
|
|
61
|
+
const cookieSecret = randomBytes(32).toString('hex');
|
|
62
|
+
const rec: StoredAuth = { algo: 'scrypt', salt, hash, cookieSecret };
|
|
63
|
+
fs.mkdirSync(path.dirname(AUTH_PATH), { recursive: true });
|
|
64
|
+
fs.writeFileSync(AUTH_PATH, JSON.stringify(rec), { mode: 0o600 });
|
|
65
|
+
try { fs.chmodSync(AUTH_PATH, 0o600); } catch { /* best effort */ }
|
|
66
|
+
cache = rec;
|
|
67
|
+
return rec;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 저장된 키 파일 삭제(reset-key). 다음 부팅은 다시 첫 실행 셋업. */
|
|
71
|
+
export function clearStored(): boolean {
|
|
72
|
+
cache = undefined;
|
|
73
|
+
try {
|
|
74
|
+
if (fs.existsSync(AUTH_PATH)) { fs.rmSync(AUTH_PATH); return true; }
|
|
75
|
+
return false;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function authFilePath(): string { return AUTH_PATH; }
|
|
82
|
+
|
|
83
|
+
// ── precedence 결정 ────────────────────────────────────────────────
|
|
84
|
+
// 1) COXPIT_AUTH_DISABLED=1 → off 2) env COXPIT_AUTH_PASS → env-mode
|
|
85
|
+
// 3) auth.json 존재 → stored 4) 아무것도 없음 → 첫 실행 셋업
|
|
86
|
+
export type AuthMode =
|
|
87
|
+
| { mode: 'disabled' }
|
|
88
|
+
| { mode: 'env'; key: string }
|
|
89
|
+
| { mode: 'stored'; rec: StoredAuth }
|
|
90
|
+
| { mode: 'setup' };
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 데몬이 외부에 노출된 바인드인가 — loopback-only(127.0.0.1/::1/localhost)면 로컬 신뢰(false),
|
|
94
|
+
* 0.0.0.0 이나 라우팅 가능한 IP 면 노출(true). 노출일 때만 접근키 인증을 적용한다.
|
|
95
|
+
* (npx coxpit 로컬 = 무마찰; 레지던트 COXPIT_HOST=0.0.0.0 = 키 필요.)
|
|
96
|
+
*/
|
|
97
|
+
export function isExposedBind(): boolean {
|
|
98
|
+
const host = (config.host ?? '').trim().toLowerCase();
|
|
99
|
+
if (host === '' || host === '127.0.0.1' || host === '::1' || host === 'localhost' || host === '::ffff:127.0.0.1') {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function authMode(): AuthMode {
|
|
106
|
+
if (config.auth.disabled) return { mode: 'disabled' };
|
|
107
|
+
// loopback-only 바인드 = 로컬 신뢰 → 인증 없음(login/setup 페이지도 없음).
|
|
108
|
+
if (!isExposedBind()) return { mode: 'disabled' };
|
|
109
|
+
if (config.auth.pass !== '') return { mode: 'env', key: config.auth.pass };
|
|
110
|
+
const rec = loadStored();
|
|
111
|
+
if (rec) return { mode: 'stored', rec };
|
|
112
|
+
return { mode: 'setup' };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 인증이 실질적으로 열려있나(비번 없음) — Funnel 가드·authOpen 배지용. */
|
|
116
|
+
export function authIsOpen(): boolean {
|
|
117
|
+
const m = authMode();
|
|
118
|
+
return m.mode === 'disabled' || m.mode === 'setup';
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 주어진 키가 현재 구성된 키와 일치하나(상수시간). env/stored 양쪽. */
|
|
122
|
+
export function verifyKey(key: string, m: AuthMode = authMode()): boolean {
|
|
123
|
+
if (m.mode === 'env') {
|
|
124
|
+
// env 는 평문 비교지만 상수시간으로 — 같은 salt 로 양쪽 해시 후 비교.
|
|
125
|
+
const salt = 'coxpit-env-mode-fixed-salt';
|
|
126
|
+
return constantEqHex(scryptHex(key, Buffer.from(salt).toString('hex')), scryptHex(m.key, Buffer.from(salt).toString('hex')));
|
|
127
|
+
}
|
|
128
|
+
if (m.mode === 'stored') {
|
|
129
|
+
return constantEqHex(scryptHex(key, m.rec.salt), m.rec.hash);
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** 쿠키 서명 시크릿 — stored 면 cookieSecret, env 면 그 키에서 파생(무상태). */
|
|
135
|
+
function cookieSecretFor(m: AuthMode = authMode()): string {
|
|
136
|
+
if (m.mode === 'stored') return m.rec.cookieSecret;
|
|
137
|
+
if (m.mode === 'env') return scryptHex(m.key, Buffer.from('coxpit-cookie-derive').toString('hex'));
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const SESSION_COOKIE = 'coxpit_sess';
|
|
142
|
+
|
|
143
|
+
// 쿠키 값 = "<expiryMs>.<hmac>" — 서버 세션 스토어 없이 stateless 검증.
|
|
144
|
+
// expiry=0 → 세션 쿠키(무만료 스탬프, 브라우저 종료 시 소멸)지만 서명은 항상 검증.
|
|
145
|
+
export function signSession(expiryMs: number, m: AuthMode = authMode()): string {
|
|
146
|
+
const secret = cookieSecretFor(m);
|
|
147
|
+
const stamp = String(expiryMs);
|
|
148
|
+
const mac = createHmac('sha256', secret).update(stamp).digest('hex');
|
|
149
|
+
return stamp + '.' + mac;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function verifySession(value: string, m: AuthMode = authMode()): boolean {
|
|
153
|
+
const secret = cookieSecretFor(m);
|
|
154
|
+
if (!secret) return false;
|
|
155
|
+
const dot = value.lastIndexOf('.');
|
|
156
|
+
if (dot <= 0) return false;
|
|
157
|
+
const stamp = value.slice(0, dot);
|
|
158
|
+
const mac = value.slice(dot + 1);
|
|
159
|
+
const expiry = Number(stamp);
|
|
160
|
+
if (!Number.isFinite(expiry)) return false;
|
|
161
|
+
const want = createHmac('sha256', secret).update(stamp).digest('hex');
|
|
162
|
+
if (!constantEqHex(mac, want)) return false;
|
|
163
|
+
if (expiry !== 0 && Date.now() > expiry) return false; // 만료
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** 요청 헤더에서 쿠키 하나 파싱(라이브러리 없이 최소 수동). */
|
|
168
|
+
export function readCookie(cookieHeader: string | undefined, name: string): string | null {
|
|
169
|
+
if (!cookieHeader) return null;
|
|
170
|
+
for (const part of cookieHeader.split(';')) {
|
|
171
|
+
const eq = part.indexOf('=');
|
|
172
|
+
if (eq < 0) continue;
|
|
173
|
+
const k = part.slice(0, eq).trim();
|
|
174
|
+
if (k === name) return decodeURIComponent(part.slice(eq + 1).trim());
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── 레이트리밋(per-client, in-memory, 백오프) ───────────────────────
|
|
180
|
+
interface Bucket { fails: number; blockedUntil: number }
|
|
181
|
+
const buckets = new Map<string, Bucket>();
|
|
182
|
+
const FREE_ATTEMPTS = 5;
|
|
183
|
+
// 5회 초과부터 창이 커진다: 5s → 30s → 2m → 5m(상한)
|
|
184
|
+
const BACKOFF_MS = [5_000, 30_000, 120_000, 300_000];
|
|
185
|
+
|
|
186
|
+
export function clientKey(headers: Record<string, unknown>, socketIp: string): string {
|
|
187
|
+
const h = (k: string): string => {
|
|
188
|
+
const v = headers[k];
|
|
189
|
+
if (Array.isArray(v)) return String(v[0] ?? '');
|
|
190
|
+
return typeof v === 'string' ? v : '';
|
|
191
|
+
};
|
|
192
|
+
const fwd = h('x-forwarded-for').split(',')[0]!.trim();
|
|
193
|
+
return h('cf-connecting-ip') || fwd || socketIp || 'unknown';
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** 지금 시도해도 되나. 막혀있으면 { blocked:true, retryMs }. */
|
|
197
|
+
export function rateCheck(id: string): { blocked: boolean; retryMs: number; attemptsLeft: number } {
|
|
198
|
+
const b = buckets.get(id);
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
if (b && b.blockedUntil > now) {
|
|
201
|
+
return { blocked: true, retryMs: b.blockedUntil - now, attemptsLeft: 0 };
|
|
202
|
+
}
|
|
203
|
+
const fails = b ? b.fails : 0;
|
|
204
|
+
return { blocked: false, retryMs: 0, attemptsLeft: Math.max(0, FREE_ATTEMPTS - fails) };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 실패 1건 기록 → 필요 시 블록 창 설정. 반환은 이후 상태(다음 시도 안내용). */
|
|
208
|
+
export function rateFail(id: string): { retryMs: number; attemptsLeft: number } {
|
|
209
|
+
const now = Date.now();
|
|
210
|
+
const b = buckets.get(id) ?? { fails: 0, blockedUntil: 0 };
|
|
211
|
+
b.fails += 1;
|
|
212
|
+
if (b.fails > FREE_ATTEMPTS) {
|
|
213
|
+
const over = b.fails - FREE_ATTEMPTS - 1;
|
|
214
|
+
const win = BACKOFF_MS[Math.min(over, BACKOFF_MS.length - 1)]!;
|
|
215
|
+
b.blockedUntil = now + win;
|
|
216
|
+
}
|
|
217
|
+
buckets.set(id, b);
|
|
218
|
+
const blockedFor = b.blockedUntil > now ? b.blockedUntil - now : 0;
|
|
219
|
+
return { retryMs: blockedFor, attemptsLeft: Math.max(0, FREE_ATTEMPTS - b.fails) };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** 성공 시 리셋. */
|
|
223
|
+
export function rateReset(id: string): void { buckets.delete(id); }
|
|
224
|
+
|
|
225
|
+
// ── 첫 실행 셋업 토큰(Jupyter 스타일) ──────────────────────────────
|
|
226
|
+
// 부팅 시 키가 없으면 1회용 토큰을 stdout(=로그)에 찍는다. 로그 접근 = 머신 소유.
|
|
227
|
+
let setupToken: string | null = null;
|
|
228
|
+
|
|
229
|
+
export function ensureSetupToken(): string {
|
|
230
|
+
if (!setupToken) setupToken = randomBytes(24).toString('hex');
|
|
231
|
+
return setupToken;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getSetupToken(): string | null { return setupToken; }
|
|
235
|
+
|
|
236
|
+
export function verifySetupToken(token: string): boolean {
|
|
237
|
+
if (!setupToken || !token) return false;
|
|
238
|
+
return constantEqHex(Buffer.from(token).toString('hex'), Buffer.from(setupToken).toString('hex'));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 셋업 anti-claim — 이 요청이 증명 가능한 소유자인가.
|
|
243
|
+
* (A) 셋업 토큰 일치(로그 접근자), 또는
|
|
244
|
+
* (B) 진짜 로컬: 소켓 remote 가 loopback AND forwarding 헤더 부재.
|
|
245
|
+
* 터널을 탄 요청(127.0.0.1 이지만 cf/x-forwarded 헤더 보유)은 (A) 토큰 필수.
|
|
246
|
+
*/
|
|
247
|
+
export function isLoopback(ip: string): boolean {
|
|
248
|
+
return ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1' || ip === 'localhost';
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function setupAllowed(
|
|
252
|
+
headers: Record<string, unknown>,
|
|
253
|
+
remoteIp: string,
|
|
254
|
+
token: string,
|
|
255
|
+
): { ok: boolean; via: 'token' | 'local' | null } {
|
|
256
|
+
if (verifySetupToken(token)) return { ok: true, via: 'token' };
|
|
257
|
+
const hasFwd = headers['x-forwarded-for'] != null || headers['cf-connecting-ip'] != null;
|
|
258
|
+
if (isLoopback(remoteIp) && !hasFwd) return { ok: true, via: 'local' };
|
|
259
|
+
return { ok: false, via: null };
|
|
260
|
+
}
|