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:
2026-08-16 23:41:33 +03:00
co-authored by Cursor
parent 8ab606747f
commit 5811ecb134
25 changed files with 1146 additions and 18 deletions
+4 -2
View File
@@ -13,8 +13,8 @@ for _candidate in (_HERE.parent, *_HERE.parents):
break
from .database import Base, engine, get_db
from .routers import admin, auth, internal, map, objects, parse_channels, parser_profiles, v1
from .seed import seed_objects, seed_test_consumer
from .routers import admin, auth, internal, map, objects, parse_channels, parser_profiles, v1, vpn
from .seed import seed_objects, seed_test_consumer, seed_vpn_settings
from .services.migrations import migrate_schema
from .services.scheduler import start_scheduler
from .storage import ensure_upload_dir
@@ -30,6 +30,7 @@ async def lifespan(_: FastAPI):
try:
seed_objects(db)
seed_test_consumer(db)
seed_vpn_settings(db)
finally:
db.close()
yield
@@ -53,4 +54,5 @@ app.include_router(auth.router)
app.include_router(admin.router)
app.include_router(parser_profiles.router)
app.include_router(parse_channels.router)
app.include_router(vpn.router)
app.include_router(v1.router)
+19
View File
@@ -190,3 +190,22 @@ class ConsumerFilter(Base):
date_from: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
consumer: Mapped["Consumer"] = relationship(back_populates="filter")
class VpnSettingsRow(Base):
"""Singleton VPN / proxy settings (id=1)."""
__tablename__ = "vpn_settings"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
enabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
mode: Mapped[str] = mapped_column(String(32), default="subscription", nullable=False)
subscription_url: Mapped[str | None] = mapped_column(Text, nullable=True)
subscription_interval_seconds: Mapped[int] = mapped_column(
Integer, default=3600, nullable=False
)
host: Mapped[str | None] = mapped_column(String(255), nullable=True)
port: Mapped[int | None] = mapped_column(Integer, nullable=True)
username: Mapped[str | None] = mapped_column(String(255), nullable=True)
password: Mapped[str | None] = mapped_column(String(255), nullable=True)
proxied_source_types: Mapped[list | None] = mapped_column(JSON, nullable=True)
+16 -1
View File
@@ -6,9 +6,15 @@ from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import verify_internal_token
from ..models import ParseJob
from ..schemas import IngestRequest, IngestResponse, ListenerSubscription
from ..schemas import (
IngestRequest,
IngestResponse,
ListenerSubscription,
VpnSettingsInternalRead,
)
from ..services.ingest import ingest_events
from ..services.jobs import resolve_job_source_config
from ..services.vpn import ensure_vpn_settings, to_internal_read
router = APIRouter(prefix="/internal", tags=["internal"])
@@ -85,3 +91,12 @@ def listener_subscriptions(
)
db.commit()
return result
@router.get("/vpn", response_model=VpnSettingsInternalRead)
def internal_vpn_settings(
_: None = Depends(verify_internal_token),
db: Session = Depends(get_db),
):
row = ensure_vpn_settings(db)
return to_internal_read(row)
+32
View File
@@ -0,0 +1,32 @@
"""Admin VPN / proxy settings."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import verify_admin
from ..schemas import VpnSettingsAdminRead, VpnSettingsUpdate
from ..services.vpn import apply_vpn_update, ensure_vpn_settings, to_admin_read
router = APIRouter(
prefix="/admin/vpn",
tags=["vpn"],
dependencies=[Depends(verify_admin)],
)
@router.get("", response_model=VpnSettingsAdminRead)
def get_vpn_settings(db: Session = Depends(get_db)):
row = ensure_vpn_settings(db)
return to_admin_read(row)
@router.put("", response_model=VpnSettingsAdminRead)
def put_vpn_settings(payload: VpnSettingsUpdate, db: Session = Depends(get_db)):
try:
row = apply_vpn_update(db, payload)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return to_admin_read(row)
+40
View File
@@ -300,3 +300,43 @@ class TimelinePoint(BaseModel):
class TopItem(BaseModel):
name: str
count: int
class VpnSettingsUpdate(BaseModel):
enabled: bool | None = None
mode: Literal["subscription", "socks5", "http"] | None = None
subscription_url: str | None = None
clear_subscription_url: bool | None = None
subscription_interval_seconds: int | None = Field(default=None, ge=60, le=86400)
host: str | None = None
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = None
password: str | None = None
clear_password: bool | None = None
proxied_source_types: list[str] | None = None
class VpnSettingsAdminRead(BaseModel):
enabled: bool
mode: Literal["subscription", "socks5", "http"]
subscription_url_set: bool
subscription_url_hint: str | None = None
subscription_interval_seconds: int
host: str | None = None
port: int | None = None
username: str | None = None
password_set: bool
proxied_source_types: list[str]
available_source_types: list[str]
class VpnSettingsInternalRead(BaseModel):
enabled: bool
mode: Literal["subscription", "socks5", "http"]
subscription_url: str | None = None
subscription_interval_seconds: int
host: str | None = None
port: int | None = None
username: str | None = None
password: str | None = None
proxied_source_types: list[str]
+5
View File
@@ -4,6 +4,7 @@ from sqlalchemy.orm import Session
from .models import Consumer, ConsumerFilter
from .services.filtering import hash_api_key
from .services.vpn import ensure_vpn_settings
def seed_objects(db: Session) -> None:
@@ -24,3 +25,7 @@ def seed_test_consumer(db: Session) -> None:
db.flush()
db.add(ConsumerFilter(consumer_id=consumer.id, regions=None, topics=None))
db.commit()
def seed_vpn_settings(db: Session) -> None:
ensure_vpn_settings(db)
+137
View File
@@ -0,0 +1,137 @@
"""VPN settings persistence helpers."""
from __future__ import annotations
from contracts.queues import known_source_types
from contracts.vpn import VpnSettings
from sqlalchemy.orm import Session
from ..models import VpnSettingsRow
from ..schemas import VpnSettingsAdminRead, VpnSettingsInternalRead, VpnSettingsUpdate
SINGLETON_ID = 1
def _hint_secret(value: str | None) -> str | None:
if not value:
return None
if len(value) <= 4:
return "****"
return f"…{value[-4:]}"
def ensure_vpn_settings(db: Session) -> VpnSettingsRow:
row = db.query(VpnSettingsRow).filter(VpnSettingsRow.id == SINGLETON_ID).first()
if row is not None:
return row
row = VpnSettingsRow(
id=SINGLETON_ID,
enabled=False,
mode="subscription",
subscription_interval_seconds=3600,
proxied_source_types=[],
)
db.add(row)
db.commit()
db.refresh(row)
return row
def row_to_contract(row: VpnSettingsRow) -> VpnSettings:
return VpnSettings.model_validate(
{
"enabled": row.enabled,
"mode": row.mode,
"subscription_url": row.subscription_url,
"subscription_interval_seconds": row.subscription_interval_seconds,
"host": row.host,
"port": row.port,
"username": row.username,
"password": row.password,
"proxied_source_types": row.proxied_source_types or [],
}
)
def to_admin_read(row: VpnSettingsRow) -> VpnSettingsAdminRead:
return VpnSettingsAdminRead(
enabled=row.enabled,
mode=row.mode, # type: ignore[arg-type]
subscription_url_set=bool(row.subscription_url),
subscription_url_hint=_hint_secret(row.subscription_url),
subscription_interval_seconds=row.subscription_interval_seconds,
host=row.host,
port=row.port,
username=row.username,
password_set=bool(row.password),
proxied_source_types=list(row.proxied_source_types or []),
available_source_types=known_source_types(),
)
def to_internal_read(row: VpnSettingsRow) -> VpnSettingsInternalRead:
return VpnSettingsInternalRead(
enabled=row.enabled,
mode=row.mode, # type: ignore[arg-type]
subscription_url=row.subscription_url,
subscription_interval_seconds=row.subscription_interval_seconds,
host=row.host,
port=row.port,
username=row.username,
password=row.password,
proxied_source_types=list(row.proxied_source_types or []),
)
def apply_vpn_update(db: Session, payload: VpnSettingsUpdate) -> VpnSettingsRow:
row = ensure_vpn_settings(db)
data = {
"enabled": row.enabled if payload.enabled is None else payload.enabled,
"mode": row.mode if payload.mode is None else payload.mode,
"subscription_url": row.subscription_url,
"subscription_interval_seconds": (
row.subscription_interval_seconds
if payload.subscription_interval_seconds is None
else payload.subscription_interval_seconds
),
"host": row.host if payload.host is None else (payload.host.strip() or None),
"port": row.port if payload.port is None else payload.port,
"username": (
row.username
if payload.username is None
else (payload.username.strip() or None)
),
"password": row.password,
"proxied_source_types": (
list(row.proxied_source_types or [])
if payload.proxied_source_types is None
else payload.proxied_source_types
),
}
if payload.clear_subscription_url:
data["subscription_url"] = None
elif payload.subscription_url is not None:
stripped = payload.subscription_url.strip()
data["subscription_url"] = stripped or None
if payload.clear_password:
data["password"] = None
elif payload.password is not None:
stripped = payload.password.strip()
data["password"] = stripped or None
validated = VpnSettings.model_validate(data)
row.enabled = validated.enabled
row.mode = validated.mode
row.subscription_url = validated.subscription_url
row.subscription_interval_seconds = validated.subscription_interval_seconds
row.host = validated.host
row.port = validated.port
row.username = validated.username
row.password = validated.password
row.proxied_source_types = validated.proxied_source_types
db.commit()
db.refresh(row)
return row
@@ -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>