mirror of
https://github.com/zyycn/codex-proxy-rs.git
synced 2026-11-06 08:06:19 +00:00
feat(proxies): test connections before saving and refine controls
Separate connection testing from saving in proxy forms. Probe unsaved URLs with the shared certificate configuration and test concurrency limit, without changing saved proxy settings or linked accounts. Keep saved authentication when testing an unchanged connection, use clearer placeholders, fix action column widths, and remove the redundant list refresh button. Validation: 5 proxy API tests, scoped strict Clippy, rustfmt, frontend lint/type checks/build, and browser checks passed. Codex CLI requests through real HTTP and SOCKS5 proxies completed with matching upstream text while Clash TUN remained enabled. Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -2,7 +2,11 @@ use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use futures::{StreamExt, stream};
|
||||
use gateway_core::{account::ProviderAccountId, routing::ProviderKind, runtime::SnapshotControl};
|
||||
use gateway_core::{
|
||||
account::{OutboundProxy, ProviderAccountId},
|
||||
routing::ProviderKind,
|
||||
runtime::SnapshotControl,
|
||||
};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
use super::{map_store_error, publish_committed};
|
||||
@@ -54,6 +58,8 @@ pub trait ProxiesService: Send + Sync {
|
||||
revision: Revision,
|
||||
context: &MutationContext,
|
||||
) -> Result<ProxyRecord, AdminError>;
|
||||
/// 探测未保存的连接地址,不写入代理记录或修改账号绑定。
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError>;
|
||||
}
|
||||
|
||||
pub(crate) struct DefaultProxiesService {
|
||||
@@ -214,6 +220,13 @@ impl ProxiesService for DefaultProxiesService {
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
async fn probe(&self, proxy: &OutboundProxy) -> Result<ProxyTestResult, AdminError> {
|
||||
let _permit = self.test_slots.try_acquire().map_err(|_| {
|
||||
AdminError::new(AdminErrorKind::RateLimited, "代理测试繁忙,请稍后重试")
|
||||
})?;
|
||||
Ok(self.probe.test(proxy).await)
|
||||
}
|
||||
|
||||
async fn test(
|
||||
&self,
|
||||
id: &str,
|
||||
|
||||
@@ -8,7 +8,8 @@ use axum::{
|
||||
use gateway_admin::model::{
|
||||
PageSize, Revision,
|
||||
proxies::{
|
||||
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord, UpdateProxy,
|
||||
NewProxy, ProxyAccountListQuery, ProxyListQuery, ProxyMutation, ProxyRecord,
|
||||
ProxyTestResult, UpdateProxy,
|
||||
},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -66,6 +67,12 @@ struct IdRequest {
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ProbeRequest {
|
||||
proxy_url: AccountProxyUpdate,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyTestView {
|
||||
@@ -75,6 +82,17 @@ struct ProxyTestView {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl From<ProxyTestResult> for ProxyTestView {
|
||||
fn from(result: ProxyTestResult) -> Self {
|
||||
Self {
|
||||
success: result.success,
|
||||
latency_ms: result.latency_ms,
|
||||
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
|
||||
message: result.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct ProxyAccountView {
|
||||
@@ -115,12 +133,7 @@ impl From<ProxyRecord> for ProxyView {
|
||||
revision: record.revision.get(),
|
||||
account_count: record.account_count,
|
||||
last_test_at: record.last_test_at.map(|at| at.to_rfc3339()),
|
||||
last_test: record.last_test.map(|result| ProxyTestView {
|
||||
success: result.success,
|
||||
latency_ms: result.latency_ms,
|
||||
exit_ip: result.exit_ip.map(|ip| ip.to_string()),
|
||||
message: result.message,
|
||||
}),
|
||||
last_test: record.last_test.map(Into::into),
|
||||
created_at: record.created_at.to_rfc3339(),
|
||||
updated_at: record.updated_at.to_rfc3339(),
|
||||
}
|
||||
@@ -170,6 +183,7 @@ where
|
||||
.route("/api/admin/proxies/update", post(update::<S>))
|
||||
.route("/api/admin/proxies/delete", post(delete::<S>))
|
||||
.route("/api/admin/proxies/test", post(test::<S>))
|
||||
.route("/api/admin/proxies/probe", post(probe::<S>))
|
||||
}
|
||||
|
||||
fn revision(value: u64) -> Result<Revision, AdminError> {
|
||||
@@ -389,6 +403,30 @@ where
|
||||
))
|
||||
}
|
||||
|
||||
async fn probe<S>(
|
||||
_: AdminAuth,
|
||||
State(state): State<S>,
|
||||
AdminJson(request): AdminJson<ProbeRequest>,
|
||||
) -> Result<impl IntoResponse, AdminError>
|
||||
where
|
||||
S: AdminSessionState + Send + Sync,
|
||||
{
|
||||
let proxy = request
|
||||
.proxy_url
|
||||
.0
|
||||
.ok_or_else(|| AdminError::bad_request("代理 URL 不能为空"))?;
|
||||
let result = state
|
||||
.admin_services()
|
||||
.proxies()
|
||||
.probe(&proxy)
|
||||
.await
|
||||
.map_err(map_error)?;
|
||||
Ok(AdminResponse::new(
|
||||
StatusCode::OK,
|
||||
AdminEnvelope::ok(ProxyTestView::from(result)),
|
||||
))
|
||||
}
|
||||
|
||||
async fn test<S>(
|
||||
auth: AdminAuth,
|
||||
State(state): State<S>,
|
||||
|
||||
@@ -304,6 +304,67 @@ async fn proxy_routes_save_reload_test_rename_and_delete_without_exposing_creden
|
||||
assert_eq!(listed["data"]["page"]["total"], 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_checks_unsaved_address_without_creating_or_changing_records() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
let draft = json!({
|
||||
"proxyUrl": "http://test-user:private-password@proxy.example:8080"
|
||||
});
|
||||
let (status, probed) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/probe",
|
||||
Some(draft.clone()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(
|
||||
probed["data"],
|
||||
json!({"success": true, "latencyMs": 15, "exitIp": "203.0.113.2", "message": "Connected"})
|
||||
);
|
||||
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
|
||||
assert_eq!(listed["data"]["page"]["total"], 0);
|
||||
|
||||
let (_, created) = request(
|
||||
&fixture,
|
||||
"/api/admin/proxies/create",
|
||||
Some(json!({"name": "Saved", "proxyUrl": "http://saved.example:8080"})),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let (status, _) = request(&fixture, "/api/admin/proxies/probe", Some(draft), true).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let (_, listed) = request(&fixture, "/api/admin/proxies", None, true).await;
|
||||
assert_eq!(listed["data"]["items"][0], created["data"]["record"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_probe_rejects_empty_or_invalid_addresses() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
fixture.auth.insert_session("valid-session");
|
||||
for (body, expected) in [
|
||||
(json!({"proxyUrl": ""}), StatusCode::BAD_REQUEST),
|
||||
(json!({"proxyUrl": null}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(json!({}), StatusCode::UNPROCESSABLE_ENTITY),
|
||||
(
|
||||
json!({"proxyUrl": "ftp://test-user:private-password@proxy.example"}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"proxyUrl": "http://proxy.example:8080", "name": "Not saved"}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
request(&fixture, "/api/admin/proxies/probe", Some(body), true)
|
||||
.await
|
||||
.0,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn proxy_routes_require_auth_and_reject_invalid_input() {
|
||||
let fixture = AdminTestFixture::new().await;
|
||||
@@ -355,7 +416,14 @@ async fn proxy_routes_require_auth_and_reject_invalid_input() {
|
||||
.0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
for action in ["create", "update", "delete", "test", "accounts/remove"] {
|
||||
for action in [
|
||||
"create",
|
||||
"update",
|
||||
"delete",
|
||||
"test",
|
||||
"probe",
|
||||
"accounts/remove",
|
||||
] {
|
||||
assert_eq!(
|
||||
request(
|
||||
&fixture,
|
||||
|
||||
@@ -104,3 +104,12 @@ export function testProxy(data: { id: string, revision: number }) {
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
export function probeProxy(data: { proxyUrl: string }) {
|
||||
return request<OutboundProxyTest>({
|
||||
url: '/api/admin/proxies/probe',
|
||||
method: 'POST',
|
||||
data,
|
||||
timeout: 25000,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ export interface BaseTableColumn<Row extends TableRow = TableRow> {
|
||||
label?: string
|
||||
kind?: TableColumnKind
|
||||
size?: TableColumnSize
|
||||
fixedWidth?: boolean
|
||||
align?: TableColumnAlign
|
||||
sortable?: boolean | string
|
||||
format?: (value: unknown, row: Row) => unknown
|
||||
@@ -212,11 +213,14 @@ export function columnStyle<Row extends TableRow>(
|
||||
column: ResolvedTableColumn<Row>,
|
||||
columns: ResolvedTableColumn<Row>[],
|
||||
) {
|
||||
const tableWidth = minimumTableWidth(columns)
|
||||
const widthPercent = tableWidth > 0 ? (column.basisWidth / tableWidth) * 100 : 0
|
||||
const fixedWidth = columns.reduce((total, item) => total + (item.fixedWidth ? item.basisWidth : 0), 0)
|
||||
const flexibleWidth = minimumTableWidth(columns) - fixedWidth
|
||||
const ratio = flexibleWidth > 0 ? column.basisWidth / flexibleWidth : 0
|
||||
|
||||
return {
|
||||
width: `${widthPercent}%`,
|
||||
width: column.fixedWidth
|
||||
? `${column.basisWidth}px`
|
||||
: `${ratio * 100}%`,
|
||||
minWidth: `${column.basisWidth}px`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,17 @@ import BaseModal from '@/components/base/BaseModal/index.vue'
|
||||
const props = defineProps<{
|
||||
proxy: OutboundProxyRecord | null
|
||||
saving: boolean
|
||||
testing: boolean
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
save: [testAfter: boolean]
|
||||
save: []
|
||||
test: []
|
||||
}>()
|
||||
const open = defineModel<boolean>({ required: true })
|
||||
const name = defineModel<string>('name', { required: true })
|
||||
const proxyUrl = defineModel<string>('proxyUrl', { required: true })
|
||||
const showSecret = shallowRef(false)
|
||||
const busy = computed(() => props.saving || props.testing)
|
||||
const title = computed(() => props.proxy ? '编辑代理' : '新增代理')
|
||||
const connectionDescription = computed(() => props.proxy
|
||||
? '留空保留当前连接和认证信息;填写新地址时,请包含所需的用户名和密码。'
|
||||
@@ -31,22 +34,22 @@ watch(open, () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal v-model="open" :title="title" size="md" :dismissible="!saving">
|
||||
<BaseModal v-model="open" :title="title" size="md" :dismissible="!busy">
|
||||
<BaseForm class="grid gap-5">
|
||||
<BaseFormItem label="代理名称" required>
|
||||
<BaseInput v-model="name" maxlength="100" :disabled="saving" aria-label="代理名称" placeholder="例如:本机代理" />
|
||||
<BaseInput v-model="name" maxlength="100" :disabled="busy" aria-label="代理名称" placeholder="请输入代理名称" />
|
||||
</BaseFormItem>
|
||||
<BaseFormItem label="代理 URL" :required="!proxy" :description="connectionDescription">
|
||||
<BaseInput
|
||||
v-model="proxyUrl"
|
||||
:type="showSecret ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:disabled="saving"
|
||||
:disabled="busy"
|
||||
aria-label="代理 URL"
|
||||
:placeholder="proxy?.endpoint ?? 'socks5h://user:password@host:1080'"
|
||||
placeholder="请输入代理 URL"
|
||||
>
|
||||
<template #suffix>
|
||||
<BaseIconButton :label="showSecret ? '隐藏代理地址' : '显示代理地址'" :disabled="saving" @click="showSecret = !showSecret">
|
||||
<BaseIconButton :label="showSecret ? '隐藏代理地址' : '显示代理地址'" :disabled="busy" @click="showSecret = !showSecret">
|
||||
<EyeOff v-if="showSecret" class="size-4" />
|
||||
<Eye v-else class="size-4" />
|
||||
</BaseIconButton>
|
||||
@@ -58,21 +61,21 @@ watch(open, () => {
|
||||
</p>
|
||||
</BaseForm>
|
||||
<template #footer>
|
||||
<BaseButton variant="secondary" :disabled="saving" @click="open = false">
|
||||
<BaseButton variant="secondary" :disabled="busy" @click="open = false">
|
||||
取消
|
||||
</BaseButton>
|
||||
<BaseButton variant="secondary" :loading="saving" @click="emit('save', false)">
|
||||
<BaseButton variant="secondary" :loading="testing" :disabled="saving" @click="emit('test')">
|
||||
<template #icon>
|
||||
<Wifi class="size-4" />
|
||||
</template>
|
||||
测试连接
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="saving" :disabled="testing" @click="emit('save')">
|
||||
<template #icon>
|
||||
<Save class="size-4" />
|
||||
</template>
|
||||
保存代理
|
||||
</BaseButton>
|
||||
<BaseButton variant="primary" :loading="saving" @click="emit('save', true)">
|
||||
<template #icon>
|
||||
<Wifi class="size-4" />
|
||||
</template>
|
||||
保存并测试
|
||||
</BaseButton>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import type { OutboundProxyRecord } from '@/api'
|
||||
import { LockKeyhole, Pencil, Plus, RefreshCw, Search, Trash2, Users, Wifi } from '@lucide/vue'
|
||||
import { LockKeyhole, Pencil, Plus, Search, Trash2, Users, Wifi } from '@lucide/vue'
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { computed, onMounted, reactive, ref, shallowRef, watch } from 'vue'
|
||||
import { createProxy, deleteProxy, getProxies, testProxy, updateProxy } from '@/api'
|
||||
import { createProxy, deleteProxy, getProxies, probeProxy, testProxy, updateProxy } from '@/api'
|
||||
import BaseButton from '@/components/base/BaseButton.vue'
|
||||
import BaseCard from '@/components/base/BaseCard.vue'
|
||||
import BaseConfirmModal from '@/components/base/BaseConfirmModal.vue'
|
||||
@@ -30,12 +30,12 @@ const query = usePagedQuery({
|
||||
const { items: proxies, loading } = query
|
||||
const pagination = computed(() => ({ currentPage: query.page.value, pageSize: query.pageSize.value, total: query.total.value }))
|
||||
const columns = defineTableColumns<OutboundProxyRecord>([
|
||||
{ key: 'identity', label: '代理', kind: 'identity' },
|
||||
{ key: 'identity', label: '代理', kind: 'identity', size: 'xl' },
|
||||
{ key: 'exitIp', label: '出口 IP', kind: 'custom' },
|
||||
{ key: 'latency', label: '耗时', kind: 'custom', size: 'sm' },
|
||||
{ key: 'accounts', label: '关联账号', kind: 'custom', size: 'sm' },
|
||||
{ key: 'testedAt', label: '测试时间', kind: 'datetime' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions' },
|
||||
{ key: 'actions', label: '操作', kind: 'actions', size: 'lg', fixedWidth: true },
|
||||
])
|
||||
const showForm = shallowRef(false)
|
||||
const editing = shallowRef<OutboundProxyRecord | null>(null)
|
||||
@@ -47,6 +47,8 @@ const { loading: deleting } = deleteAction
|
||||
const showDelete = shallowRef(false)
|
||||
const pendingDelete = shallowRef<OutboundProxyRecord | null>(null)
|
||||
const testingIds = ref(new Set<string>())
|
||||
const formTestAction = useAsyncAction()
|
||||
const testingForm = computed(() => formTestAction.loading.value || (editing.value !== null && testingIds.value.has(editing.value.id)))
|
||||
const showAccounts = shallowRef(false)
|
||||
const inspected = shallowRef<OutboundProxyRecord | null>(null)
|
||||
|
||||
@@ -77,8 +79,30 @@ async function checkProxy(proxy: OutboundProxyRecord) {
|
||||
}
|
||||
}
|
||||
|
||||
async function save(testAfter: boolean) {
|
||||
if (saving.value)
|
||||
async function testConnection() {
|
||||
if (saving.value || testingForm.value)
|
||||
return
|
||||
const proxyUrl = form.proxyUrl.trim()
|
||||
if (!proxyUrl && editing.value) {
|
||||
await checkProxy(editing.value)
|
||||
return
|
||||
}
|
||||
if (!proxyUrl) {
|
||||
toast.warning('请填写代理连接地址')
|
||||
return
|
||||
}
|
||||
await formTestAction.run(async () => {
|
||||
// 新地址只做探测,保存前不修改代理及关联账号的连接配置。
|
||||
const result = await probeProxy({ proxyUrl })
|
||||
if (result.success)
|
||||
toast.success(`连接成功,耗时 ${result.latencyMs} ms`)
|
||||
else
|
||||
toast.error(result.message)
|
||||
}, { errorText: '代理测试失败' })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving.value || testingForm.value)
|
||||
return
|
||||
const name = form.name.trim()
|
||||
const proxyUrl = form.proxyUrl.trim()
|
||||
@@ -88,17 +112,15 @@ async function save(testAfter: boolean) {
|
||||
}
|
||||
await saveAction.run(async () => {
|
||||
// 编辑时留空保留已保存的地址和认证,不能用脱敏地址覆盖原连接。
|
||||
const result = editing.value
|
||||
? await updateProxy({ id: editing.value.id, revision: editing.value.revision, name, proxyUrl: proxyUrl || undefined })
|
||||
: await createProxy({ name, proxyUrl })
|
||||
await (editing.value
|
||||
? updateProxy({ id: editing.value.id, revision: editing.value.revision, name, proxyUrl: proxyUrl || undefined })
|
||||
: createProxy({ name, proxyUrl }))
|
||||
showForm.value = false
|
||||
form.proxyUrl = ''
|
||||
toast.success('代理已保存')
|
||||
search.value = ''
|
||||
query.page.value = 1
|
||||
await query.execute()
|
||||
if (testAfter)
|
||||
await checkProxy(result.record)
|
||||
}, { errorText: '代理保存失败' })
|
||||
}
|
||||
|
||||
@@ -154,9 +176,6 @@ onMounted(() => void query.execute())
|
||||
</template>
|
||||
</BaseInput>
|
||||
<div class="flex shrink-0 items-center justify-end gap-2 sm:ml-auto">
|
||||
<BaseIconButton label="刷新代理列表" :loading="loading" @click="query.execute()">
|
||||
<RefreshCw class="size-4" />
|
||||
</BaseIconButton>
|
||||
<BaseButton variant="primary" @click="openForm()">
|
||||
<template #icon>
|
||||
<Plus class="size-4" />
|
||||
@@ -223,7 +242,9 @@ onMounted(() => void query.execute())
|
||||
v-model:proxy-url="form.proxyUrl"
|
||||
:proxy="editing"
|
||||
:saving="saving"
|
||||
:testing="testingForm"
|
||||
@save="save"
|
||||
@test="testConnection"
|
||||
/>
|
||||
<BaseConfirmModal v-model="showDelete" title="删除代理" destructive :loading="deleting" @confirm="confirmDelete">
|
||||
<p class="m-0">
|
||||
|
||||
Reference in New Issue
Block a user