Add VPN admin tab with subscription proxy via cp-vpn for selected sources.
Persist settings in CA, expose /admin/vpn and /internal/vpn, and route Telegram (and optional web/nlp) traffic through mihomo SOCKS when enabled. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -14,6 +14,8 @@ import type {
|
||||
ParseJobUpdate,
|
||||
TimelinePoint,
|
||||
TopItem,
|
||||
VpnSettings,
|
||||
VpnSettingsUpdate,
|
||||
} from "../types/admin";
|
||||
|
||||
function buildQuery(params: Record<string, string | number | undefined>): string {
|
||||
@@ -157,3 +159,15 @@ export async function testDistribution(apiKey: string, limit = 5): Promise<Event
|
||||
}
|
||||
return response.json() as Promise<EventRecord[]>;
|
||||
}
|
||||
|
||||
export function fetchVpnSettings(): Promise<VpnSettings> {
|
||||
return request<VpnSettings>("/vpn", undefined, ADMIN_BASE);
|
||||
}
|
||||
|
||||
export function updateVpnSettings(payload: VpnSettingsUpdate): Promise<VpnSettings> {
|
||||
return request<VpnSettings>(
|
||||
"/vpn",
|
||||
{ method: "PUT", body: JSON.stringify(payload) },
|
||||
ADMIN_BASE,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ const adminNavItems = [
|
||||
{ to: "/channels", label: "Каналы" },
|
||||
{ to: "/parser-profiles", label: "Профили" },
|
||||
{ to: "/parsers", label: "Парсеры" },
|
||||
{ to: "/vpn", label: "VPN" },
|
||||
{ to: "/events", label: "События" },
|
||||
{ to: "/analytics", label: "Аналитика" },
|
||||
{ to: "/consumers", label: "ПИ" },
|
||||
|
||||
@@ -10,6 +10,7 @@ import LoginView from "../views/LoginView.vue";
|
||||
import MapViewPage from "../views/MapViewPage.vue";
|
||||
import ParserProfilesView from "../views/ParserProfilesView.vue";
|
||||
import ParsersView from "../views/ParsersView.vue";
|
||||
import VpnView from "../views/VpnView.vue";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@@ -43,6 +44,12 @@ const router = createRouter({
|
||||
component: ParsersView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "vpn",
|
||||
name: "vpn",
|
||||
component: VpnView,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "events",
|
||||
name: "events",
|
||||
|
||||
@@ -206,3 +206,33 @@ export interface TopItem {
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type VpnMode = "subscription" | "socks5" | "http";
|
||||
|
||||
export interface VpnSettings {
|
||||
enabled: boolean;
|
||||
mode: VpnMode;
|
||||
subscription_url_set: boolean;
|
||||
subscription_url_hint: string | null;
|
||||
subscription_interval_seconds: number;
|
||||
host: string | null;
|
||||
port: number | null;
|
||||
username: string | null;
|
||||
password_set: boolean;
|
||||
proxied_source_types: string[];
|
||||
available_source_types: string[];
|
||||
}
|
||||
|
||||
export interface VpnSettingsUpdate {
|
||||
enabled?: boolean;
|
||||
mode?: VpnMode;
|
||||
subscription_url?: string | null;
|
||||
clear_subscription_url?: boolean;
|
||||
subscription_interval_seconds?: number;
|
||||
host?: string | null;
|
||||
port?: number | null;
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
clear_password?: boolean;
|
||||
proxied_source_types?: string[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import { fetchVpnSettings, updateVpnSettings } from "../api/admin";
|
||||
import type { VpnMode, VpnSettings } from "../types/admin";
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref("");
|
||||
const success = ref("");
|
||||
const settings = ref<VpnSettings | null>(null);
|
||||
|
||||
const form = ref({
|
||||
enabled: false,
|
||||
mode: "subscription" as VpnMode,
|
||||
subscription_url: "",
|
||||
clear_subscription_url: false,
|
||||
subscription_interval_seconds: 3600,
|
||||
host: "",
|
||||
port: 1080 as number | null,
|
||||
username: "",
|
||||
password: "",
|
||||
clear_password: false,
|
||||
proxied_source_types: [] as string[],
|
||||
});
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
telegram: "Telegram",
|
||||
crawl4ai: "Crawl4AI (web)",
|
||||
viina: "VIINA (nlp)",
|
||||
};
|
||||
|
||||
const isSubscription = computed(() => form.value.mode === "subscription");
|
||||
const isDirectProxy = computed(
|
||||
() => form.value.mode === "socks5" || form.value.mode === "http",
|
||||
);
|
||||
|
||||
function applySettings(data: VpnSettings) {
|
||||
settings.value = data;
|
||||
form.value = {
|
||||
enabled: data.enabled,
|
||||
mode: data.mode,
|
||||
subscription_url: "",
|
||||
clear_subscription_url: false,
|
||||
subscription_interval_seconds: data.subscription_interval_seconds,
|
||||
host: data.host ?? "",
|
||||
port: data.port ?? 1080,
|
||||
username: data.username ?? "",
|
||||
password: "",
|
||||
clear_password: false,
|
||||
proxied_source_types: [...data.proxied_source_types],
|
||||
};
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
applySettings(await fetchVpnSettings());
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось загрузить VPN";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSource(sourceType: string, checked: boolean) {
|
||||
const set = new Set(form.value.proxied_source_types);
|
||||
if (checked) set.add(sourceType);
|
||||
else set.delete(sourceType);
|
||||
form.value.proxied_source_types = [...set];
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
submitting.value = true;
|
||||
error.value = "";
|
||||
success.value = "";
|
||||
try {
|
||||
const payload: Parameters<typeof updateVpnSettings>[0] = {
|
||||
enabled: form.value.enabled,
|
||||
mode: form.value.mode,
|
||||
subscription_interval_seconds: form.value.subscription_interval_seconds,
|
||||
host: form.value.host.trim() || null,
|
||||
port: form.value.port,
|
||||
username: form.value.username.trim() || null,
|
||||
proxied_source_types: form.value.proxied_source_types,
|
||||
};
|
||||
|
||||
if (form.value.clear_subscription_url) {
|
||||
payload.clear_subscription_url = true;
|
||||
} else if (form.value.subscription_url.trim()) {
|
||||
payload.subscription_url = form.value.subscription_url.trim();
|
||||
}
|
||||
|
||||
if (form.value.clear_password) {
|
||||
payload.clear_password = true;
|
||||
} else if (form.value.password) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
applySettings(await updateVpnSettings(payload));
|
||||
success.value = "Настройки VPN сохранены";
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : "Не удалось сохранить";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<h2 class="page-heading">VPN</h2>
|
||||
<p class="intro">
|
||||
Проксирование трафика выбранных источников парсинга. Режим
|
||||
<strong>subscription</strong> поднимает SOCKS через сервис
|
||||
<code>cp-vpn</code> (mihomo). Режимы SOCKS5/HTTP задают адрес напрямую для
|
||||
воркеров. Без включённого VPN остаётся fallback из
|
||||
<code>TELEGRAM_PROXY_*</code> в <code>.env</code>.
|
||||
</p>
|
||||
|
||||
<section class="card">
|
||||
<h3>Настройки <span v-if="loading" class="muted">(загрузка...)</span></h3>
|
||||
<form v-if="settings" class="admin-form" @submit.prevent="handleSave">
|
||||
<label class="checkbox-row">
|
||||
<input v-model="form.enabled" type="checkbox" />
|
||||
<span>Прокси включён</span>
|
||||
</label>
|
||||
|
||||
<div class="form-row vpn-row">
|
||||
<label>
|
||||
Способ проксирования
|
||||
<select v-model="form.mode">
|
||||
<option value="subscription">Subscription URL</option>
|
||||
<option value="socks5">SOCKS5</option>
|
||||
<option value="http">HTTP</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<template v-if="isSubscription">
|
||||
<label>
|
||||
Адрес подписки (subscription URL)
|
||||
<input
|
||||
v-model="form.subscription_url"
|
||||
type="url"
|
||||
placeholder="https://sub.example.com/…"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</label>
|
||||
<p v-if="settings.subscription_url_set" class="hint">
|
||||
Сохранён:
|
||||
<code>{{ settings.subscription_url_hint }}</code>
|
||||
— оставьте поле пустым, чтобы не менять.
|
||||
</p>
|
||||
<label class="checkbox-row">
|
||||
<input v-model="form.clear_subscription_url" type="checkbox" />
|
||||
<span>Очистить сохранённый URL</span>
|
||||
</label>
|
||||
<label>
|
||||
Интервал обновления подписки (сек)
|
||||
<input
|
||||
v-model.number="form.subscription_interval_seconds"
|
||||
type="number"
|
||||
min="60"
|
||||
max="86400"
|
||||
/>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<template v-if="isDirectProxy">
|
||||
<div class="form-row">
|
||||
<label>
|
||||
Host
|
||||
<input v-model="form.host" type="text" placeholder="127.0.0.1" />
|
||||
</label>
|
||||
<label>
|
||||
Port
|
||||
<input v-model.number="form.port" type="number" min="1" max="65535" />
|
||||
</label>
|
||||
<label>
|
||||
Username
|
||||
<input v-model="form.username" type="text" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:placeholder="settings.password_set ? '•••• (оставьте пустым)' : ''"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label v-if="settings.password_set" class="checkbox-row">
|
||||
<input v-model="form.clear_password" type="checkbox" />
|
||||
<span>Очистить пароль</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<fieldset class="sources">
|
||||
<legend>Источники через прокси</legend>
|
||||
<label
|
||||
v-for="st in settings.available_source_types"
|
||||
:key="st"
|
||||
class="checkbox-row"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="form.proxied_source_types.includes(st)"
|
||||
@change="
|
||||
toggleSource(st, ($event.target as HTMLInputElement).checked)
|
||||
"
|
||||
/>
|
||||
<span>{{ sourceLabels[st] || st }} <code>({{ st }})</code></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary" :disabled="submitting || loading">
|
||||
{{ submitting ? "Сохранение..." : "Сохранить" }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="error" class="form-error">{{ error }}</p>
|
||||
<p v-if="success" class="form-success">{{ success }}</p>
|
||||
</form>
|
||||
<p v-else-if="error" class="form-error">{{ error }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.intro {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.9rem;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.vpn-row {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: -0.5rem 0 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.sources {
|
||||
border: 1px solid #e5e5e5;
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem 0.25rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.sources legend {
|
||||
padding: 0 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
.form-success {
|
||||
margin: 0.75rem 0 0;
|
||||
color: #1a7f37;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user