fix(frontend): 设置页恢复 IP 白名单订阅 URL 与解析预览
补回代理订阅配置 UI,修正 ip-allowlist 保存 payload 为 manual_ips + subscription_url。
This commit is contained in:
@@ -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` | 同日前序 commit(cron 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 → 解析预览 → 保存 → 刷新后见订阅节点。
|
||||
@@ -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.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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user