Skip to content

Commit f2681f8

Browse files
committed
Decouple dashboard saves from client API auth
1 parent 669c5cd commit f2681f8

5 files changed

Lines changed: 214 additions & 10 deletions

File tree

src/dashboard.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function dashboardHtml(initialConfig: unknown = null): string {
3535
<div class="status"><span class="pill"><i id="dot" class="dot"></i><span id="online">checking</span></span><div id="bridgeVersion" class="small">v${BRIDGE_VERSION}</div></div>
3636
</section>
3737
<section class="grid">
38-
<div class="card wide"><h2>Server Bind <span class="sub">after restart</span></h2><div class="bind-grid"><div class="field"><label>Bind host</label><select id="bindHost"><option value="127.0.0.1">127.0.0.1 · local only</option><option value="0.0.0.0">0.0.0.0 · LAN/Tailscale</option></select></div><div class="field"><label>Port</label><input id="bindPort" type="number" min="1" max="65535" /></div></div><div id="bridgeKeyWrap" class="field bridge-key"><label>Admin API Key</label><div class="bridge-key-row"><span class="bridge-key-prefix">sk-</span><input id="bridgeApiKey" type="text" autocomplete="off" spellcheck="false" placeholder="cmdbridge-랜덤6 · 복사/수정 가능" /></div><div class="bridge-key-help-row"><span class="bridge-key-help">0.0.0.0/LAN 공개 시 필요</span><button id="generateBridgeKey" class="secondary" type="button" aria-label="Generate Admin API Key">🎲</button><button id="copyBridgeKey" class="secondary" type="button" aria-label="Copy Admin API Key">📋</button><button id="saveBridgeKey" class="secondary" type="button" aria-label="Save Admin API Key">💾</button></div></div></div>
38+
<div class="card wide"><h2>Server Bind <span class="sub">after restart</span></h2><div class="bind-grid"><div class="field"><label>Bind host</label><select id="bindHost"><option value="127.0.0.1">127.0.0.1 · local only</option><option value="0.0.0.0">0.0.0.0 · LAN/Tailscale</option></select></div><div class="field"><label>Port</label><input id="bindPort" type="number" min="1" max="65535" /></div></div><div id="bridgeKeyWrap" class="field bridge-key"><label>Client API Key</label><div class="bridge-key-row"><span class="bridge-key-prefix">sk-</span><input id="bridgeApiKey" type="text" autocomplete="off" spellcheck="false" placeholder="cmdbr-랜덤6 · 복사/수정 가능" /></div><div class="bridge-key-help-row"><span class="bridge-key-help">외부 /v1 호출용 key</span><button id="generateBridgeKey" class="secondary" type="button" aria-label="Generate Client API Key">🎲</button><button id="copyBridgeKey" class="secondary" type="button" aria-label="Copy Client API Key">📋</button><button id="saveBridgeKey" class="secondary" type="button" aria-label="Save Client API Key">💾</button></div></div></div>
3939
<div class="card wide"><h2>Routing Policy <span class="sub">default: daily burn</span></h2><div id="policies" class="seg"></div><div class="one-line-field concurrency-row"><label for="maxPer">키당 동시 요청</label><input id="maxPer" type="number" min="1" value="4" /><span>회</span><span class="concurrency-spacer" aria-hidden="true"></span><span id="concurrencyInfo" class="info" tabindex="0" aria-label="키당 동시 요청 설명">ℹ️<span class="tip">운영 기본값은 키당 4회입니다. 등록된 key별로 이 값까지만 병렬 요청을 보냅니다.</span></span></div></div>
4040
<div class="card wide"><h2>Credentials <span class="row"><button id="refreshCreds" class="secondary">Refresh</button><button id="addCred" class="secondary">Add key</button></span></h2><div id="creds" class="stack creds"></div></div>
4141
<div class="card wide"><h2>Models <span class="sub">on/off after restart</span></h2><div id="models" class="stack models"></div></div>
@@ -70,10 +70,10 @@ $('policies').innerHTML=policies.map(p=>'<label class="policy"><input type="radi
7070
$('creds').innerHTML=cfg.credentials.map((c,i)=>{c.originalId=c.originalId||c.id; if(c.enabled===undefined)c.enabled=true; const m=c.metrics||{}; const b=m.billing||{}; const bm=b.metrics||{}; const bal=Number(bm.currentBalance??b.currentBalance??b.monthlyCredits); const days=Number(bm.daysRemaining??b.daysRemaining); const daily=Number(bm.requiredDailyBurn??b.requiredDailyBurn); const money=Number.isFinite(bal)?'$'+bal.toFixed(2):'unknown'; const expired=Number.isFinite(days)&&days<=0; if(expired)expiredIds.add(c.id); else expiredIds.delete(c.id); const dayText=Number.isFinite(days)?Math.max(0,days).toFixed(1)+' 일':'unknown'; const dailyText=Number.isFinite(daily)?'$'+daily.toFixed(2)+'/일':'unknown'; const status=expired?'expired':(c.enabled===false?'manual off':(m.disabledUntil?'disabled':(m.available===false?'blocked':'ready'))); return '<div class="cred"><div class="cred-head"><input class="cred-name" data-cid="'+i+'" value="'+esc(c.id||('key'+(i+1)))+'"><label class="switch" title="사용 여부"><input data-cenabled="'+i+'" type="checkbox" '+(c.enabled!==false&&!expired?'checked':'')+' '+(expired?'disabled':'')+'><span class="slider"></span></label><button class="danger" data-del="'+i+'">Delete</button></div><div class="kv"><span>Status</span><b>'+esc(status)+'</b></div><div class="kv"><span>잔액</span><b>'+esc(money)+'</b></div><div class="kv"><span>기한</span><b>'+esc(dayText)+'</b></div><div class="kv"><span>잔액/기한</span><b>'+esc(dailyText)+'</b></div><div class="field"><label>API Key '+(c.apiKeyConfigured?'('+esc(c.apiKeyPreview)+')':'')+'</label><input data-ckey="'+i+'" type="password" placeholder="leave blank to keep existing"></div></div>'}).join('');
7171
document.querySelectorAll('[data-cid]').forEach(e=>e.oninput=()=>{cfg.credentials[+e.dataset.cid].id=e.value;setDirty();}); document.querySelectorAll('[data-cenabled]').forEach(e=>e.onchange=()=>{cfg.credentials[+e.dataset.cenabled].enabled=e.checked;setDirty();}); document.querySelectorAll('[data-ckey]').forEach(e=>e.oninput=()=>{if(preventDuplicateVisibleKey(e))return; cfg.credentials[+e.dataset.ckey].apiKey=e.value;setDirty();}); document.querySelectorAll('[data-del]').forEach(e=>e.onclick=()=>{cfg.credentials.splice(+e.dataset.del,1);render();setDirty();});
7272
$('models').innerHTML=cfg.models.map((m,i)=>'<div class="model"><div class="model-head"><div><b>'+esc(m.label||m.id)+'</b><div class="small">'+esc(m.provider)+' · '+esc(m.id)+'</div></div><label class="switch"><input data-mid="'+i+'" type="checkbox" '+(m.enabled?'checked':'')+'><span class="slider"></span></label></div><div class="small">'+esc(m.notes||'')+'</div></div>').join(''); document.querySelectorAll('[data-mid]').forEach(e=>e.onchange=()=>{cfg.models[+e.dataset.mid].enabled=e.checked;setDirty();});}
73-
function randomBridgeKey(){const bytes=new Uint8Array(3); if(globalThis.crypto?.getRandomValues) crypto.getRandomValues(bytes); else for(let i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256); return 'sk-cmdbridge-'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
73+
function randomBridgeKey(){const bytes=new Uint8Array(3); if(globalThis.crypto?.getRandomValues) crypto.getRandomValues(bytes); else for(let i=0;i<bytes.length;i++)bytes[i]=Math.floor(Math.random()*256); return 'sk-cmdbr-'+Array.from(bytes,b=>b.toString(16).padStart(2,'0')).join('');}
7474
function rememberPendingBridgeKey(key){pendingBridgeKey=authKey(key); if(pendingBridgeKey)localStorage.setItem('pendingBridgeApiKey',pendingBridgeKey); else localStorage.removeItem('pendingBridgeApiKey');}
75-
function generateBridgeKey(){const key=randomBridgeKey(); rememberPendingBridgeKey(key); const el=$('bridgeApiKey'); if(el)el.value=displayBridgeKey(key); setDirty(); toast('Random Admin API key generated. Save JSON, then restart to apply.');}
76-
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(nextKey){const key=nextKey||fullBridgeKey(); const current=currentBridgeAuthKey(); if(key&&key!==current){rememberPendingBridgeKey(key); syncBridgeKey(); setDirty(); toast('Pending Admin API key saved. Save JSON, then restart to apply.'); return;} if(key)localStorage.setItem('bridgeApiKey',key); else localStorage.removeItem('bridgeApiKey'); syncBridgeKey(); toast(key?'Current Admin API key saved in this browser.':'Admin API key cleared.');} async function copyBridgeKey(){const key=fullBridgeKey()||authKey(localStorage.getItem('bridgeApiKey'))||''; if(!key){toast('Admin API key is empty.');return;} try{await navigator.clipboard.writeText(key);toast('Admin API key copied.');}catch{toast('Copy failed. Select and copy manually.');}} $('generateBridgeKey').onclick=generateBridgeKey; $('saveBridgeKey').onclick=()=>saveBridgeKey(); $('copyBridgeKey').onclick=copyBridgeKey; $('bridgeApiKey').onkeydown=e=>{if(e.key==='Enter')saveBridgeKey();}; $('maxPer').oninput=()=>{cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; setDirty();}; $('refreshCreds').onclick=async()=>{try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id)); render(); toast('Credentials refreshed.');}catch(e){toast('Credential refresh failed.');}}; $('addCred').onclick=()=>{cfg.credentials.push({id:'key'+(cfg.credentials.length+1),apiKey:'',weight:1,enabled:true});render();setDirty();};
75+
function generateBridgeKey(){const key=randomBridgeKey(); rememberPendingBridgeKey(key); const el=$('bridgeApiKey'); if(el)el.value=displayBridgeKey(key); setDirty(); toast('Random Client API key generated. Save JSON, then restart to apply.');}
76+
$('bindHost').onchange=()=>{cfg.server.host=$('bindHost').value;syncBridgeKey();setDirty();}; $('bindPort').oninput=()=>{cfg.server.port=Number($('bindPort').value)||9992;setDirty();}; function saveBridgeKey(nextKey){const key=nextKey||fullBridgeKey(); const current=currentBridgeAuthKey(); if(key&&key!==current){rememberPendingBridgeKey(key); syncBridgeKey(); setDirty(); toast('Pending Client API key saved. Save JSON, then restart to apply.'); return;} if(key)localStorage.setItem('bridgeApiKey',key); else localStorage.removeItem('bridgeApiKey'); syncBridgeKey(); toast(key?'Current Client API key saved in this browser.':'Client API key cleared.');} async function copyBridgeKey(){const key=fullBridgeKey()||authKey(localStorage.getItem('bridgeApiKey'))||''; if(!key){toast('Client API key is empty.');return;} try{await navigator.clipboard.writeText(key);toast('Client API key copied.');}catch{toast('Copy failed. Select and copy manually.');}} $('generateBridgeKey').onclick=generateBridgeKey; $('saveBridgeKey').onclick=()=>saveBridgeKey(); $('copyBridgeKey').onclick=copyBridgeKey; $('bridgeApiKey').onkeydown=e=>{if(e.key==='Enter')saveBridgeKey();}; $('maxPer').oninput=()=>{cfg.routing.maxInFlightPerCredential=Number($('maxPer').value)||4; setDirty();}; $('refreshCreds').onclick=async()=>{try{const m=await api('/admin/commandcode/credentials?refresh=true'); const byId=new Map((m.credentials||[]).map(x=>[x.id,x])); cfg.credentials.forEach(c=>c.metrics=byId.get(c.id)); render(); toast('Credentials refreshed.');}catch(e){toast('Credential refresh failed.');}}; $('addCred').onclick=()=>{cfg.credentials.push({id:'key'+(cfg.credentials.length+1),apiKey:'',weight:1,enabled:true});render();setDirty();};
7777
$('save').onclick=async()=>{try{if(!cfg)throw new Error('config not loaded'); const pendingKey=pendingBridgeKey||((fullBridgeKey()&&fullBridgeKey()!==currentBridgeAuthKey())?fullBridgeKey():''); const payload={...(pendingKey?{bridgeApiKey:pendingKey}:{}),server:cfg.server,routing:cfg.routing,models:cfg.models,credentials:cfg.credentials.map(c=>({id:c.id,originalId:c.originalId,apiKey:c.apiKey||undefined,weight:c.weight||1,enabled:expiredIds.has(c.id)?undefined:c.enabled!==false,maxInFlight:c.maxInFlight,allowedModels:c.allowedModels}))}; cfg=await api('/admin/config',{method:'PUT',body:JSON.stringify(payload)}); render(); setDirty(true); toast('JSON saved. Restart required.');}catch(e){toast(duplicateCredentialMessage(e)||('Save failed: '+(e?.message||e)));}};
7878
$('restart').onclick=async()=>{const pendingKey=pendingBridgeKey; try{await api('/admin/restart',{method:'POST',body:'{}'}); if(pendingKey){localStorage.setItem('bridgeApiKey',pendingKey); rememberPendingBridgeKey('');} toast('Restart requested'); setTimeout(load,1800);}catch(e){toast('Restart failed: '+(e?.message||e));}};
7979
load();

