fix(frontend): 设置页恢复 IP 白名单订阅 URL 与解析预览

补回代理订阅配置 UI,修正 ip-allowlist 保存 payload 为 manual_ips + subscription_url。
This commit is contained in:
Nexus Agent
2026-06-07 19:34:24 +08:00
parent a5d5518054
commit 2e911dcca7
5 changed files with 258 additions and 14 deletions
+40
View File
@@ -336,3 +336,43 @@
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:31:03+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:33:34+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"test","result":"BLOCK","detail":"tests failed"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:33:45+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"5/7"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"review","result":"BLOCK","detail":"audit missing changed files"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:34:00+08:00","date":"2026-06-07","gate":"summary","result":"BLOCK","detail":"6/7"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"changelog","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md 27lines"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"audit","result":"PASS","detail":"2026-06-07-settings-ip-allowlist-subscription.md"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"test","result":"PASS","detail":"all passed"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"lint","result":"PASS","detail":"0 violations"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"import","result":"PASS","detail":"ok"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"security","result":"PASS","detail":"0 HIGH"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"review","result":"PASS","detail":"audit covers changes"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"shell_eol","result":"PASS","detail":"all tracked sh LF"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"text_eol","result":"PASS","detail":"git index LF"}
{"ts":"2026-06-07T19:34:14+08:00","date":"2026-06-07","gate":"summary","result":"PASS","detail":"7/7"}
@@ -0,0 +1,35 @@
# 审计 — 设置页 IP 白名单订阅恢复
## 范围
| 文件 | 变更 |
|------|------|
| `frontend/src/pages/SettingsPage.vue` | 订阅 URL、解析预览、节点展示、正确 save payload |
| `frontend/src/types/api.ts` | `SubscriptionParseResponse``IpAllowlistSaveRequest` |
| `deploy/install_ops_cron.sh` | 同日前序 commitcron bash 调用) |
| `deploy/gate_log.jsonl` | 门控记录 |
## Step 3 规则扫描
| H | 规则 | 结论 |
|---|------|------|
| H1 | 后端 API 未删 | PASS — `parse-subscription` / `IpAllowlistSaveRequest` 仍在 |
| H2 | SSRF 仍由后端拦截 | PASS — 未改 `settings.py` |
| H3 | 错误 payload 修复 | FIXED — 不再 POST `{ ips }` |
## Closure
| H | 判定 | 依据 |
|---|------|------|
| H1 | PASS | `server/api/settings.py` |
| H2 | PASS | 解析仍走服务端 |
| H3 | FIXED | `saveAllowlist` payload |
## DoD
- [x] vite build 通过
- [x] changelog / audit
## 验证
设置页 → 登录 IP 白名单 → 订阅 URL + 解析预览 + 保存
@@ -0,0 +1,27 @@
# Changelog — 设置页恢复 IP 白名单订阅功能
**日期**2026-06-07
## 摘要
Vue SPA 设置页补回代理订阅 URL、解析预览、订阅节点展示,并修正 `POST /settings/ip-allowlist` 请求体(`manual_ips` + `subscription_url`)。
## 动机
迁移后 IP 白名单仅保留合并 textarea + 错误 payload `{ ips }`,订阅 URL 无法配置,与后端 `IpAllowlistSaveRequest``parse-subscription` 脱节。
## 涉及文件
- `frontend/src/pages/SettingsPage.vue`
- `frontend/src/types/api.ts`
## 行为
- 订阅 URL 输入 +「解析预览」→ `POST /settings/ip-allowlist/parse-subscription`
- 保存 → `{ manual_ips, subscription_url, enabled }`,触发后台 2h 刷新
- 展示订阅节点 chip 与 `last_refresh`
- toggle 改用 query `?enabled=`(对齐 FastAPI 参数)
## 验证
`cd frontend && npx vite build`;设置页配置订阅 URL → 解析预览 → 保存 → 刷新后见订阅节点。
+145 -14
View File
@@ -164,18 +164,92 @@
<v-card elevation="0" border rounded="lg">
<v-card-title class="d-flex align-center">
<v-icon class="mr-2">mdi-ip-network</v-icon>
IP 白名单
登录 IP 白名单
</v-card-title>
<v-divider />
<v-card-text>
<v-switch v-model="ipAllowlistEnabled" label="启用 IP 白名单" color="primary" density="compact" hide-details class="mb-3" @update:model-value="toggleAllowlist" />
<v-textarea v-model="ipList" label="IP 列表(每行一个)" variant="outlined" density="compact" rows="4" class="mb-2" />
<v-btn size="small" variant="tonal" @click="saveAllowlist" :loading="savingAllowlist">保存</v-btn>
<v-switch
v-model="ipAllowlistEnabled"
label="启用 IP 白名单(仅白名单 IP 可登录)"
color="primary"
density="compact"
hide-details
class="mb-4"
@update:model-value="toggleAllowlist"
/>
<div class="text-subtitle-2 mb-2">代理订阅</div>
<p class="text-caption text-medium-emphasis mb-2">
填写 SS/VMess/VLESS 等订阅链接保存后每 2 小时自动刷新节点 IP 并合并进白名单
</p>
<v-text-field
v-model="subscriptionUrl"
label="订阅 URL"
variant="outlined"
density="compact"
class="mb-2"
placeholder="https://example.com/sub?token=..."
/>
<div class="d-flex flex-wrap ga-2 mb-3">
<v-btn size="small" variant="tonal" prepend-icon="mdi-cloud-download" @click="parseSubscription" :loading="parsingSubscription">解析预览</v-btn>
<v-btn size="small" variant="tonal" color="primary" prepend-icon="mdi-content-save" @click="saveAllowlist" :loading="savingAllowlist">保存白名单</v-btn>
</div>
<div v-if="subscriptionIps.length" class="mb-4">
<div class="text-caption text-medium-emphasis mb-2">
订阅节点 {{ subscriptionIps.length }}
<span v-if="allowlistLastRefresh"> · 上次刷新 {{ allowlistLastRefresh }}</span>
</div>
<div class="d-flex flex-wrap ga-1">
<v-chip v-for="ip in subscriptionIpsPreview" :key="ip" size="x-small" variant="tonal" label>{{ ip }}</v-chip>
<v-chip v-if="subscriptionIps.length > subscriptionIpsPreview.length" size="x-small" variant="text" label>
+{{ subscriptionIps.length - subscriptionIpsPreview.length }} 更多
</v-chip>
</div>
</div>
<div v-else-if="subscriptionUrl" class="text-caption text-medium-emphasis mb-4">
{{ allowlistLastRefresh ? `上次刷新 ${allowlistLastRefresh}暂无节点` : '等待首次刷新保存订阅 URL 后将自动拉取' }}
</div>
<v-divider class="my-3" />
<div class="text-subtitle-2 mb-2">手动 IP</div>
<v-textarea
v-model="manualIpList"
label="手动 IP / CIDR(每行一个)"
variant="outlined"
density="compact"
rows="4"
class="mb-2"
placeholder="1.2.3.4&#10;10.0.0.0/24"
/>
<div class="text-caption text-medium-emphasis">
手动 IP 与订阅节点合并生效 {{ allowlistTotalCount }} 去重后
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Subscription parse preview -->
<v-dialog v-model="subParseDialog" max-width="520">
<v-card border>
<v-card-title>订阅解析预览</v-card-title>
<v-card-text>
<div class="text-body-2 mb-2">共解析出 <strong>{{ subParseHosts.length }}</strong> 个节点 IP / 域名</div>
<div class="d-flex flex-wrap ga-1 mb-2" style="max-height: 240px; overflow-y: auto">
<v-chip v-for="h in subParseHosts" :key="h" size="small" variant="tonal" label>{{ h }}</v-chip>
</div>
<div class="text-caption text-medium-emphasis">点击保存白名单后才会写入订阅 URL 并触发后台刷新</div>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="subParseDialog = false">关闭</v-btn>
<v-btn color="primary" variant="flat" @click="subParseDialog = false; saveAllowlist()">保存白名单</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- API Key Reveal Dialog -->
<v-dialog v-model="showRevealDialog" max-width="400">
<v-card border>
@@ -238,13 +312,21 @@
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { http } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { useSnackbar } from '@/composables/useSnackbar'
import { required, numberRange, isNumber, matchOther } from '@/utils/validation'
import TerminalQuickCommandsSettings from '@/components/TerminalQuickCommandsSettings.vue'
import type { SettingsResponse, AllowlistResponse, SettingItem, TelegramChatItem, TelegramChatsResponse } from '@/types/api'
import type {
SettingsResponse,
AllowlistResponse,
SettingItem,
TelegramChatItem,
TelegramChatsResponse,
SubscriptionParseResponse,
IpAllowlistSaveRequest,
} from '@/types/api'
const snackbar = useSnackbar()
@@ -291,8 +373,17 @@ const revealingKey = ref(false)
// ── IP Allowlist ──
const ipAllowlistEnabled = ref(false)
const ipList = ref('')
const subscriptionUrl = ref('')
const manualIpList = ref('')
const subscriptionIps = ref<string[]>([])
const allowlistLastRefresh = ref<string | null>(null)
const allowlistTotalCount = ref(0)
const savingAllowlist = ref(false)
const parsingSubscription = ref(false)
const subParseDialog = ref(false)
const subParseHosts = ref<string[]>([])
const subscriptionIpsPreview = computed(() => subscriptionIps.value.slice(0, 24))
// ── Data loading ──
async function loadSettings() {
@@ -329,7 +420,13 @@ async function loadAllowlist() {
try {
const res = await http.get<AllowlistResponse>('/settings/ip-allowlist')
ipAllowlistEnabled.value = res.enabled || false
ipList.value = ([...(res.manual_ips || []), ...(res.subscription_ips || [])]).join('\n')
subscriptionUrl.value = res.subscription_url || ''
manualIpList.value = (res.manual_ips || []).join('\n')
subscriptionIps.value = res.subscription_ips || []
allowlistLastRefresh.value = res.last_refresh || null
allowlistTotalCount.value = res.total_count ?? (
new Set([...(res.manual_ips || []), ...(res.subscription_ips || [])]).size
)
} catch {
snackbar('加载白名单失败', 'error')
}
@@ -548,18 +645,52 @@ function copyApiKey() {
async function toggleAllowlist() {
try {
await http.post('/settings/ip-allowlist/toggle', { enabled: ipAllowlistEnabled.value })
} catch (e: any) { snackbar(e.message || '操作失败', 'error') }
await http.post(`/settings/ip-allowlist/toggle?enabled=${ipAllowlistEnabled.value ? 'true' : 'false'}`)
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '操作失败', 'error')
}
}
async function parseSubscription() {
const url = subscriptionUrl.value.trim()
if (!url) {
snackbar('请先填写订阅 URL', 'error')
return
}
parsingSubscription.value = true
try {
const res = await http.post<SubscriptionParseResponse>('/settings/ip-allowlist/parse-subscription', { url })
subParseHosts.value = res.hosts || []
if (!subParseHosts.value.length) {
snackbar('未能解析出任何节点', 'warning')
return
}
subParseDialog.value = true
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '解析失败', 'error')
} finally {
parsingSubscription.value = false
}
}
async function saveAllowlist() {
savingAllowlist.value = true
try {
const ips = ipList.value.split('\n').map(s => s.trim()).filter(Boolean)
await http.post('/settings/ip-allowlist', { ips })
const manual_ips = manualIpList.value.split('\n').map(s => s.trim()).filter(Boolean)
const payload: IpAllowlistSaveRequest = {
manual_ips,
subscription_url: subscriptionUrl.value.trim(),
enabled: ipAllowlistEnabled.value,
}
await http.post('/settings/ip-allowlist', payload)
snackbar('白名单已保存')
} catch (e: any) { snackbar(e.message || '保存失败', 'error') }
finally { savingAllowlist.value = false }
subParseDialog.value = false
await loadAllowlist()
} catch (e: unknown) {
snackbar(e instanceof Error ? e.message : '保存失败', 'error')
} finally {
savingAllowlist.value = false
}
}
+11
View File
@@ -47,6 +47,17 @@ export interface AllowlistResponse {
last_refresh?: string | null
}
export interface SubscriptionParseResponse {
hosts: string[]
count: number
}
export interface IpAllowlistSaveRequest {
manual_ips?: string[]
subscription_url?: string | null
enabled?: boolean
}
/** File browse response from POST /api/sync/browse */
export interface BrowseResponse {
path?: string