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/src/icons.ts ADDED
@@ -0,0 +1,46 @@
1
+ // Lucide 아이콘 스프라이트(ISC 라이선스 · CDN 0 · 인라인). currentColor 상속.
2
+ // board.ts / login.ts 가 공유한다. 경로는 전부 진짜 Lucide SVG(licenses/lucide.txt).
3
+ // 사용: 정적 <svg class="ic"><use href="#i-…"/></svg> · 클라이언트 동적은 ic('name') 헬퍼.
4
+
5
+ /** 숨김 <symbol> 스프라이트. <body> 최상단에 1벌 인라인. */
6
+ export const ICON_SPRITE = `<svg style="position:absolute;width:0;height:0" aria-hidden="true">
7
+ <symbol id="i-lock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></symbol>
8
+ <symbol id="i-unlock" viewBox="0 0 24 24"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 9.9-1"/></symbol>
9
+ <symbol id="i-terminal" viewBox="0 0 24 24"><path d="m4 17 6-6-6-6"/><path d="M12 19h8"/></symbol>
10
+ <symbol id="i-folder" viewBox="0 0 24 24"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/></symbol>
11
+ <symbol id="i-branch" viewBox="0 0 24 24"><line x1="6" x2="6" y1="3" y2="15"/><circle cx="18" cy="6" r="3"/><circle cx="6" cy="18" r="3"/><path d="M18 9a9 9 0 0 1-9 9"/></symbol>
12
+ <symbol id="i-merge" viewBox="0 0 24 24"><circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/></symbol>
13
+ <symbol id="i-play" viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3"/></symbol>
14
+ <symbol id="i-check" viewBox="0 0 24 24"><path d="M20 6 9 17l-5-5"/></symbol>
15
+ <symbol id="i-x" viewBox="0 0 24 24"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
16
+ <symbol id="i-refresh" viewBox="0 0 24 24"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></symbol>
17
+ <symbol id="i-file" viewBox="0 0 24 24"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></symbol>
18
+ <symbol id="i-code" viewBox="0 0 24 24"><path d="m16 18 6-6-6-6"/><path d="m8 6-6 6 6 6"/></symbol>
19
+ <symbol id="i-image" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.09-3.09a2 2 0 0 0-2.82 0L6 21"/></symbol>
20
+ <symbol id="i-message" viewBox="0 0 24 24"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></symbol>
21
+ <symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
22
+ <symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
23
+ <symbol id="i-pencil" viewBox="0 0 24 24"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/></symbol>
24
+ <symbol id="i-trash" viewBox="0 0 24 24"><path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></symbol>
25
+ <symbol id="i-recycle" viewBox="0 0 24 24"><path d="M7 19H4.815a1.83 1.83 0 0 1-1.57-.881 1.785 1.785 0 0 1-.004-1.784L7.196 9.5"/><path d="M11 19h8.203a1.83 1.83 0 0 0 1.556-.89 1.784 1.784 0 0 0 0-1.775l-1.226-2.12"/><path d="m14 16-3 3 3 3"/><path d="M8.293 13.596 4.875 9.5 1.5 13.5"/><path d="m9 12 3-3-3-3"/><path d="M13.5 3.5 17 9.5l-3.5 2"/></symbol>
26
+ <symbol id="i-settings" viewBox="0 0 24 24"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></symbol>
27
+ <symbol id="i-external-link" viewBox="0 0 24 24"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></symbol>
28
+ <symbol id="i-eye" viewBox="0 0 24 24"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></symbol>
29
+ <symbol id="i-alert-triangle" viewBox="0 0 24 24"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></symbol>
30
+ <symbol id="i-bell" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/></symbol>
31
+ <symbol id="i-bell-off" viewBox="0 0 24 24"><path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M17 17H4a1 1 0 0 1-.74-1.673C4.59 13.956 6 12.499 6 8a6 6 0 0 1 .258-1.742"/><path d="m2 2 20 20"/><path d="M8.668 3.01A6 6 0 0 1 18 8c0 2.687.77 4.653 1.707 6.05"/></symbol>
32
+ <symbol id="i-download" viewBox="0 0 24 24"><path d="M12 15V3"/><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><path d="m7 10 5 5 5-5"/></symbol>
33
+ <symbol id="i-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></symbol>
34
+ <symbol id="i-server" viewBox="0 0 24 24"><rect width="20" height="8" x="2" y="2" rx="2"/><rect width="20" height="8" x="2" y="14" rx="2"/><path d="M6 6h.01"/><path d="M6 18h.01"/></symbol>
35
+ <symbol id="i-layers" viewBox="0 0 24 24"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83Z"/><path d="m22 17.65-9.17 4.16a2 2 0 0 1-1.66 0L2 17.65"/><path d="m22 12.65-9.17 4.16a2 2 0 0 1-1.66 0L2 12.65"/></symbol>
36
+ <symbol id="i-target" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></symbol>
37
+ <symbol id="i-archive" viewBox="0 0 24 24"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></symbol>
38
+ </svg>`;
39
+
40
+ /** .ic 아이콘 스타일(1em·currentColor 상속·stroke). board/login 공통. */
41
+ export const ICON_CSS =
42
+ `.ic{width:1em;height:1em;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;vertical-align:-.125em;flex:none}`;
43
+
44
+ // 클라이언트에서 동적 HTML 문자열을 만들 때 쓰는 헬퍼 소스(보드 스크립트에 그대로 삽입).
45
+ // ic('x') => '<svg class="ic"><use href="#i-x"/></svg>'
46
+ export const ICON_JS_HELPER = `function ic(n){ return '<svg class="ic"><use href="#i-'+n+'"/></svg>'; }`;
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { config } from './config';
2
+ import { authMode, ensureSetupToken, isExposedBind } from './authkey';
2
3
  import { db, ensureSchema } from './db';
