feat: добавлена поддержка кэша прямого обхода с использованием ipset
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
All checks were successful
Build and Deploy Gateway / build-and-deploy (push) Successful in 19s
Refs: None
This commit is contained in:
@@ -3,7 +3,7 @@ ARG SINGBOX_VERSION=1.12.13
|
|||||||
COPY dist /app/dist
|
COPY dist /app/dist
|
||||||
|
|
||||||
RUN apt-get update \
|
RUN apt-get update \
|
||||||
&& apt-get install -y --no-install-recommends ca-certificates curl iptables iproute2 nodejs dumb-init \
|
&& apt-get install -y --no-install-recommends ca-certificates curl iptables ipset iproute2 nodejs dumb-init \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ TPROXY_MARK="${TPROXY_MARK:-1}"
|
|||||||
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
TPROXY_TABLE="${TPROXY_TABLE:-100}"
|
||||||
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
TPROXY_CHAIN="${TPROXY_CHAIN:-VPN_PROXY_TPROXY}"
|
||||||
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
BYPASS_CIDRS="${BYPASS_CIDRS:-0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4}"
|
||||||
|
# Имя ipset для IP-адресов, которые sing-box отправил напрямую (direct bypass cache)
|
||||||
|
DIRECT_BYPASS_SET="${DIRECT_BYPASS_SET:-vpn_direct_bypass}"
|
||||||
|
# TTL записи в ipset (секунды). По умолчанию 1 час.
|
||||||
|
DIRECT_BYPASS_TTL="${DIRECT_BYPASS_TTL:-3600}"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
printf '[gateway-entrypoint] %s\n' "$*"
|
printf '[gateway-entrypoint] %s\n' "$*"
|
||||||
@@ -22,6 +26,15 @@ cleanup_tproxy() {
|
|||||||
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
ipt -t mangle -X "$TPROXY_CHAIN" 2>/dev/null || true
|
||||||
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
ip rule del fwmark "$TPROXY_MARK" table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
ip route flush table "$TPROXY_TABLE" 2>/dev/null || true
|
||||||
|
# ipset не чистим при завершении — TTL сам истечёт
|
||||||
|
}
|
||||||
|
|
||||||
|
setup_direct_bypass_set() {
|
||||||
|
log "setup ipset ${DIRECT_BYPASS_SET} (timeout=${DIRECT_BYPASS_TTL}s)"
|
||||||
|
# Создаём с timeout; если уже существует — не трогаем (сохраняем накопленные записи)
|
||||||
|
ipset create "$DIRECT_BYPASS_SET" hash:ip timeout "$DIRECT_BYPASS_TTL" 2>/dev/null || true
|
||||||
|
# Экспортируем имя для использования в Node.js через env
|
||||||
|
export DIRECT_BYPASS_SET DIRECT_BYPASS_TTL
|
||||||
}
|
}
|
||||||
|
|
||||||
setup_tproxy() {
|
setup_tproxy() {
|
||||||
@@ -36,6 +49,9 @@ setup_tproxy() {
|
|||||||
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -m addrtype --dst-type LOCAL -j RETURN
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -m mark --mark "$TPROXY_MARK" -j RETURN
|
||||||
|
|
||||||
|
# Direct bypass cache: IP-адреса из ipset идут напрямую, минуя sing-box
|
||||||
|
ipt -t mangle -A "$TPROXY_CHAIN" -m set --match-set "$DIRECT_BYPASS_SET" dst -j RETURN
|
||||||
|
|
||||||
for cidr in $BYPASS_CIDRS; do
|
for cidr in $BYPASS_CIDRS; do
|
||||||
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
ipt -t mangle -A "$TPROXY_CHAIN" -d "$cidr" -j RETURN
|
||||||
done
|
done
|
||||||
@@ -45,6 +61,7 @@ setup_tproxy() {
|
|||||||
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
|
ipt -t mangle -A PREROUTING -j "$TPROXY_CHAIN"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setup_direct_bypass_set
|
||||||
setup_tproxy
|
setup_tproxy
|
||||||
|
|
||||||
node /app/src/server/index.js &
|
node /app/src/server/index.js &
|
||||||
|
|||||||
@@ -21,6 +21,68 @@ fs.mkdirSync(settings.dataDir, { recursive: true });
|
|||||||
|
|
||||||
const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid");
|
const SINGBOX_PID_FILE = path.join(settings.dataDir, "singbox.pid");
|
||||||
|
|
||||||
|
// ─── Direct bypass cache (ipset) ────────────────────────────────────────────
|
||||||
|
const DIRECT_BYPASS_SET = process.env.DIRECT_BYPASS_SET || "vpn_direct_bypass";
|
||||||
|
const DIRECT_BYPASS_TTL = process.env.DIRECT_BYPASS_TTL || "3600";
|
||||||
|
const IPSET_AVAILABLE = (() => {
|
||||||
|
try {
|
||||||
|
spawnSync("ipset", ["version"], { timeout: 1000 });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const IP_RE = /^\d{1,3}(?:\.\d{1,3}){3}$/;
|
||||||
|
|
||||||
|
// Локальный счётчик добавленных IP (RAM-only, сбрасывается при перезапуске)
|
||||||
|
let directBypassCount = 0;
|
||||||
|
|
||||||
|
function addToDirectBypass(ip) {
|
||||||
|
if (!IPSET_AVAILABLE || !IP_RE.test(ip)) return;
|
||||||
|
try {
|
||||||
|
spawnSync(
|
||||||
|
"ipset",
|
||||||
|
["add", DIRECT_BYPASS_SET, ip, "timeout", DIRECT_BYPASS_TTL, "-exist"],
|
||||||
|
{
|
||||||
|
timeout: 500,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
directBypassCount++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushDirectBypass() {
|
||||||
|
directBypassCount = 0;
|
||||||
|
if (!IPSET_AVAILABLE) return;
|
||||||
|
try {
|
||||||
|
spawnSync("ipset", ["flush", DIRECT_BYPASS_SET], { timeout: 1000 });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function listDirectBypass() {
|
||||||
|
if (!IPSET_AVAILABLE) return [];
|
||||||
|
try {
|
||||||
|
const result = spawnSync(
|
||||||
|
"ipset",
|
||||||
|
["list", DIRECT_BYPASS_SET, "-output", "plain"],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 2000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const lines = (result.stdout || "").split("\n");
|
||||||
|
// После строки "Members:" идут IP-адреса
|
||||||
|
const membersIdx = lines.findIndex((l) => l.trim() === "Members:");
|
||||||
|
if (membersIdx === -1) return [];
|
||||||
|
return lines
|
||||||
|
.slice(membersIdx + 1)
|
||||||
|
.map((l) => l.trim().split(" ")[0])
|
||||||
|
.filter((l) => IP_RE.test(l));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let singboxProcess = null;
|
let singboxProcess = null;
|
||||||
let singboxStartedAt = null;
|
let singboxStartedAt = null;
|
||||||
const LOG_BUFFER_SIZE = 500;
|
const LOG_BUFFER_SIZE = 500;
|
||||||
@@ -129,6 +191,10 @@ function pushLog(level, line) {
|
|||||||
const traffic = parseTrafficLine(line);
|
const traffic = parseTrafficLine(line);
|
||||||
if (traffic) {
|
if (traffic) {
|
||||||
pushTrafficEntry(traffic);
|
pushTrafficEntry(traffic);
|
||||||
|
// Если direct и назначение — IP, добавляем в bypass-кэш
|
||||||
|
if (traffic.category === "direct" && IP_RE.test(traffic.host)) {
|
||||||
|
addToDirectBypass(traffic.host);
|
||||||
|
}
|
||||||
} else if (
|
} else if (
|
||||||
(level === "info" || level === "debug") &&
|
(level === "info" || level === "debug") &&
|
||||||
_debugUnparsed < 30 &&
|
_debugUnparsed < 30 &&
|
||||||
@@ -387,6 +453,8 @@ function publicState() {
|
|||||||
rulesUpdatedAt: state.rulesUpdatedAt || null,
|
rulesUpdatedAt: state.rulesUpdatedAt || null,
|
||||||
rulesAppliedAt: state.rulesAppliedAt || null,
|
rulesAppliedAt: state.rulesAppliedAt || null,
|
||||||
bypassMode: Boolean(state.bypassMode),
|
bypassMode: Boolean(state.bypassMode),
|
||||||
|
directBypassCount,
|
||||||
|
directBypassAvailable: IPSET_AVAILABLE,
|
||||||
...rest,
|
...rest,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -540,6 +608,21 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true });
|
return sendJson(res, 200, { success: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === "GET" && req.url === "/api/direct-cache") {
|
||||||
|
const members = listDirectBypass();
|
||||||
|
return sendJson(res, 200, {
|
||||||
|
success: true,
|
||||||
|
count: members.length,
|
||||||
|
available: IPSET_AVAILABLE,
|
||||||
|
members,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.method === "DELETE" && req.url === "/api/direct-cache") {
|
||||||
|
flushDirectBypass();
|
||||||
|
return sendJson(res, 200, { success: true });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === "POST" && req.url === "/api/bypass") {
|
if (req.method === "POST" && req.url === "/api/bypass") {
|
||||||
const body = await readBody(req);
|
const body = await readBody(req);
|
||||||
const enabled = Boolean(body.enabled);
|
const enabled = Boolean(body.enabled);
|
||||||
|
|||||||
@@ -186,6 +186,13 @@ function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function flushDirectCache() {
|
||||||
|
return withBusy('Bypass-кэш сброшен', async () => {
|
||||||
|
await api.directCache.flush();
|
||||||
|
await loadState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// === Rules CRUD ===
|
// === Rules CRUD ===
|
||||||
function emptyRule() {
|
function emptyRule() {
|
||||||
return {
|
return {
|
||||||
@@ -321,6 +328,7 @@ function App() {
|
|||||||
onShowConfig={() => setConfigOpen(true)}
|
onShowConfig={() => setConfigOpen(true)}
|
||||||
onNav={navigate}
|
onNav={navigate}
|
||||||
onBypassToggle={toggleBypass}
|
onBypassToggle={toggleBypass}
|
||||||
|
onFlushDirectCache={flushDirectCache}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{page === 'servers' && (
|
{page === 'servers' && (
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ export const api = {
|
|||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
directCache: {
|
||||||
|
get: () => request("/api/direct-cache"),
|
||||||
|
flush: () => request("/api/direct-cache", { method: "DELETE" }),
|
||||||
|
},
|
||||||
|
|
||||||
route: {
|
route: {
|
||||||
check: ({ host, ip, port, network }) =>
|
check: ({ host, ip, port, network }) =>
|
||||||
request("/api/route/check", {
|
request("/api/route/check", {
|
||||||
|
|||||||
@@ -132,9 +132,11 @@ function RecentEvents({ onNav }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function RoutingSummary({ state, onNav }) {
|
function RoutingSummary({ state, onNav, onFlushDirectCache }) {
|
||||||
const rules = state?.customRules || [];
|
const rules = state?.customRules || [];
|
||||||
const enabled = rules.filter((r) => r.enabled).length;
|
const enabled = rules.filter((r) => r.enabled).length;
|
||||||
|
const cacheCount = state?.directBypassCount || 0;
|
||||||
|
const cacheAvailable = state?.directBypassAvailable;
|
||||||
return (
|
return (
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
@@ -148,12 +150,23 @@ function RoutingSummary({ state, onNav }) {
|
|||||||
)}
|
)}
|
||||||
<div className="row"><span className="key">Custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
<div className="row"><span className="key">Custom правил</span><span className="val">{enabled} из {rules.length}</span></div>
|
||||||
<div className="row"><span className="key">Остальное</span><span className="val text-warning">→ VPN</span></div>
|
<div className="row"><span className="key">Остальное</span><span className="val text-warning">→ VPN</span></div>
|
||||||
|
{cacheAvailable && (
|
||||||
|
<div className="row">
|
||||||
|
<span className="key">Direct bypass cache</span>
|
||||||
|
<span className="val" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<span className="text-success">{cacheCount} IP</span>
|
||||||
|
<button className="btn btn-ghost" style={{ fontSize: 11, padding: '1px 6px' }} onClick={onFlushDirectCache} title="Сбросить — все IP снова пройдут через sing-box один раз">
|
||||||
|
✕ сбросить
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle }) {
|
export function OverviewPage({ state, status, busy, onRestart, onStop, onShowConfig, onNav, onBypassToggle, onFlushDirectCache }) {
|
||||||
return (
|
return (
|
||||||
<div className="section-stack">
|
<div className="section-stack">
|
||||||
{state?.bypassMode && (
|
{state?.bypassMode && (
|
||||||
@@ -168,7 +181,7 @@ export function OverviewPage({ state, status, busy, onRestart, onStop, onShowCon
|
|||||||
<StatusHero state={state} status={status} />
|
<StatusHero state={state} status={status} />
|
||||||
<div className="grid-2">
|
<div className="grid-2">
|
||||||
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
|
<QuickActions state={state} busy={busy} onRestart={onRestart} onStop={onStop} onShowConfig={onShowConfig} onNav={onNav} onBypassToggle={onBypassToggle} />
|
||||||
<RoutingSummary state={state} onNav={onNav} />
|
<RoutingSummary state={state} onNav={onNav} onFlushDirectCache={onFlushDirectCache} />
|
||||||
</div>
|
</div>
|
||||||
<RecentEvents onNav={onNav} />
|
<RecentEvents onNav={onNav} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user