diff --git a/.env.example b/.env.example index 7bb7c8d..7f08584 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,6 @@ PORT=3456 PROXY_PORT=8080 +PROXY_BIND_IP=0.0.0.0 TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 diff --git a/Dockerfile b/Dockerfile index 9840840..32efb2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,8 +29,10 @@ RUN chmod +x /entrypoint.sh \ ENV PORT=3456 \ PROXY_PORT=8080 \ + PROXY_BIND_IP=0.0.0.0 \ TPROXY_PORT=7895 \ DIRECT_BYPASS_CACHE=false \ + RULE_SET_DOWNLOAD_DETOUR=vpn \ DATA_DIR=/var/lib/vpn-proxy \ SING_BOX_CONFIG=/etc/sing-box/config.json \ SING_BOX_CACHE=/var/lib/sing-box/cache.db diff --git a/README.md b/README.md index 69df50f..e728262 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ ipset-кэш намеренно **не** очищается — записи и | 3 | `rule_set: [geoip-ru, geosite-category-ru]` | `direct` | | 4 | Device defaults для `tproxy-in` | `direct` / VPN / `block` | | 5 | Proxy default для `mixed-in` | по умолчанию VPN | -| 6 | Transparent default для unknown devices | по умолчанию `direct` | +| 6 | Transparent default для unknown devices | по умолчанию VPN | | 7 | Всё остальное (`final`) | `direct` | Конфиг генерируется динамически через `buildGatewayConfig()` из подписки + сохранённых правил. Перед применением выполняется `sing-box check`. @@ -142,7 +142,7 @@ DIRECT_BYPASS_TTL=3600 # TTL в секундах ```json { - "defaultTransparentMode": "direct", + "defaultTransparentMode": "vpn", "proxyDefaultMode": "vpn", "devices": [ { @@ -290,7 +290,8 @@ UI доступен на `http://:3456`. | `DIRECT_BYPASS_CACHE` | `false` | Включить dst-IP bypass cache; по умолчанию выключен | | `DIRECT_BYPASS_SET` | `vpn_direct_bypass` | Имя ipset bypass-кэша | | `DIRECT_BYPASS_TTL` | `3600` | TTL записей (секунды) | -| `PROXY_BIND_IP` | `127.0.0.1` | Bind для HTTP/SOCKS; `0.0.0.0` для LAN | +| `RULE_SET_DOWNLOAD_DETOUR` | `vpn` | Через какой outbound sing-box скачивает remote rule-set; `vpn` = выбранный сервер | +| `PROXY_BIND_IP` | `0.0.0.0` | Bind для HTTP/SOCKS в LAN; можно сузить до IP gateway | | `PROXY_FIREWALL` | `true` | Закрыть `PROXY_PORT` не из allowed CIDR | | `PROXY_ALLOWED_CIDRS` | `10.0.0.0/8 172.16.0.0/12 192.168.0.0/16` | Кто может подключаться к mixed proxy | diff --git a/entrypoint.sh b/entrypoint.sh index 445e911..6563a8a 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -6,7 +6,7 @@ TPROXY_MARK="${TPROXY_MARK:-1}" TPROXY_TABLE="${TPROXY_TABLE:-100}" TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}" PROXY_PORT="${PROXY_PORT:-8080}" -PROXY_BIND_IP="${PROXY_BIND_IP:-127.0.0.1}" +PROXY_BIND_IP="${PROXY_BIND_IP:-0.0.0.0}" PROXY_INPUT_CHAIN="${PROXY_INPUT_CHAIN:-VPN_PROXY_INPUT}" PROXY_FIREWALL="${PROXY_FIREWALL:-true}" PROXY_ALLOWED_CIDRS="${PROXY_ALLOWED_CIDRS:-10.0.0.0/8 172.16.0.0/12 192.168.0.0/16}" diff --git a/scripts/deploy-gateway.sh b/scripts/deploy-gateway.sh index e4ada4f..ca2339d 100644 --- a/scripts/deploy-gateway.sh +++ b/scripts/deploy-gateway.sh @@ -41,6 +41,7 @@ if [ ! -f "${DEPLOY_PATH}/.env" ]; then cat > "${DEPLOY_PATH}/.env" <<'EOF' PORT=3456 PROXY_PORT=8080 +PROXY_BIND_IP=0.0.0.0 TPROXY_PORT=7895 TPROXY_MARK=1 TPROXY_TABLE=100 diff --git a/src/server/config.js b/src/server/config.js index fe4f6c0..52ad692 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -6,7 +6,7 @@ export const settings = { port: Number(process.env.PORT || 3456), proxyPort: Number(process.env.PROXY_PORT || 8080), tproxyPort: Number(process.env.TPROXY_PORT || 7895), - bindIp: process.env.PROXY_BIND_IP || "127.0.0.1", + bindIp: process.env.PROXY_BIND_IP || "0.0.0.0", dataDir, distDir: process.env.DIST_DIR || "/app/dist", configPath: @@ -20,6 +20,7 @@ export const settings = { subscriptionCachePath: path.join(dataDir, "subscription-cache.json"), hwidPath: path.join(dataDir, "hwid"), routingRuDirect: String(process.env.ROUTING_RU_DIRECT || "true") !== "false", + ruleSetDownloadDetour: process.env.RULE_SET_DOWNLOAD_DETOUR || "vpn", logLevel: process.env.LOG_LEVEL || "info", appName: "VPN Proxy Gateway", }; diff --git a/src/server/devices.js b/src/server/devices.js index 491ffe4..40ddeeb 100644 --- a/src/server/devices.js +++ b/src/server/devices.js @@ -4,7 +4,7 @@ import { settings } from "./config.js"; export const DEVICE_MODES = new Set(["direct", "vpn", "rules", "block"]); export const DEFAULT_DEVICE_MODES = new Set(["direct", "vpn", "block"]); -export const DEFAULT_DEVICE_MODE = "direct"; +export const DEFAULT_DEVICE_MODE = "vpn"; export const DEFAULT_PROXY_MODE = "vpn"; export const TPROXY_INBOUND = "tproxy-in"; export const MIXED_INBOUND = "mixed-in"; diff --git a/src/server/index.js b/src/server/index.js index 93e5854..968d544 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -21,6 +21,30 @@ import { matchRoute, detectRuleConflicts } from "./routeMatcher.js"; import { tcpPing, resolveHost } from "./ping.js"; const APPLY_HISTORY_LIMIT = 10; +const FALLBACK_RULE_SET_CATALOG = { + geosite: [ + "geosite-category-ru", + "geosite-google", + "geosite-youtube", + "geosite-telegram", + "geosite-openai", + "geosite-apple", + "geosite-github", + "geosite-steam", + "geosite-discord", + "geosite-netflix", + "geosite-cloudflare", + "geosite-category-ads-all", + ], + geoip: [ + "geoip-ru", + "geoip-cloudflare", + "geoip-telegram", + "geoip-google", + "geoip-netflix", + "geoip-private", + ], +}; fs.mkdirSync(settings.dataDir, { recursive: true }); @@ -402,6 +426,88 @@ function sendJson(res, statusCode, payload) { res.end(body); } +function normalizeRuleSetUrl(url) { + const value = String(url || "").trim(); + if (!value) return []; + + const urls = [value]; + + const jsdelivrMatch = value.match( + /^https:\/\/cdn\.jsdelivr\.net\/gh\/([^/]+)\/([^@/]+)@([^/]+)\/(.+)$/i, + ); + if (jsdelivrMatch) { + const [, owner, repo, ref, filePath] = jsdelivrMatch; + urls.push( + `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${filePath}`, + ); + } + + const rawMatch = value.match( + /^https:\/\/raw\.githubusercontent\.com\/([^/]+)\/([^/]+)\/([^/]+)\/(.+)$/i, + ); + if (rawMatch) { + const [, owner, repo, ref, filePath] = rawMatch; + urls.push(`https://cdn.jsdelivr.net/gh/${owner}/${repo}@${ref}/${filePath}`); + } + + return Array.from(new Set(urls)); +} + +function downloadFile(urlOrUrls, outputPath) { + return new Promise((resolve, reject) => { + const candidates = Array.isArray(urlOrUrls) + ? Array.from(new Set(urlOrUrls.flatMap((url) => normalizeRuleSetUrl(url)))) + : normalizeRuleSetUrl(urlOrUrls); + let index = 0; + let lastError = ""; + + function tryNext() { + const url = candidates[index]; + if (!url) { + reject(new Error(lastError || "Не удалось скачать файл")); + return; + } + + let stderr = ""; + const dl = spawn("curl", [ + "-fsSL", + "--retry", + "2", + "--retry-delay", + "1", + "--connect-timeout", + "10", + "--max-time", + "45", + "-A", + "vpn-proxy-app", + url, + "-o", + outputPath, + ]); + dl.stderr.on("data", (d) => { + stderr += d; + }); + dl.on("error", (err) => { + lastError = err.message; + index += 1; + tryNext(); + }); + dl.on("close", (code) => { + if (code === 0) { + resolve(url); + return; + } + lastError = `curl ${url} завершился с кодом ${code}: ${stderr}`; + index += 1; + tryNext(); + }); + } + + tryNext(); + }); +} + function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; @@ -490,6 +596,7 @@ function publicState() { mode: "gateway", port: settings.port, proxyPort: settings.proxyPort, + proxyBindIp: settings.bindIp, tproxyPort: settings.tproxyPort, routingRuDirect: settings.routingRuDirect, configExists: fs.existsSync(settings.configPath), @@ -772,7 +879,7 @@ async function handleApi(req, res) { }); } const profiles = writeDeviceProfiles({ - defaultTransparentMode: "direct", + defaultTransparentMode: "vpn", proxyDefaultMode: "vpn", devices, }); @@ -865,26 +972,7 @@ async function handleApi(req, res) { const tmpSrs = path.join(os.tmpdir(), `singbox-rs-${Date.now()}.srs`); const tmpJson = tmpSrs.replace(".srs", ".json"); try { - await new Promise((resolve, reject) => { - let stderr = ""; - // Скачиваем через curl (доступен в контейнере) - const dl = spawn("curl", [ - "-fsSL", - "--max-time", - "30", - url, - "-o", - tmpSrs, - ]); - dl.stderr.on("data", (d) => { - stderr += d; - }); - dl.on("close", (code) => { - if (code !== 0) - reject(new Error(`curl завершился с кодом ${code}: ${stderr}`)); - else resolve(); - }); - }); + const downloadedFrom = await downloadFile(url, tmpSrs); // Декомпилировать через sing-box rule-set decompile const dec = spawnSync( @@ -896,6 +984,13 @@ async function handleApi(req, res) { }, ); + if (dec.error) { + return sendJson(res, 200, { + success: false, + error: `sing-box не найден или не запустился: ${dec.error.message}`, + }); + } + if (dec.status !== 0) { return sendJson(res, 200, { success: false, @@ -942,6 +1037,7 @@ async function handleApi(req, res) { const result = { tag, url, + downloadedFrom, entries, stats, cachedAt: new Date().toISOString(), @@ -987,6 +1083,11 @@ async function handleApi(req, res) { { headers }, ), ]); + if (!gsRes.ok || !giRes.ok) { + throw new Error( + `GitHub API недоступен: geosite=${gsRes.status}, geoip=${giRes.status}`, + ); + } const gsData = await gsRes.json(); const giData = await giRes.json(); @@ -999,11 +1100,21 @@ async function handleApi(req, res) { .map((f) => f.path.replace(".srs", "")) .sort(); + if (!geosite.length && !geoip.length) { + throw new Error("GitHub API вернул пустой каталог rule-set"); + } + const result = { geosite, geoip, cachedAt: new Date().toISOString() }; writeJson(cacheFile, result); return sendJson(res, 200, { success: true, ...result }); } catch (err) { - return sendJson(res, 500, { success: false, error: err.message }); + const result = { + ...FALLBACK_RULE_SET_CATALOG, + cachedAt: new Date().toISOString(), + fallback: true, + warning: `GitHub каталог не загрузился, показан встроенный список: ${err.message}`, + }; + return sendJson(res, 200, { success: true, ...result }); } } diff --git a/src/server/routeMatcher.js b/src/server/routeMatcher.js index 4c509cc..3e40b97 100644 --- a/src/server/routeMatcher.js +++ b/src/server/routeMatcher.js @@ -143,7 +143,7 @@ export function matchRoute(target, customRules, options = {}) { routingRuDirect = true, vpnTag = "vpn-out", deviceProfiles = { - defaultTransparentMode: "direct", + defaultTransparentMode: "vpn", proxyDefaultMode: "vpn", devices: [], }, @@ -227,7 +227,7 @@ export function matchRoute(target, customRules, options = {}) { // 6. unknown transparent device default. if (inbound === TPROXY_INBOUND) { - const mode = deviceProfiles.defaultTransparentMode || "direct"; + const mode = deviceProfiles.defaultTransparentMode || "vpn"; return { matched: "transparent-default", ruleIndex: -1, diff --git a/src/server/singbox.js b/src/server/singbox.js index 8c58a15..e6db187 100644 --- a/src/server/singbox.js +++ b/src/server/singbox.js @@ -51,7 +51,14 @@ function readCustomRuleSets() { } } -function ruleSets(customRuleSets = []) { +function ruleSetDownloadDetour(vpnTag) { + const detour = String(settings.ruleSetDownloadDetour || "vpn").trim(); + if (!detour || detour === "vpn") return vpnTag; + return detour; +} + +function ruleSets(customRuleSets = [], vpnTag = "direct") { + const downloadDetour = ruleSetDownloadDetour(vpnTag); const builtIn = settings.routingRuDirect ? [ { @@ -59,14 +66,14 @@ function ruleSets(customRuleSets = []) { tag: "geoip-ru", format: "binary", url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-ru.srs", - download_detour: "direct", + download_detour: downloadDetour, }, { type: "remote", tag: "geosite-category-ru", format: "binary", url: "https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-category-ru.srs", - download_detour: "direct", + download_detour: downloadDetour, }, ] : []; @@ -78,7 +85,7 @@ function ruleSets(customRuleSets = []) { tag: String(rs.tag).trim(), format: rs.format || "binary", url: String(rs.url).trim(), - download_detour: "direct", + download_detour: downloadDetour, })); // Пользовательские rule-sets не должны дублировать встроенные @@ -286,7 +293,7 @@ export function buildGatewayConfig( { type: "block", tag: "block" }, ], route: { - rule_set: bypassAll ? [] : ruleSets(customRuleSets), + rule_set: bypassAll ? [] : ruleSets(customRuleSets, vpnOutbound.tag), rules: bypassAll ? [{ ip_is_private: true, outbound: "direct" }] : routeRules(subscriptionConfig.customRules, vpnOutbound.tag), diff --git a/src/web/App.jsx b/src/web/App.jsx index 132c0ff..e250e2f 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -28,7 +28,7 @@ function App() { const [servers, setServers] = useState([]); const [customRules, setCustomRules] = useState([]); const [devicesConfig, setDevicesConfig] = useState({ - defaultTransparentMode: 'direct', + defaultTransparentMode: 'vpn', proxyDefaultMode: 'vpn', devices: [], }); @@ -73,7 +73,7 @@ function App() { setServers(data.servers || []); if (!rulesDirtyRef.current) setCustomRules(data.customRules || []); setDevicesConfig(data.devicesConfig || { - defaultTransparentMode: 'direct', + defaultTransparentMode: 'vpn', proxyDefaultMode: 'vpn', devices: data.devices || [], }); @@ -208,7 +208,7 @@ function App() { try { const data = await api.devices.save(nextConfig); setDevicesConfig({ - defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'direct', + defaultTransparentMode: data.defaultTransparentMode || data.defaultMode || 'vpn', proxyDefaultMode: data.proxyDefaultMode || 'vpn', devices: data.devices || [], }); diff --git a/src/web/components/OverviewPage.jsx b/src/web/components/OverviewPage.jsx index 18d59a5..d57e9a7 100644 --- a/src/web/components/OverviewPage.jsx +++ b/src/web/components/OverviewPage.jsx @@ -137,7 +137,7 @@ function RoutingSummary({ state, onNav, onFlushDirectCache }) { const enabled = rules.filter((r) => r.enabled).length; const cacheCount = state?.directBypassCount || 0; const cacheAvailable = state?.directBypassAvailable && state?.directBypassEnabled; - const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'direct'; + const transparentDefault = state?.devicesConfig?.defaultTransparentMode || 'vpn'; const proxyDefault = state?.devicesConfig?.proxyDefaultMode || 'vpn'; return (
diff --git a/src/web/components/RoutingPage.jsx b/src/web/components/RoutingPage.jsx index 6cb4c7c..44163d9 100644 --- a/src/web/components/RoutingPage.jsx +++ b/src/web/components/RoutingPage.jsx @@ -38,7 +38,7 @@ function DeviceModeSelect({ value, onChange }) { function DevicesCard({ devicesConfig, onDefaultsChange, onAdd, onUpdate, onRemove }) { const devices = devicesConfig?.devices || []; - const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'direct'; + const defaultTransparentMode = devicesConfig?.defaultTransparentMode || devicesConfig?.defaultMode || 'vpn'; const proxyDefaultMode = devicesConfig?.proxyDefaultMode || 'vpn'; return ( diff --git a/src/web/components/SettingsPage.jsx b/src/web/components/SettingsPage.jsx index 6e86f16..25a0fee 100644 --- a/src/web/components/SettingsPage.jsx +++ b/src/web/components/SettingsPage.jsx @@ -496,6 +496,11 @@ function SagerNetSearchCard({ ruleSets, onAdd, busy }) { Ищите по имени: steam, gaming, netflix, apple и т.д. Кеш обновляется раз в 24 ч. + {catalog.fallback && ( +
+ !
{catalog.warning || 'Показан встроенный fallback-каталог.'}
+
+ )}

Порты и маршруты

UI:{state?.port || 3456}
-
Mixed proxy (http+socks5):{state?.proxyPort || 8080}
+
Mixed proxy (http+socks5){state?.proxyBindIp || '0.0.0.0'}:{state?.proxyPort || 8080}
TProxy:{state?.tproxyPort || 7895}
RU direct (geoip-ru){state?.routingRuDirect ? 'включено' : 'выключено'}
diff --git a/src/web/components/StatusPane.jsx b/src/web/components/StatusPane.jsx index c050075..07ba6b3 100644 --- a/src/web/components/StatusPane.jsx +++ b/src/web/components/StatusPane.jsx @@ -38,7 +38,7 @@ export function StatusPane({ state, busy, onStop, onRestart, onShowConfig }) {
- +