src/server.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,17 @@ function isAdminRequest(request: FastifyRequest): boolean {
134134
return request.url.startsWith("/admin/");
135135
}
136136

137+
function isDashboardAdminWrite(request: FastifyRequest): boolean {
138+
return (
139+
(request.method === "PUT" && request.url.startsWith("/admin/config")) ||
140+
(request.method === "POST" && request.url.startsWith("/admin/restart"))
141+
);
142+
}
143+
137144
function shouldRequireAuth(request: FastifyRequest): boolean {
138145
if (request.method === "OPTIONS") return false;
139146
if (request.method === "GET" && request.url.startsWith("/admin/config")) return false;
147+
if (isDashboardAdminWrite(request)) return false;
140148
if (request.method === "GET" && request.url.startsWith("/admin/commandcode/credentials")) {
141149
return false;
142150
}
@@ -146,6 +154,7 @@ function shouldRequireAuth(request: FastifyRequest): boolean {
146154
function isPublicAdminRequest(request: FastifyRequest): boolean {
147155
return (
148156
request.method === "OPTIONS" ||
157+
isDashboardAdminWrite(request) ||
149158
(request.method === "GET" &&
150159
(request.url.startsWith("/admin/config") ||
151160
request.url.startsWith("/admin/commandcode/credentials")))
@@ -167,6 +176,29 @@ function sameHostnameOrigin(request: FastifyRequest): string | undefined {
167176
return undefined;
168177
}
169178

179+
function isLoopbackHost(host: string | undefined): boolean {
180+
const hostname = host?.split(":")[0];
181+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "[::1]";
182+
}
183+
184+
function hasSameHostnameReferer(request: FastifyRequest): boolean {
185+
const referer = request.headers.referer;
186+
const host = request.headers.host;
187+
if (!referer || !host) return false;
188+
try {
189+
const refererUrl = new URL(referer);
190+
return refererUrl.protocol === "http:" && refererUrl.hostname === host.split(":")[0];
191+
} catch {
192+
return false;
193+
}
194+
}
195+
196+
function isDashboardWriteSourceAllowed(request: FastifyRequest): boolean {
197+
if (sameHostnameOrigin(request)) return true;
198+
if (hasSameHostnameReferer(request)) return true;
199+
return !request.headers.origin && !request.headers.referer && isLoopbackHost(request.headers.host);
200+
}
201+
170202
function asOpenAIRequest(
171203
value: z.infer<typeof chatCompletionRequestSchema>,
172204
): OpenAIChatCompletionRequest {
@@ -373,6 +405,17 @@ export async function createApp(options: CreateAppOptions = {}): Promise<Fastify
373405
}
374406

375407
app.addHook("preHandler", async (request, reply) => {
408+
if (isDashboardAdminWrite(request) && !isDashboardWriteSourceAllowed(request)) {
409+
return reply
410+
.code(403)
411+
.send(
412+
openAIError(
413+
"Dashboard config writes must come from the same host as the bridge dashboard",
414+
"authentication_error",
415+
"admin_origin_forbidden",
416+
),
417+
);
418+
}
376419
if (isAdminRequest(request) && !isPublicAdminRequest(request) && !config.bridgeApiKey) {
377420
return reply
378421
.code(403)

0 commit comments

Comments
 (0)