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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user