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:
Zyy
2026-09-11 16:08:14 +08:00
co-authored by Codex
parent bd152dec00
commit 66fd09eba6
7 changed files with 196 additions and 40 deletions
@@ -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,
+9
View File
@@ -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>
+35 -14
View File
@@ -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">