fix(cliproxy): add management_key support for remote proxy auth separation

- Add management_key field to CliproxyServerConfig for separate management API auth
- Update proxy-target-resolver with buildManagementHeaders() for /v0/management/* endpoints
- Update stats-fetcher and remote-auth-fetcher to use management headers
- Add management_key input field to dashboard remote proxy settings
- Disable quota UI in remote mode (tokens on remote server)
- Fix preset application to use dynamic API key from config instead of hardcoded placeholder
This commit is contained in:
kaitranntt
2026-01-02 22:31:28 -05:00
parent dea0e872bd
commit 0e58d0e8b7
12 changed files with 149 additions and 19 deletions
+30 -1
View File
@@ -22,8 +22,10 @@ export interface ProxyTarget {
port: number;
/** Protocol (http/https) */
protocol: 'http' | 'https';
/** Optional auth token - only send header if defined and non-empty */
/** Optional auth token for API endpoints - only send header if defined and non-empty */
authToken?: string;
/** Optional management key for management API endpoints (/v0/management/*) */
managementKey?: string;
/** True if targeting remote server, false if local */
isRemote: boolean;
}
@@ -56,6 +58,7 @@ export function getProxyTarget(): ProxyTarget {
port,
protocol,
authToken: config.remote.auth_token || undefined, // Empty string -> undefined
managementKey: config.remote.management_key || undefined, // Empty string -> undefined
isRemote: true,
};
}
@@ -102,3 +105,29 @@ export function buildProxyHeaders(
return headers;
}
/**
* Build request headers for management API endpoints (/v0/management/*).
* Uses management_key if configured, otherwise falls back to authToken.
*
* @param target Resolved proxy target
* @param additionalHeaders Extra headers to merge
*/
export function buildManagementHeaders(
target: ProxyTarget,
additionalHeaders: Record<string, string> = {}
): Record<string, string> {
const headers: Record<string, string> = {
Accept: 'application/json',
...additionalHeaders,
};
// Use management key for management API, fallback to authToken
const authKey = target.managementKey ?? target.authToken;
if (authKey) {
headers['Authorization'] = `Bearer ${authKey}`;
}
return headers;
}
+3 -2
View File
@@ -6,7 +6,7 @@
import {
getProxyTarget,
buildProxyUrl,
buildProxyHeaders,
buildManagementHeaders,
ProxyTarget,
} from './proxy-target-resolver';
@@ -81,6 +81,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
}
const url = buildProxyUrl(proxyTarget, '/v0/management/auth-files');
const headers = buildManagementHeaders(proxyTarget);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), REMOTE_FETCH_TIMEOUT_MS);
@@ -88,7 +89,7 @@ export async function fetchRemoteAuthStatus(target?: ProxyTarget): Promise<Remot
try {
const response = await fetch(url, {
signal: controller.signal,
headers: buildProxyHeaders(proxyTarget),
headers,
});
clearTimeout(timeoutId);
+12 -7
View File
@@ -6,7 +6,12 @@
*/
import { getEffectiveApiKey, getEffectiveManagementSecret } from './auth-token-manager';
import { getProxyTarget, buildProxyUrl, buildProxyHeaders } from './proxy-target-resolver';
import {
getProxyTarget,
buildProxyUrl,
buildProxyHeaders,
buildManagementHeaders,
} from './proxy-target-resolver';
/** Per-account usage statistics */
export interface AccountUsageStats {
@@ -109,9 +114,9 @@ export async function fetchCliproxyStats(port?: number): Promise<CliproxyStats |
}
const url = buildProxyUrl(target, '/v0/management/usage');
// For management endpoints, use CCS control panel secret for local, remote auth for remote
// For management endpoints, use management key for remote, local management secret for local
const headers = target.isRemote
? buildProxyHeaders(target)
? buildManagementHeaders(target)
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
const response = await fetch(url, {
@@ -326,9 +331,9 @@ export async function fetchCliproxyErrorLogs(port?: number): Promise<CliproxyErr
}
const url = buildProxyUrl(target, '/v0/management/request-error-logs');
// For management endpoints, use CCS control panel secret for local, remote auth for remote
// For management endpoints, use management key for remote, local management secret for local
const headers = target.isRemote
? buildProxyHeaders(target)
? buildManagementHeaders(target)
: { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` };
const response = await fetch(url, {
@@ -374,9 +379,9 @@ export async function fetchCliproxyErrorLogContent(
`/v0/management/request-error-logs/${encodeURIComponent(name)}`
);
// For management endpoints, use CCS control panel secret for local, remote auth for remote
// For management endpoints, use management key for remote, local management secret for local
const headers = target.isRemote
? buildProxyHeaders(target)
? buildManagementHeaders(target)
: { Authorization: `Bearer ${getEffectiveManagementSecret()}` };
const response = await fetch(url, {
+2
View File
@@ -194,6 +194,8 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
auth_token:
partial.cliproxy_server?.remote?.auth_token ??
DEFAULT_CLIPROXY_SERVER_CONFIG.remote.auth_token,
// management_key is optional - falls back to auth_token when not set
management_key: partial.cliproxy_server?.remote?.management_key,
},
fallback: {
enabled:
+8 -1
View File
@@ -225,8 +225,15 @@ export interface ProxyRemoteConfig {
port?: number;
/** Protocol for remote connection */
protocol: 'http' | 'https';
/** Auth token for remote proxy (optional, sent as header) */
/** Auth token for remote proxy API endpoints (optional, sent as header) */
auth_token: string;
/**
* Management key for remote proxy management API endpoints.
* CLIProxyAPI uses separate authentication for management endpoints
* (/v0/management/*) via 'secret-key' config.
* If not set, falls back to auth_token for backwards compatibility.
*/
management_key?: string;
/** Connection timeout in milliseconds (default: 2000) */
timeout?: number;
}
@@ -5,6 +5,7 @@
/* eslint-disable react-refresh/only-export-components */
import { useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { Loader2, Code2 } from 'lucide-react';
@@ -93,11 +94,23 @@ export function ProviderEditor({
const accounts = authStatus.accounts || [];
// Fetch effective API key for presets (uses configured value, not hardcoded)
const { data: authTokens } = useQuery<{ apiKey: { value: string } }>({
queryKey: ['auth-tokens-raw'],
queryFn: async () => {
const response = await fetch('/api/settings/auth/tokens/raw');
if (!response.ok) return { apiKey: { value: 'ccs-internal-managed' } };
return response.json();
},
staleTime: 60000, // Cache for 1 minute
});
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
const handleApplyPreset = (updates: Record<string, string>) => {
const effectivePort = port ?? CLIPROXY_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
...updates,
});
toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`);
@@ -107,7 +120,7 @@ export function ProviderEditor({
const effectivePort = port ?? CLIPROXY_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
ANTHROPIC_MODEL: values.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet,
@@ -189,6 +202,7 @@ export function ProviderEditor({
onRemoveAccount={onRemoveAccount}
isRemovingAccount={isRemovingAccount}
privacyMode={privacyMode}
isRemoteMode={isRemoteMode}
/>
</TabsContent>
<TabsContent
@@ -38,6 +38,8 @@ interface ModelConfigTabProps {
onRemoveAccount: (accountId: string) => void;
isRemovingAccount?: boolean;
privacyMode?: boolean;
/** True if connected to remote CLIProxy (quota not available) */
isRemoteMode?: boolean;
}
export function ModelConfigTab({
@@ -60,6 +62,7 @@ export function ModelConfigTab({
onRemoveAccount,
isRemovingAccount,
privacyMode,
isRemoteMode,
}: ModelConfigTabProps) {
// Kiro-specific: no-incognito setting (defaults to true = normal browser)
const isKiro = provider === 'kiro';
@@ -133,7 +136,7 @@ export function ModelConfigTab({
onRemoveAccount={onRemoveAccount}
isRemovingAccount={isRemovingAccount}
privacyMode={privacyMode}
showQuota={provider === 'agy'}
showQuota={provider === 'agy' && !isRemoteMode}
isKiro={isKiro}
kiroNoIncognito={kiroNoIncognito}
onKiroNoIncognitoChange={saveKiroNoIncognito}
+5 -1
View File
@@ -193,6 +193,8 @@ export interface ProxyRemoteConfig {
port?: number;
protocol: 'http' | 'https';
auth_token: string;
/** Management key for /v0/management/* endpoints (optional, falls back to auth_token) */
management_key?: string;
}
/** Fallback configuration */
@@ -278,7 +280,9 @@ export const api = {
cliproxy: {
list: () => request<{ variants: Variant[] }>('/cliproxy'),
getAuthStatus: () =>
request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local' }>('/cliproxy/auth'),
request<{ authStatus: AuthStatus[]; source?: 'remote' | 'local'; error?: string }>(
'/cliproxy/auth'
),
create: (data: CreateVariant) =>
request('/cliproxy', {
method: 'POST',
+22 -1
View File
@@ -8,6 +8,24 @@ import { MODEL_CATALOGS } from './model-catalogs';
/** CLIProxy port - should match the backend configuration */
export const CLIPROXY_PORT = 8317;
/** Default fallback API key if fetch fails */
const DEFAULT_API_KEY = 'ccs-internal-managed';
/**
* Fetch effective API key from backend
* Falls back to default if fetch fails
*/
async function fetchEffectiveApiKey(): Promise<string> {
try {
const response = await fetch('/api/settings/auth/tokens/raw');
if (!response.ok) return DEFAULT_API_KEY;
const data = await response.json();
return data?.apiKey?.value ?? DEFAULT_API_KEY;
} catch {
return DEFAULT_API_KEY;
}
}
/**
* Apply default preset for a provider to its settings
* Uses the first model's presetMapping or falls back to using defaultModel for all tiers
@@ -32,11 +50,14 @@ export async function applyDefaultPreset(
haiku: catalog.defaultModel,
};
// Fetch effective API key (respects user customization)
const effectiveApiKey = await fetchEffectiveApiKey();
const effectivePort = port ?? CLIPROXY_PORT;
const settings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
ANTHROPIC_MODEL: mapping.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
@@ -13,6 +13,7 @@ export function useProxyConfig() {
const [editedHost, setEditedHost] = useState<string | null>(null);
const [editedPort, setEditedPort] = useState<string | null>(null);
const [editedAuthToken, setEditedAuthToken] = useState<string | null>(null);
const [editedManagementKey, setEditedManagementKey] = useState<string | null>(null);
const [editedLocalPort, setEditedLocalPort] = useState<string | null>(null);
const fetchConfig = useCallback(async () => {
@@ -108,6 +109,8 @@ export function useProxyConfig() {
setEditedPort,
editedAuthToken,
setEditedAuthToken,
editedManagementKey,
setEditedManagementKey,
editedLocalPort,
setEditedLocalPort,
fetchConfig,
@@ -28,6 +28,8 @@ export default function ProxySection() {
setEditedPort,
editedAuthToken,
setEditedAuthToken,
editedManagementKey,
setEditedManagementKey,
editedLocalPort,
setEditedLocalPort,
fetchConfig,
@@ -62,11 +64,13 @@ export default function ProxySection() {
const hostInput = config.remote.host ?? '';
const portInput = config.remote.port !== undefined ? config.remote.port.toString() : '';
const authTokenInput = config.remote.auth_token ?? '';
const managementKeyInput = config.remote.management_key ?? '';
const localPortInput = (config.local.port ?? 8317).toString();
const displayHost = editedHost ?? hostInput;
const displayPort = editedPort ?? portInput;
const displayAuthToken = editedAuthToken ?? authTokenInput;
const displayManagementKey = editedManagementKey ?? managementKeyInput;
const displayLocalPort = editedLocalPort ?? localPortInput;
// Save functions for blur events
@@ -97,6 +101,14 @@ export default function ProxySection() {
setEditedAuthToken(null);
};
const saveManagementKey = () => {
const value = editedManagementKey ?? displayManagementKey;
if (value !== config.remote.management_key) {
saveConfig({ remote: { ...remoteConfig, management_key: value || undefined } });
}
setEditedManagementKey(null);
};
const saveLocalPort = () => {
const port = parseInt(editedLocalPort ?? displayLocalPort, 10);
if (!isNaN(port) && port !== config.local.port) {
@@ -203,12 +215,15 @@ export default function ProxySection() {
displayHost={displayHost}
displayPort={displayPort}
displayAuthToken={displayAuthToken}
displayManagementKey={displayManagementKey}
setEditedHost={setEditedHost}
setEditedPort={setEditedPort}
setEditedAuthToken={setEditedAuthToken}
setEditedManagementKey={setEditedManagementKey}
onSaveHost={saveHost}
onSavePort={savePort}
onSaveAuthToken={saveAuthToken}
onSaveManagementKey={saveManagementKey}
onSaveConfig={saveConfig}
onTestConnection={handleTestConnection}
/>
@@ -23,12 +23,15 @@ interface RemoteProxyCardProps {
displayHost: string;
displayPort: string;
displayAuthToken: string;
displayManagementKey: string;
setEditedHost: (value: string | null) => void;
setEditedPort: (value: string | null) => void;
setEditedAuthToken: (value: string | null) => void;
setEditedManagementKey: (value: string | null) => void;
onSaveHost: () => void;
onSavePort: () => void;
onSaveAuthToken: () => void;
onSaveManagementKey: () => void;
onSaveConfig: (updates: Partial<CliproxyServerConfig>) => void;
onTestConnection: () => void;
}
@@ -41,12 +44,15 @@ export function RemoteProxyCard({
displayHost,
displayPort,
displayAuthToken,
displayManagementKey,
setEditedHost,
setEditedPort,
setEditedAuthToken,
setEditedManagementKey,
onSaveHost,
onSavePort,
onSaveAuthToken,
onSaveManagementKey,
onSaveConfig,
onTestConnection,
}: RemoteProxyCardProps) {
@@ -115,18 +121,38 @@ export function RemoteProxyCard({
</div>
</div>
{/* Auth Token */}
{/* Auth Token (API Key) */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Auth Token (optional)</label>
<label className="text-sm text-muted-foreground">API Key (optional)</label>
<Input
type="password"
value={displayAuthToken}
onChange={(e) => setEditedAuthToken(e.target.value)}
onBlur={onSaveAuthToken}
placeholder="Bearer token for authentication"
placeholder="For /v1/* API endpoints"
className="font-mono"
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Used for API requests to /v1/chat/completions
</p>
</div>
{/* Management Key */}
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Management Key (optional)</label>
<Input
type="password"
value={displayManagementKey}
onChange={(e) => setEditedManagementKey(e.target.value)}
onBlur={onSaveManagementKey}
placeholder="For /v0/management/* endpoints"
className="font-mono"
disabled={saving}
/>
<p className="text-xs text-muted-foreground">
Used for dashboard management APIs. Falls back to API Key if not set.
</p>
</div>
{/* Test Connection */}