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>
279 lines
8.2 KiB
Vue
279 lines
8.2 KiB
Vue
<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>
|