3
4
  import { machines } from './db/schema';
4
5
  import { acquireDaemonLock } from './lock';
@@ -40,10 +41,25 @@ try {
40
41
  throw e;
41
42
  }
42
43
 
43
- // 인증 켜졌는데 비번이 비면 fail-closed( 요청 401) 실행자가 401 보고 당황하지 않게 명시.
44
- if (!config.auth.disabled && config.auth.pass === '') {
45
- console.warn(
46
- '[coxpit] auth is ON but COXPIT_AUTH_PASS is empty — every request will be rejected (401).\n' +
47
- '[coxpit] Set COXPIT_AUTH_PASS to a password, or COXPIT_AUTH_DISABLED=1 for local dev.',
48
- );
44
+ // 접근키 인증 상태 안내. 실행( 미설정)이면 브랜디드 셋업 페이지 + 1회용 셋업 토큰을 찍는다.
45
+ // (토큰 = Jupyter 스타일: 로그 접근자 = 머신 소유자. 터널/타넷/로컬 어디서 붙어도 이 토큰으로 셋업 가능.)
46
+ {
47
+ const m = authMode();
48
+ if (m.mode === 'disabled') {
49
+ if (config.auth.disabled) {
50
+ console.warn('[coxpit] auth is DISABLED (COXPIT_AUTH_DISABLED=1) — every request is allowed. Front it with your own gateway if exposed.');
51
+ } else if (!isExposedBind()) {
52
+ console.log(`[coxpit] loopback-only bind (${config.host}) — trusted local, no login required. Bind to 0.0.0.0 to require an access key.`);
53
+ }
54
+ } else if (m.mode === 'setup') {
55
+ const token = ensureSetupToken();
56
+ console.log(
57
+ '[coxpit] no access key configured yet — open the board to set one (first-run setup).\n' +
58
+ `[coxpit] one-time setup token (needed unless you visit http://127.0.0.1:${config.port} directly): ${token}`,
59
+ );
60
+ } else if (m.mode === 'env') {
61
+ console.log('[coxpit] access-key auth ON (COXPIT_AUTH_PASS) — the branded unlock page asks for that key.');
62
+ } else {
63
+ console.log('[coxpit] access-key auth ON (stored key) — the branded unlock page asks for your key.');
64
+ }
49
65
  }
package/src/login.ts ADDED
@@ -0,0 +1,131 @@
1
+ // 브랜디드 접근키 언락/셋업 페이지(단일 자가완결 HTML — 빌드 0, 보드 토큰 매치).
2
+ // 브라우저 basic-auth 팝업을 대체한다.
3
+ // ── Safari 쿠키 레이스 수정(v5.0) ──
4
+ // 폼은 real navigation POST(urlencoded, hidden nav=1)로 /api/auth/{unlock,setup} 에 제출한다.
5
+ // 서버는 성공 시 303 → GET /(브라우저가 그 응답의 Set-Cookie 를 커밋한 뒤 이동)로 답하므로,
6
+ // fetch-then-location.replace 가 겪던 "쿠키 미커밋 → 다시 로그인" 레이스가 사라진다.
7
+ // 실패는 서버가 이 페이지를 error 와 함께 다시 렌더한다(JS 없이도 동작). JSON API 는 그대로 유지.
8
+ // setup=true → 첫 실행(키 설정, confirm 필드+토큰 힌트), false → 언락(키 1개).
9
+ import { ICON_SPRITE, ICON_CSS } from './icons.js';
10
+
11
+ /** HTML 속성 값 이스케이프(서버 렌더 에러 문자열 안전 주입용). */
12
+ function escA(s: string): string {
13
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ }
15
+
16
+ export interface LoginOpts { error?: string }
17
+
18
+ /** login/setup 페이지 HTML. setup 이면 셋업(키+확인), 아니면 언락. opts.error 는 서버가 재렌더 시 주입. */
19
+ export function loginPageHTML(setup: boolean, opts: LoginOpts = {}): string {
20
+ const title = setup ? 'set an access key' : 'unlock';
21
+ const initialErr = opts.error ? escA(opts.error) : '';
22
+ const action = setup ? '/api/auth/setup' : '/api/auth/unlock';
23
+ return /* html */ `<!doctype html>
24
+ <html lang="en">
25
+ <head>
26
+ <meta charset="utf-8" />
27
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
28
+ <title>coxpit · ${title}</title>
29
+ <style>
30
+ :root{
31
+ --bg:#0b0d12; --surface:#12151c; --surface2:#171b24; --line:#222835; --line-hi:#2f3648;
32
+ --ink:#dee4ec; --muted:#8792a2; --faint:#5c6675;
33
+ --brand:#4ec9b0; --brand-ink:#062822; --brand-dim:rgba(78,201,176,.13);
34
+ --s-failed:#e25b67; --s-preparing:#d6a249;
35
+ --mono:ui-monospace,'SF Mono',SFMono-Regular,Menlo,Consolas,monospace;
36
+ --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,Roboto,sans-serif;
37
+ --r-card:10px; --r-ctl:8px; --shadow:0 8px 28px rgba(0,0,0,.35);
38
+ }
39
+ *{box-sizing:border-box}
40
+ [hidden]{display:none !important}
41
+ html,body{height:100%}
42
+ html{overscroll-behavior:none}
43
+ body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
44
+ -webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;
45
+ padding:24px;padding:max(24px,env(safe-area-inset-top)) max(24px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(24px,env(safe-area-inset-left));
46
+ overflow-x:hidden}
47
+ ::selection{background:var(--brand-dim)}
48
+ .card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
49
+ border-radius:var(--r-card);box-shadow:var(--shadow);padding:26px 24px 22px}
50
+ .mark{font-family:var(--mono);font-weight:700;color:var(--brand);font-size:17px;letter-spacing:.02em;
51
+ display:flex;align-items:center;gap:9px}
52
+ .glyph{font-size:18px;line-height:1}
53
+ h1{font-size:15px;font-weight:600;margin:16px 0 4px;color:var(--ink)}
54
+ .sub{color:var(--muted);font-size:12.5px;margin:0 0 18px;line-height:1.5}
55
+ .flabel{display:block;font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.12em;
56
+ color:var(--faint);margin:0 0 6px}
57
+ input[type=password]{width:100%;background:#0e1118;border:1px solid var(--line);border-radius:var(--r-ctl);
58
+ color:var(--ink);font-family:var(--mono);font-size:16px;padding:10px 12px;outline:none}
59
+ input[type=password]:focus{border-color:var(--brand)}
60
+ input::placeholder{color:var(--faint)}
61
+ .fld{margin-bottom:14px}
62
+ .row{display:flex;align-items:center;gap:8px;margin:2px 0 16px;color:var(--muted);font-size:12.5px;user-select:none;cursor:pointer}
63
+ .row input{accent-color:var(--brand);width:15px;height:15px}
64
+ .btn{width:100%;background:var(--brand);color:var(--brand-ink);border:0;border-radius:var(--r-ctl);
65
+ font-family:var(--sans);font-weight:600;font-size:14px;padding:11px;cursor:pointer;
66
+ display:flex;align-items:center;justify-content:center;gap:8px}
67
+ .btn:disabled{opacity:.55;cursor:default}
68
+ .err{color:var(--s-failed);font-size:12.5px;min-height:17px;margin:0 0 12px;font-family:var(--mono)}
69
+ .hint{background:var(--surface2);border:1px solid var(--line);border-radius:var(--r-ctl);
70
+ padding:9px 11px;color:var(--muted);font-size:11.5px;line-height:1.55;margin:0 0 16px}
71
+ .hint code{font-family:var(--mono);color:var(--brand);font-size:11px}
72
+ .ft{margin-top:18px;padding-top:14px;border-top:1px solid var(--line);text-align:center}
73
+ .ft a{color:var(--faint);text-decoration:none;font-size:11.5px}
74
+ .ft a:hover{color:var(--muted)}
75
+ ${ICON_CSS}
76
+ .mark .ic{width:18px;height:18px}
77
+ .btn .ic{width:16px;height:16px}
78
+ </style>
79
+ </head>
80
+ <body>
81
+ ${ICON_SPRITE}
82
+ <form class="card" id="f" method="post" action="${action}" autocomplete="off">
83
+ <input type="hidden" name="nav" value="1">
84
+ <div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span>coxpit</span></div>
85
+ <h1>${setup ? 'Protect this coxpit' : 'Unlock this coxpit'}</h1>
86
+ <p class="sub">${setup
87
+ ? 'Set an access key. You&#39;ll enter it once per device — no accounts, no username.'
88
+ : 'Enter your access key. One key, one owner — no username.'}</p>
89
+ ${setup ? `<div class="hint">To prove you own this machine, this first-time setup needs the one-time
90
+ <code>setup token</code> printed in the daemon log &mdash; unless you&#39;re on
91
+ <code>http://127.0.0.1</code> directly. Paste it below if asked.</div>` : ''}
92
+ <div class="err" id="err">${initialErr}</div>
93
+ ${setup ? `<div class="fld"><label class="flabel" for="tok">setup token (from the daemon log)</label>
94
+ <input id="tok" name="token" type="password" placeholder="paste if not on localhost" autocomplete="off"
95
+ autocapitalize="none" autocorrect="off" spellcheck="false"></div>` : ''}
96
+ <div class="fld"><label class="flabel" for="key">access key</label>
97
+ <input id="key" name="key" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;"
98
+ autocomplete="${setup ? 'new-password' : 'current-password'}"
99
+ autocapitalize="none" autocorrect="off" spellcheck="false" autofocus></div>
100
+ ${setup ? `<div class="fld"><label class="flabel" for="key2">confirm access key</label>
101
+ <input id="key2" type="password" placeholder="&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;&#8226;"
102
+ autocomplete="new-password" autocapitalize="none" autocorrect="off" spellcheck="false"></div>` : ''}
103
+ <label class="row"><input id="rem" name="remember" type="checkbox" value="on"${setup ? ' checked' : ''}><span>Remember this device</span></label>
104
+ <button class="btn" id="go" type="submit"><svg class="ic"><use href="#i-${setup ? 'lock' : 'unlock'}"/></svg><span>${setup ? 'Set key &amp; enter' : 'Unlock'}</span></button>
105
+ <div class="ft"><a href="https://github.com/hanmariyang/coxpit-oss#remote-access" target="_blank" rel="noopener">Fronting with Cloudflare Access / Tailscale? &rarr;</a></div>
106
+ </form>
107
+ <script>
108
+ (function(){
109
+ var SETUP = ${setup ? 'true' : 'false'};
110
+ var f = document.getElementById('f');
111
+ var err = document.getElementById('err');
112
+ var go = document.getElementById('go');
113
+ function show(m){ err.textContent = m || ''; }
114
+ // 폼은 native navigation POST 로 제출된다(Safari 가 응답의 Set-Cookie 를 커밋 → 303 GET /).
115
+ // 여기서는 클라이언트 사전검증만: 비면 막고, setup 이면 confirm/길이 확인. 통과 시 native 제출 진행.
116
+ f.addEventListener('submit', function(ev){
117
+ var key = document.getElementById('key').value;
118
+ if (!key){ ev.preventDefault(); show('access key required'); return; }
119
+ if (SETUP){
120
+ var key2 = document.getElementById('key2').value;
121
+ if (key !== key2){ ev.preventDefault(); show('keys do not match'); return; }
122
+ if (key.length < 6){ ev.preventDefault(); show('use at least 6 characters'); return; }
123
+ }
124
+ show('');
125
+ go.disabled = true; // 네비게이션 진행 중 중복 제출 방지(네이티브 제출은 계속)
126
+ });
127
+ })();
128
+ </script>
129
+ </body>
130
+ </html>`;
131
+ }
package/src/server.ts CHANGED
@@ -4,10 +4,15 @@ import { homedir } from 'node:os';
4
4
  import { resolve as presolve, dirname as pdirname, join as pjoin, sep as psep } from 'node:path';
5
5
  import { createRequire } from 'node:module';
6
6
  import { randomBytes } from 'node:crypto';
7
- import Fastify, { type FastifyInstance } from 'fastify';
7
+ import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify';
8
8
  import websocket from '@fastify/websocket';
9
9
  import { eq, inArray, and, like, desc } from 'drizzle-orm';
10
10
  import { authGate } from './auth';
11
+ import { loginPageHTML } from './login';
12
+ import {
13
+ authMode, authIsOpen, verifyKey, storeKey, signSession, SESSION_COOKIE,
14
+ clientKey, rateCheck, rateFail, rateReset, setupAllowed,
15
+ } from './authkey';
11
16
  import { config } from './config';
12
17
  import { db } from './db';
13
18
  import { machines, repos, tasks, agentRuns, agentEvents, designCaptures, shareLinks, taskGroups } from './db/schema';
@@ -175,14 +180,126 @@ function sharePageHTML(
175
180
  export async function buildServer(): Promise<FastifyInstance> {
176
181
  const app = Fastify({ logger: true });
177
182
  await app.register(websocket);
183
+ // urlencoded 본문 파서(deps 0) — login/setup 폼이 real navigation POST 를 보내면
184
+ // 브라우저가 그 응답의 Set-Cookie 를 확정 커밋한다(Safari fetch-then-replace 레이스 회피).
185
+ app.addContentTypeParser(
186
+ 'application/x-www-form-urlencoded',
187
+ { parseAs: 'string' },
188
+ (_req, body, done) => {
189
+ try {
190
+ const out: Record<string, string> = {};
191
+ const p = new URLSearchParams(body as string);
192
+ for (const [k, v] of p.entries()) out[k] = v;
193
+ done(null, out);
194
+ } catch (e) { done(e as Error, undefined); }
195
+ },
196
+ );
178
197
  app.addHook('onRequest', authGate);
179
198
 
180
199
  // 무인증 헬스(외부 감시용)
181
200
  app.get('/api/health', async () => ({ ok: true, name: 'coxpit', version: config.version }));
182
201
 
183
- // 플릿 보드(단일 페이지). 인증 게이트 적용됨.
202
+ // 플릿 보드(단일 페이지). 인증 게이트 적용됨(무인증 요청은 게이트가 login/setup 페이지로 응답).
184
203
  app.get('/', async (_req, reply) => reply.type('text/html').send(BOARD_HTML));
185
204
 
205
+ // ─── 접근키 인증(access-key) ────────────────────────────────────
206
+ // 요청이 tunnel/https 를 탔나 — Secure 쿠키 여부 결정용.
207
+ const isSecureReq = (req: { headers: Record<string, unknown> }): boolean => {
208
+ const proto = String(req.headers['x-forwarded-proto'] ?? '');
209
+ return proto.split(',')[0]!.trim() === 'https' || req.headers['cf-connecting-ip'] != null;
210
+ };
211
+ const REMEMBER_MS = 30 * 24 * 60 * 60 * 1000; // 30d
212
+ // 세션 쿠키 헤더 조립(라이브러리 없이). remember → Max-Age 30d, else 세션 쿠키.
213
+ const setSessionCookie = (
214
+ reply: FastifyReply, req: { headers: Record<string, unknown> }, remember: boolean,
215
+ ): void => {
216
+ const expiry = remember ? Date.now() + REMEMBER_MS : 0;
217
+ const value = signSession(expiry);
218
+ const parts = [
219
+ `${SESSION_COOKIE}=${encodeURIComponent(value)}`,
220
+ 'Path=/', 'HttpOnly', 'SameSite=Lax',
221
+ ];
222
+ if (remember) parts.push(`Max-Age=${Math.floor(REMEMBER_MS / 1000)}`);
223
+ if (isSecureReq(req)) parts.push('Secure');
224
+ reply.header('set-cookie', parts.join('; '));
225
+ };
226
+ const socketIp = (req: { socket?: { remoteAddress?: string } }): string =>
227
+ String(req.socket?.remoteAddress ?? '');
228
+ // real form navigation POST(urlencoded + nav flag) 인가 — 그러면 JSON 대신 303/HTML 로 답한다.
229
+ // 이 경로는 브라우저가 응답의 Set-Cookie 를 커밋한 뒤 GET / 로 이동하므로 Safari 쿠키 레이스가 없다.
230
+ const isFormNav = (req: { body?: unknown; headers: Record<string, unknown> }): boolean => {
231
+ const ct = String(req.headers['content-type'] ?? '');
232
+ const b = (req.body ?? {}) as { nav?: unknown };
233
+ return ct.includes('application/x-www-form-urlencoded') || b.nav != null;
234
+ };
235
+ const truthy = (v: unknown): boolean =>
236
+ v === true || v === 'true' || v === 'on' || v === '1' || v === 1;
237
+
238
+ // 첫 실행 셋업(anti-claim) — 셋업 토큰 일치 OR 진짜 로컬(loopback+no-fwd)만 허용.
239
+ // 키가 이미 있으면 409(단발). 성공 시 해시 저장 + 세션 쿠키.
240
+ app.post('/api/auth/setup', async (req, reply) => {
241
+ const nav = isFormNav(req);
242
+ // form-nav 실패는 로그인 페이지를 에러와 함께 다시 렌더(JSON 자동화는 아래처럼 JSON 유지).
243
+ const fail = (code: number, detail: string): FastifyReply => {
244
+ if (nav) return reply.type('text/html').code(code).send(loginPageHTML(true, { error: detail }));
245
+ return reply.code(code).send({ error: 'setup', detail });
246
+ };
247
+ if (authMode().mode !== 'setup') return fail(409, 'already configured');
248
+ const b = (req.body ?? {}) as { key?: string; token?: string; remember?: unknown };
249
+ const key = String(b.key ?? '');
250
+ if (key.length < 6) return fail(400, 'use at least 6 characters');
251
+ const gate = setupAllowed(req.headers as Record<string, unknown>, socketIp(req), String(b.token ?? ''));
252
+ if (!gate.ok) {
253
+ return fail(403, 'paste the one-time setup token from the daemon log (this request is not local)');
254
+ }
255
+ storeKey(key); // 평문 키는 절대 로그하지 않음
256
+ setSessionCookie(reply, req, truthy(b.remember));
257
+ // form-nav → 303 redirect to /(브라우저가 Set-Cookie 커밋 후 이동). JSON → 201.
258
+ if (nav) return reply.code(303).header('location', '/').send();
259
+ return reply.code(201).send({ ok: true });
260
+ });
261
+
262
+ // 언락 — 상수시간 검증 + per-client 레이트리밋(백오프). 성공 시 세션 쿠키.
263
+ app.post('/api/auth/unlock', async (req, reply) => {
264
+ const nav = isFormNav(req);
265
+ const fail = (code: number, detail: string): FastifyReply => {
266
+ if (nav) return reply.type('text/html').code(code).send(loginPageHTML(false, { error: detail }));
267
+ return reply.code(code).send({ error: 'unlock', detail });
268
+ };
269
+ const m = authMode();
270
+ if (m.mode === 'disabled') {
271
+ if (nav) return reply.code(303).header('location', '/').send();
272
+ return reply.send({ ok: true });
273
+ }
274
+ if (m.mode === 'setup') return fail(409, 'set an access key first');
275
+ const id = clientKey(req.headers as Record<string, unknown>, socketIp(req));
276
+ const rc = rateCheck(id);
277
+ if (rc.blocked) {
278
+ const secs = Math.ceil(rc.retryMs / 1000);
279
+ return fail(429, `too many attempts — try again in ${secs}s`);
280
+ }
281
+ const b = (req.body ?? {}) as { key?: string; remember?: unknown };
282
+ if (verifyKey(String(b.key ?? ''), m)) {
283
+ rateReset(id);
284
+ setSessionCookie(reply, req, truthy(b.remember));
285
+ if (nav) return reply.code(303).header('location', '/').send();
286
+ return reply.send({ ok: true });
287
+ }
288
+ const after = rateFail(id);
289
+ const detail = after.retryMs > 0
290
+ ? `wrong key — try again in ${Math.ceil(after.retryMs / 1000)}s`
291
+ : `wrong key — ${after.attemptsLeft} attempt(s) left`;
292
+ return fail(401, detail);
293
+ });
294
+
295
+ // 로그아웃 — 쿠키 제거(Max-Age=0).
296
+ app.post('/api/auth/logout', async (req, reply) => {
297
+ const parts = [`${SESSION_COOKIE}=`, 'Path=/', 'HttpOnly', 'SameSite=Lax', 'Max-Age=0'];
298
+ if (isSecureReq(req)) parts.push('Secure');
299
+ reply.header('set-cookie', parts.join('; '));
300
+ return reply.send({ ok: true });
301
+ });
302
+
186
303
  // 보드 하이드레이션 — machines/repos/tasks/runs(+events)/captures 한 방에.
187
304
  // 기본 view=active: 닫힌 태스크·그 run·이벤트 전량을 내리지 않는다(수백 run 시 페이로드 폭발 방지).
188
305
  // 이벤트는 활성 run 당 최근 40개만(카드는 8, 모달은 전량 refetch). view=all 은 구버전 전량.
@@ -219,7 +336,8 @@ export async function buildServer(): Promise<FastifyInstance> {
219
336
  // authOpen = 비밀번호 미설정 → Funnel(공개) 가드가 켜져야 함(원격접근 카드용)
220
337
  daemon: {
221
338
  version: config.version, pid: process.pid, port: config.port, dbPath: config.dbPath,
222
- authOpen: config.auth.disabled || config.auth.pass === '',
339
+ // authOpen = 인증이 실질 열려있음(disabled 또는 아직 미설정) → Funnel 가드 켜져야 함
340
+ authOpen: authIsOpen(),
223
341
  },
224
342
  providers: listProviders(),
225
343
  };
@@ -537,8 +655,12 @@ export async function buildServer(): Promise<FastifyInstance> {
537
655
  // ─── Design Mode ───────────────────────────────────────────────
538
656
  // 캡처 키: 인증 off 면 자유, on 이면 ?k=<COXPIT_AUTH_PASS> (북마클릿은 basic 헤더 불가)
539
657
  const captureKeyOk = (req: { query?: unknown }): boolean => {
540
- if (config.auth.disabled || config.auth.pass === '') return config.auth.disabled;
541
- return ((req.query ?? {}) as { k?: string }).k === config.auth.pass;
658
+ const m = authMode();
659
+ // 인증 꺼짐 → 자유. 아직 키 미설정(setup) 캡처 불가(키가 없으니 증명 수단 없음).
660
+ if (m.mode === 'disabled') return true;
661
+ if (m.mode === 'setup') return false;
662
+ const k = ((req.query ?? {}) as { k?: string }).k ?? '';
663
+ return verifyKey(k, m);
542
664
  };
543
665
  const cors = (reply: { header: (k: string, v: string) => unknown }) => {
544
666
  reply.header('access-control-allow-origin', '*');
@@ -591,8 +713,8 @@ export async function buildServer(): Promise<FastifyInstance> {
591
713
  // Funnel has no Tailscale-side auth, so coxpit's basic auth is the only gate.
592
714
  app.post('/api/remote/funnel', async (req, reply) => {
593
715
  const b = (req.body ?? {}) as { on?: boolean };
594
- if (b.on === true && (config.auth.disabled || config.auth.pass === '')) {
595
- return reply.code(409).send({ error: 'set a password first', code: 'NO_AUTH' });
716
+ if (b.on === true && authIsOpen()) {
717
+ return reply.code(409).send({ error: 'set an access key first', code: 'NO_AUTH' });
596
718
  }
597
719
  return setFunnel(config.port, b.on === true);
598
720
  });