mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat: block official provider switching during proxy takeover
Prevent users from switching to official providers (Anthropic/OpenAI/Google)
when proxy takeover is active, as using a proxy with official APIs may cause
account bans.
Defense-in-depth across 4 layers:
- Backend: ProviderService::switch(), hot_switch_provider(), switch_proxy_provider command
- Frontend: useProviderActions soft guard with error toast
- UI: ProviderActions button disabled with ShieldAlert icon
- Tray menu: official provider items disabled with ⛔ indicator
Also warns when enabling proxy takeover while current provider is official.
This commit is contained in:
@@ -253,6 +253,19 @@ pub async fn switch_proxy_provider(
|
||||
app_type: String,
|
||||
provider_id: String,
|
||||
) -> Result<(), String> {
|
||||
// Block official providers during proxy takeover
|
||||
let provider = state
|
||||
.db
|
||||
.get_provider_by_id(&provider_id, &app_type)
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
state
|
||||
.proxy_service
|
||||
.switch_proxy_target(&app_type, &provider_id)
|
||||
|
||||
@@ -1407,6 +1407,16 @@ impl ProviderService {
|
||||
// Hot-switch only when BOTH: this app is taken over AND proxy server is actually running
|
||||
let should_hot_switch = (is_app_taken_over || live_taken_over) && is_proxy_running;
|
||||
|
||||
// Block switching to official providers when proxy takeover is active.
|
||||
// Using a proxy with official APIs (Anthropic/OpenAI/Google) may cause account bans.
|
||||
if should_hot_switch && _provider.category.as_deref() == Some("official") {
|
||||
return Err(AppError::localized(
|
||||
"switch.official_blocked_by_proxy",
|
||||
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁。请先关闭代理接管,或选择第三方供应商。",
|
||||
"Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
|
||||
));
|
||||
}
|
||||
|
||||
if should_hot_switch {
|
||||
// Proxy takeover mode: hot-switch only, don't write Live config
|
||||
log::info!(
|
||||
|
||||
@@ -15,6 +15,7 @@ use crate::services::provider::{
|
||||
use serde_json::{json, Value};
|
||||
use std::str::FromStr;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
/// 用于接管 Live 配置时的占位符(避免客户端提示缺少 key,同时不泄露真实 Token)
|
||||
@@ -375,6 +376,26 @@ impl ProxyService {
|
||||
|
||||
// 7) 兼容旧逻辑:写入 any-of 标志(失败不影响功能)
|
||||
let _ = self.db.set_live_takeover_active(true).await;
|
||||
|
||||
// 8) Warn if the current provider is official (risk of account ban via proxy)
|
||||
if let Ok(Some(current_id)) =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app)
|
||||
{
|
||||
if let Ok(Some(provider)) = self.db.get_provider_by_id(¤t_id, app_type_str) {
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
if let Some(handle) = self.app_handle.read().await.as_ref() {
|
||||
let _ = handle.emit(
|
||||
"proxy-official-warning",
|
||||
serde_json::json!({
|
||||
"appType": app_type_str,
|
||||
"providerName": provider.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -1548,6 +1569,14 @@ impl ProxyService {
|
||||
.map_err(|e| format!("读取供应商失败: {e}"))?
|
||||
.ok_or_else(|| format!("供应商不存在: {provider_id}"))?;
|
||||
|
||||
// Defense-in-depth: block official providers during proxy takeover
|
||||
if provider.category.as_deref() == Some("official") {
|
||||
return Err(
|
||||
"代理接管模式下不能切换到官方供应商 (Cannot switch to official provider during proxy takeover)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let logical_target_changed =
|
||||
crate::settings::get_effective_current_provider(&self.db, &app_type_enum)
|
||||
.map_err(|e| format!("读取当前供应商失败: {e}"))?
|
||||
|
||||
+22
-2
@@ -297,6 +297,9 @@ pub fn create_tray_menu(
|
||||
.map_err(|e| AppError::Message(format!("创建打开主界面菜单失败: {e}")))?;
|
||||
menu_builder = menu_builder.item(&show_main_item).separator();
|
||||
|
||||
// Pre-compute proxy running state (used to disable official providers in tray menu)
|
||||
let is_proxy_running = futures::executor::block_on(app_state.proxy_service.is_running());
|
||||
|
||||
// 每个应用类型折叠为子菜单,避免供应商过多时菜单过长
|
||||
for section in TRAY_SECTIONS.iter() {
|
||||
if !visible_apps.is_visible(§ion.app_type) {
|
||||
@@ -327,15 +330,32 @@ pub fn create_tray_menu(
|
||||
};
|
||||
let submenu_id = format!("submenu_{}", app_type_str);
|
||||
|
||||
// Check if this app is under proxy takeover (for disabling official providers)
|
||||
let is_app_taken_over = is_proxy_running
|
||||
&& (futures::executor::block_on(app_state.db.get_live_backup(app_type_str))
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
|| app_state
|
||||
.proxy_service
|
||||
.detect_takeover_in_live_config_for_app(§ion.app_type));
|
||||
|
||||
let mut submenu_builder = SubmenuBuilder::with_id(app, &submenu_id, &submenu_label);
|
||||
|
||||
for (id, provider) in sort_providers(&providers) {
|
||||
let is_current = current_id == *id;
|
||||
let is_official_blocked =
|
||||
is_app_taken_over && provider.category.as_deref() == Some("official");
|
||||
let label = if is_official_blocked {
|
||||
format!("{} \u{26D4}", &provider.name) // ⛔ emoji
|
||||
} else {
|
||||
provider.name.clone()
|
||||
};
|
||||
let item = CheckMenuItem::with_id(
|
||||
app,
|
||||
format!("{}{}", section.prefix, id),
|
||||
&provider.name,
|
||||
true,
|
||||
&label,
|
||||
!is_official_blocked, // disabled when blocked
|
||||
is_current,
|
||||
None::<&str>,
|
||||
)
|
||||
|
||||
+31
-1
@@ -270,7 +270,11 @@ function App() {
|
||||
deleteProvider,
|
||||
saveUsageScript,
|
||||
setAsDefaultModel,
|
||||
} = useProviderActions(activeApp, isProxyRunning);
|
||||
} = useProviderActions(
|
||||
activeApp,
|
||||
isProxyRunning,
|
||||
isProxyRunning && isCurrentAppTakeoverActive,
|
||||
);
|
||||
|
||||
const disableOmoMutation = useDisableCurrentOmo();
|
||||
const handleDisableOmo = () => {
|
||||
@@ -401,6 +405,32 @@ function App() {
|
||||
};
|
||||
}, [queryClient, t]);
|
||||
|
||||
// Listen for proxy-official-warning: warn when takeover is enabled with an official provider
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
unsubscribe = await listen("proxy-official-warning", (event) => {
|
||||
const { providerName } = event.payload as {
|
||||
appType: string;
|
||||
providerName: string;
|
||||
};
|
||||
toast.warning(
|
||||
t("notifications.proxyOfficialWarning", {
|
||||
name: providerName,
|
||||
defaultValue: `当前供应商 ${providerName} 是官方供应商,建议切换到第三方供应商后再使用代理接管`,
|
||||
}),
|
||||
{ duration: 8000 },
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
void setup();
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let unlistenResize: (() => void) | undefined;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Minus,
|
||||
Play,
|
||||
Plus,
|
||||
ShieldAlert,
|
||||
Terminal,
|
||||
TestTube2,
|
||||
Trash2,
|
||||
@@ -36,6 +37,7 @@ interface ProviderActionsProps {
|
||||
isAutoFailoverEnabled?: boolean;
|
||||
isInFailoverQueue?: boolean;
|
||||
onToggleFailover?: (enabled: boolean) => void;
|
||||
isOfficialBlockedByProxy?: boolean;
|
||||
// OpenClaw: default model
|
||||
isDefaultModel?: boolean;
|
||||
onSetAsDefault?: () => void;
|
||||
@@ -60,6 +62,7 @@ export function ProviderActions({
|
||||
isAutoFailoverEnabled = false,
|
||||
isInFailoverQueue = false,
|
||||
onToggleFailover,
|
||||
isOfficialBlockedByProxy = false,
|
||||
// OpenClaw: default model
|
||||
isDefaultModel = false,
|
||||
onSetAsDefault,
|
||||
@@ -166,6 +169,16 @@ export function ProviderActions({
|
||||
};
|
||||
}
|
||||
|
||||
if (isOfficialBlockedByProxy) {
|
||||
return {
|
||||
disabled: true,
|
||||
variant: "secondary" as const,
|
||||
className: "opacity-40 cursor-not-allowed",
|
||||
icon: <ShieldAlert className="h-4 w-4" />,
|
||||
text: t("provider.blockedByProxy", { defaultValue: "已拦截" }),
|
||||
};
|
||||
}
|
||||
|
||||
if (isCurrent) {
|
||||
return {
|
||||
disabled: true,
|
||||
|
||||
@@ -175,6 +175,8 @@ export function ProviderCard({
|
||||
|
||||
const usageEnabled = provider.meta?.usage_script?.enabled ?? false;
|
||||
const isOfficial = isOfficialProvider(provider, appId);
|
||||
const isOfficialBlockedByProxy =
|
||||
isProxyTakeover && (provider.category === "official" || isOfficial);
|
||||
const isCopilot =
|
||||
provider.meta?.providerType === PROVIDER_TYPES.GITHUB_COPILOT ||
|
||||
provider.meta?.usage_script?.templateType === "github_copilot";
|
||||
@@ -424,6 +426,7 @@ export function ProviderCard({
|
||||
isInConfig={isInConfig}
|
||||
isTesting={isTesting}
|
||||
isProxyTakeover={isProxyTakeover}
|
||||
isOfficialBlockedByProxy={isOfficialBlockedByProxy}
|
||||
isOmo={isAnyOmo}
|
||||
onSwitch={() => onSwitch(provider)}
|
||||
onEdit={() => onEdit(provider)}
|
||||
|
||||
@@ -23,7 +23,11 @@ import { openclawKeys } from "@/hooks/useOpenClaw";
|
||||
* Hook for managing provider actions (add, update, delete, switch)
|
||||
* Extracts business logic from App.tsx
|
||||
*/
|
||||
export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
export function useProviderActions(
|
||||
activeApp: AppId,
|
||||
isProxyRunning?: boolean,
|
||||
isProxyTakeover?: boolean,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -185,6 +189,18 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
);
|
||||
}
|
||||
|
||||
// Block official providers when proxy takeover is active
|
||||
if (isProxyTakeover && provider.category === "official") {
|
||||
toast.error(
|
||||
t("notifications.officialBlockedByProxy", {
|
||||
defaultValue:
|
||||
"代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁",
|
||||
}),
|
||||
{ duration: 6000 },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await switchProviderMutation.mutateAsync(provider.id);
|
||||
await syncClaudePlugin(provider);
|
||||
@@ -220,7 +236,14 @@ export function useProviderActions(activeApp: AppId, isProxyRunning?: boolean) {
|
||||
// 错误提示由 mutation 处理
|
||||
}
|
||||
},
|
||||
[switchProviderMutation, syncClaudePlugin, activeApp, isProxyRunning, t],
|
||||
[
|
||||
switchProviderMutation,
|
||||
syncClaudePlugin,
|
||||
activeApp,
|
||||
isProxyRunning,
|
||||
isProxyTakeover,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// 删除供应商
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"currentlyUsing": "Currently Using",
|
||||
"enable": "Enable",
|
||||
"inUse": "In Use",
|
||||
"blockedByProxy": "Blocked",
|
||||
"editProvider": "Edit Provider",
|
||||
"editProviderHint": "Configuration will be applied to the current provider immediately after update.",
|
||||
"deleteProvider": "Delete Provider",
|
||||
@@ -206,7 +207,9 @@
|
||||
"openclawDefaultModelSetFailed": "Failed to set default model",
|
||||
"openclawNoModels": "No models configured",
|
||||
"backfillWarning": "Switched successfully, but failed to save changes back to the previous provider",
|
||||
"windowControlFailed": "Window control failed: {{error}}"
|
||||
"windowControlFailed": "Window control failed: {{error}}",
|
||||
"officialBlockedByProxy": "Cannot switch to official provider while proxy takeover is active. Using proxy with official APIs may cause account bans.",
|
||||
"proxyOfficialWarning": "Current provider {{name}} is official. Consider switching to a third-party provider before using proxy takeover."
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "Delete Provider",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"currentlyUsing": "現在使用中",
|
||||
"enable": "有効化",
|
||||
"inUse": "使用中",
|
||||
"blockedByProxy": "ブロック",
|
||||
"editProvider": "プロバイダーを編集",
|
||||
"editProviderHint": "保存すると現在のプロバイダーにすぐ反映されます。",
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
@@ -206,7 +207,9 @@
|
||||
"openclawDefaultModelSetFailed": "デフォルトモデルの設定に失敗しました",
|
||||
"openclawNoModels": "モデルが設定されていません",
|
||||
"backfillWarning": "切り替え成功しましたが、前のプロバイダーへの設定保存に失敗しました",
|
||||
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}"
|
||||
"windowControlFailed": "ウィンドウ操作に失敗しました: {{error}}",
|
||||
"officialBlockedByProxy": "プロキシ接管モード中は公式プロバイダーに切り替えできません。プロキシ経由で公式 API にアクセスするとアカウントが停止される可能性があります。",
|
||||
"proxyOfficialWarning": "現在のプロバイダー {{name}} は公式です。プロキシ接管を使用する前にサードパーティプロバイダーに切り替えてください。"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "プロバイダーを削除",
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"currentlyUsing": "当前使用",
|
||||
"enable": "启用",
|
||||
"inUse": "使用中",
|
||||
"blockedByProxy": "已拦截",
|
||||
"editProvider": "编辑供应商",
|
||||
"editProviderHint": "更新配置后将立即应用到当前供应商。",
|
||||
"deleteProvider": "删除供应商",
|
||||
@@ -206,7 +207,9 @@
|
||||
"openclawDefaultModelSetFailed": "设置默认模型失败",
|
||||
"openclawNoModels": "该供应商没有配置模型",
|
||||
"backfillWarning": "切换成功,但旧供应商配置回填失败,您手动修改的配置可能未保存",
|
||||
"windowControlFailed": "窗口控制失败:{{error}}"
|
||||
"windowControlFailed": "窗口控制失败:{{error}}",
|
||||
"officialBlockedByProxy": "代理接管模式下不能切换到官方供应商,使用代理访问官方 API 可能导致账号被封禁",
|
||||
"proxyOfficialWarning": "当前供应商 {{name}} 是官方供应商,建议切换到第三方供应商后再使用代理接管"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteProvider": "删除供应商",
|
||||
|
||||
Reference in New Issue
Block a user