coxpit 4.8.0 → 5.0.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/package.json +1 -1
- package/src/board.ts +552 -153
- package/src/icons.ts +6 -0
- package/src/login.ts +40 -33
- package/src/server.ts +52 -11
package/src/icons.ts
CHANGED
|
@@ -19,7 +19,9 @@ export const ICON_SPRITE = `<svg style="position:absolute;width:0;height:0" aria
|
|
|
19
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
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
21
|
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6"/></symbol>
|
|
22
|
+
<symbol id="i-chevron-down" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
|
|
22
23
|
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M5 12h14"/><path d="M12 5v14"/></symbol>
|
|
24
|
+
<symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14"/></symbol>
|
|
23
25
|
<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
26
|
<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
27
|
<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>
|
|
@@ -31,6 +33,10 @@ export const ICON_SPRITE = `<svg style="position:absolute;width:0;height:0" aria
|
|
|
31
33
|
<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
34
|
<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
35
|
<symbol id="i-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></symbol>
|
|
36
|
+
<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>
|
|
37
|
+
<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>
|
|
38
|
+
<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>
|
|
39
|
+
<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>
|
|
34
40
|
</svg>`;
|
|
35
41
|
|
|
36
42
|
/** .ic 아이콘 스타일(1em·currentColor 상속·stroke). board/login 공통. */
|
package/src/login.ts
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
// 브랜디드 접근키 언락/셋업 페이지(단일 자가완결 HTML — 빌드 0, 보드 토큰 매치).
|
|
2
|
-
// 브라우저 basic-auth 팝업을 대체한다.
|
|
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 는 그대로 유지.
|
|
3
8
|
// setup=true → 첫 실행(키 설정, confirm 필드+토큰 힌트), false → 언락(키 1개).
|
|
4
9
|
import { ICON_SPRITE, ICON_CSS } from './icons.js';
|
|
5
10
|
|
|
6
|
-
/**
|
|
7
|
-
|
|
11
|
+
/** HTML 속성 값 이스케이프(서버 렌더 에러 문자열 안전 주입용). */
|
|
12
|
+
function escA(s: string): string {
|
|
13
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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 {
|
|
8
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';
|
|
9
23
|
return /* html */ `<!doctype html>
|
|
10
24
|
<html lang="en">
|
|
11
25
|
<head>
|
|
@@ -25,8 +39,11 @@ export function loginPageHTML(setup: boolean): string {
|
|
|
25
39
|
*{box-sizing:border-box}
|
|
26
40
|
[hidden]{display:none !important}
|
|
27
41
|
html,body{height:100%}
|
|
42
|
+
html{overscroll-behavior:none}
|
|
28
43
|
body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:14px;line-height:1.5;
|
|
29
|
-
-webkit-font-smoothing:antialiased;display:flex;align-items:center;justify-content:center;
|
|
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}
|
|
30
47
|
::selection{background:var(--brand-dim)}
|
|
31
48
|
.card{width:100%;max-width:380px;background:var(--surface);border:1px solid var(--line);
|
|
32
49
|
border-radius:var(--r-card);box-shadow:var(--shadow);padding:26px 24px 22px}
|
|
@@ -38,7 +55,7 @@ export function loginPageHTML(setup: boolean): string {
|
|
|
38
55
|
.flabel{display:block;font-family:var(--mono);font-size:10px;text-transform:uppercase;letter-spacing:.12em;
|
|
39
56
|
color:var(--faint);margin:0 0 6px}
|
|
40
57
|
input[type=password]{width:100%;background:#0e1118;border:1px solid var(--line);border-radius:var(--r-ctl);
|
|
41
|
-
color:var(--ink);font-family:var(--mono);font-size:
|
|
58
|
+
color:var(--ink);font-family:var(--mono);font-size:16px;padding:10px 12px;outline:none}
|
|
42
59
|
input[type=password]:focus{border-color:var(--brand)}
|
|
43
60
|
input::placeholder{color:var(--faint)}
|
|
44
61
|
.fld{margin-bottom:14px}
|
|
@@ -62,7 +79,8 @@ export function loginPageHTML(setup: boolean): string {
|
|
|
62
79
|
</head>
|
|
63
80
|
<body>
|
|
64
81
|
${ICON_SPRITE}
|
|
65
|
-
<form class="card" id="f" autocomplete="off">
|
|
82
|
+
<form class="card" id="f" method="post" action="${action}" autocomplete="off">
|
|
83
|
+
<input type="hidden" name="nav" value="1">
|
|
66
84
|
<div class="mark"><svg class="ic"><use href="#i-lock"/></svg><span>coxpit</span></div>
|
|
67
85
|
<h1>${setup ? 'Protect this coxpit' : 'Unlock this coxpit'}</h1>
|
|
68
86
|
<p class="sub">${setup
|
|
@@ -71,14 +89,18 @@ ${ICON_SPRITE}
|
|
|
71
89
|
${setup ? `<div class="hint">To prove you own this machine, this first-time setup needs the one-time
|
|
72
90
|
<code>setup token</code> printed in the daemon log — unless you're on
|
|
73
91
|
<code>http://127.0.0.1</code> directly. Paste it below if asked.</div>` : ''}
|
|
74
|
-
<div class="err" id="err"
|
|
92
|
+
<div class="err" id="err">${initialErr}</div>
|
|
75
93
|
${setup ? `<div class="fld"><label class="flabel" for="tok">setup token (from the daemon log)</label>
|
|
76
|
-
<input id="tok" type="password" placeholder="paste if not on localhost" autocomplete="off"
|
|
94
|
+
<input id="tok" name="token" type="password" placeholder="paste if not on localhost" autocomplete="off"
|
|
95
|
+
autocapitalize="none" autocorrect="off" spellcheck="false"></div>` : ''}
|
|
77
96
|
<div class="fld"><label class="flabel" for="key">access key</label>
|
|
78
|
-
<input id="key" type="password" placeholder="••••••••"
|
|
97
|
+
<input id="key" name="key" type="password" placeholder="••••••••"
|
|
98
|
+
autocomplete="${setup ? 'new-password' : 'current-password'}"
|
|
99
|
+
autocapitalize="none" autocorrect="off" spellcheck="false" autofocus></div>
|
|
79
100
|
${setup ? `<div class="fld"><label class="flabel" for="key2">confirm access key</label>
|
|
80
|
-
<input id="key2" type="password" placeholder="••••••••"
|
|
81
|
-
|
|
101
|
+
<input id="key2" type="password" placeholder="••••••••"
|
|
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>
|
|
82
104
|
<button class="btn" id="go" type="submit"><svg class="ic"><use href="#i-${setup ? 'lock' : 'unlock'}"/></svg><span>${setup ? 'Set key & enter' : 'Unlock'}</span></button>
|
|
83
105
|
<div class="ft"><a href="https://github.com/hanmariyang/coxpit-oss#remote-access" target="_blank" rel="noopener">Fronting with Cloudflare Access / Tailscale? →</a></div>
|
|
84
106
|
</form>
|
|
@@ -89,33 +111,18 @@ ${ICON_SPRITE}
|
|
|
89
111
|
var err = document.getElementById('err');
|
|
90
112
|
var go = document.getElementById('go');
|
|
91
113
|
function show(m){ err.textContent = m || ''; }
|
|
114
|
+
// 폼은 native navigation POST 로 제출된다(Safari 가 응답의 Set-Cookie 를 커밋 → 303 GET /).
|
|
115
|
+
// 여기서는 클라이언트 사전검증만: 비면 막고, setup 이면 confirm/길이 확인. 통과 시 native 제출 진행.
|
|
92
116
|
f.addEventListener('submit', function(ev){
|
|
93
|
-
ev.preventDefault();
|
|
94
|
-
show('');
|
|
95
117
|
var key = document.getElementById('key').value;
|
|
96
|
-
|
|
97
|
-
if (!key){ show('access key required'); return; }
|
|
98
|
-
var body, url;
|
|
118
|
+
if (!key){ ev.preventDefault(); show('access key required'); return; }
|
|
99
119
|
if (SETUP){
|
|
100
120
|
var key2 = document.getElementById('key2').value;
|
|
101
|
-
if (key !== key2){ show('keys do not match'); return; }
|
|
102
|
-
if (key.length < 6){ show('use at least 6 characters'); return; }
|
|
103
|
-
url = '/api/auth/setup';
|
|
104
|
-
body = { key: key, token: document.getElementById('tok').value, remember: rem };
|
|
105
|
-
} else {
|
|
106
|
-
url = '/api/auth/unlock';
|
|
107
|
-
body = { key: key, remember: rem };
|
|
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; }
|
|
108
123
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
.then(function(r){ return r.json().then(function(j){ return { s:r.status, j:j }; }); })
|
|
112
|
-
.then(function(res){
|
|
113
|
-
if (res.s >= 200 && res.s < 300 && res.j && res.j.ok){ location.replace('/'); return; }
|
|
114
|
-
go.disabled = false;
|
|
115
|
-
var m = (res.j && (res.j.detail || res.j.error)) || ('error ' + res.s);
|
|
116
|
-
show(m);
|
|
117
|
-
})
|
|
118
|
-
.catch(function(){ go.disabled = false; show('network error'); });
|
|
124
|
+
show('');
|
|
125
|
+
go.disabled = true; // 네비게이션 진행 중 중복 제출 방지(네이티브 제출은 계속)
|
|
119
126
|
});
|
|
120
127
|
})();
|
|
121
128
|
</script>
|
package/src/server.ts
CHANGED
|
@@ -8,6 +8,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';
|
|
11
12
|
import {
|
|
12
13
|
authMode, authIsOpen, verifyKey, storeKey, signSession, SESSION_COOKIE,
|
|
13
14
|
clientKey, rateCheck, rateFail, rateReset, setupAllowed,
|
|
@@ -179,6 +180,20 @@ function sharePageHTML(
|
|
|
179
180
|
export async function buildServer(): Promise<FastifyInstance> {
|
|
180
181
|
const app = Fastify({ logger: true });
|
|
181
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
|
+
);
|
|
182
197
|
app.addHook('onRequest', authGate);
|
|
183
198
|
|
|
184
199
|
// 무인증 헬스(외부 감시용)
|
|
@@ -210,45 +225,71 @@ export async function buildServer(): Promise<FastifyInstance> {
|
|
|
210
225
|
};
|
|
211
226
|
const socketIp = (req: { socket?: { remoteAddress?: string } }): string =>
|
|
212
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;
|
|
213
237
|
|
|
214
238
|
// 첫 실행 셋업(anti-claim) — 셋업 토큰 일치 OR 진짜 로컬(loopback+no-fwd)만 허용.
|
|
215
239
|
// 키가 이미 있으면 409(단발). 성공 시 해시 저장 + 세션 쿠키.
|
|
216
240
|
app.post('/api/auth/setup', async (req, reply) => {
|
|
217
|
-
|
|
218
|
-
|
|
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 };
|
|
219
249
|
const key = String(b.key ?? '');
|
|
220
|
-
if (key.length < 6) return
|
|
250
|
+
if (key.length < 6) return fail(400, 'use at least 6 characters');
|
|
221
251
|
const gate = setupAllowed(req.headers as Record<string, unknown>, socketIp(req), String(b.token ?? ''));
|
|
222
252
|
if (!gate.ok) {
|
|
223
|
-
return
|
|
253
|
+
return fail(403, 'paste the one-time setup token from the daemon log (this request is not local)');
|
|
224
254
|
}
|
|
225
255
|
storeKey(key); // 평문 키는 절대 로그하지 않음
|
|
226
|
-
setSessionCookie(reply, req, b.remember
|
|
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();
|
|
227
259
|
return reply.code(201).send({ ok: true });
|
|
228
260
|
});
|
|
229
261
|
|
|
230
262
|
// 언락 — 상수시간 검증 + per-client 레이트리밋(백오프). 성공 시 세션 쿠키.
|
|
231
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
|
+
};
|
|
232
269
|
const m = authMode();
|
|
233
|
-
if (m.mode === 'disabled')
|
|
234
|
-
|
|
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');
|
|
235
275
|
const id = clientKey(req.headers as Record<string, unknown>, socketIp(req));
|
|
236
276
|
const rc = rateCheck(id);
|
|
237
277
|
if (rc.blocked) {
|
|
238
278
|
const secs = Math.ceil(rc.retryMs / 1000);
|
|
239
|
-
return
|
|
279
|
+
return fail(429, `too many attempts — try again in ${secs}s`);
|
|
240
280
|
}
|
|
241
|
-
const b = (req.body ?? {}) as { key?: string; remember?:
|
|
281
|
+
const b = (req.body ?? {}) as { key?: string; remember?: unknown };
|
|
242
282
|
if (verifyKey(String(b.key ?? ''), m)) {
|
|
243
283
|
rateReset(id);
|
|
244
|
-
setSessionCookie(reply, req, b.remember
|
|
284
|
+
setSessionCookie(reply, req, truthy(b.remember));
|
|
285
|
+
if (nav) return reply.code(303).header('location', '/').send();
|
|
245
286
|
return reply.send({ ok: true });
|
|
246
287
|
}
|
|
247
288
|
const after = rateFail(id);
|
|
248
289
|
const detail = after.retryMs > 0
|
|
249
290
|
? `wrong key — try again in ${Math.ceil(after.retryMs / 1000)}s`
|
|
250
291
|
: `wrong key — ${after.attemptsLeft} attempt(s) left`;
|
|
251
|
-
return
|
|
292
|
+
return fail(401, detail);
|
|
252
293
|
});
|
|
253
294
|
|
|
254
295
|
// 로그아웃 — 쿠키 제거(Max-Age=0).
|