Add Harbor Gateway auto-detection for client routing
This commit is contained in:
@@ -25,6 +25,9 @@ export const settings = {
|
||||
statePath: path.join(dataDir, "state.json"),
|
||||
subscriptionCachePath: path.join(dataDir, "subscription-cache.json"),
|
||||
sharedProxyHost: process.env.SHARED_PROXY_HOST || "",
|
||||
hostNetworkStatePath:
|
||||
process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json",
|
||||
gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456),
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
logLevel: process.env.LOG_LEVEL || "info",
|
||||
appName: "VPN Proxy Gateway",
|
||||
|
||||
210
src/server/gatewayPresence.js
Normal file
210
src/server/gatewayPresence.js
Normal file
@@ -0,0 +1,210 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
|
||||
const NONCE_RE = /^[a-f0-9]{32}$/;
|
||||
const PROOF_RE = /^[a-f0-9]{64}$/;
|
||||
const INTERFACE_RE = /^[a-zA-Z0-9._-]{1,32}$/;
|
||||
const MAC_RE = /^[a-f0-9]{2}(?::[a-f0-9]{2}){5}$/i;
|
||||
const SECRET_QUERY_KEYS = new Set(['access_token', 'auth', 'key', 'secret', 'token', 'uuid']);
|
||||
|
||||
function isIpv4(value) {
|
||||
const parts = String(value || '').split('.');
|
||||
return parts.length === 4 && parts.every((part) => (
|
||||
/^\d{1,3}$/.test(part) && Number(part) >= 0 && Number(part) <= 255
|
||||
));
|
||||
}
|
||||
|
||||
function subscriptionSecret(subscriptionUrl) {
|
||||
try {
|
||||
const url = new URL(String(subscriptionUrl || '').trim());
|
||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
||||
const candidates = [
|
||||
url.username,
|
||||
url.password,
|
||||
...[...url.searchParams.entries()]
|
||||
.filter(([key]) => SECRET_QUERY_KEYS.has(key.toLowerCase()))
|
||||
.map(([, value]) => value),
|
||||
pathSegments.at(-1),
|
||||
]
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter((value) => value.length >= 24);
|
||||
if (!candidates.length) return '';
|
||||
|
||||
url.hash = '';
|
||||
url.searchParams.sort();
|
||||
return url.toString();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function presenceProof(subscriptionUrl, nonce, gatewayId) {
|
||||
const credentialUrl = subscriptionSecret(subscriptionUrl);
|
||||
if (!credentialUrl) return '';
|
||||
const key = crypto.createHash('sha256')
|
||||
.update(`harbor-gateway-presence-key\n${credentialUrl}`)
|
||||
.digest();
|
||||
return crypto.createHmac('sha256', key)
|
||||
.update(`v1\n${nonce}\n${gatewayId}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
export function buildGatewayPresence({ appMode, subscriptionUrl, gatewayId, nonce }) {
|
||||
if (!NONCE_RE.test(String(nonce || ''))) {
|
||||
const error = new Error('Некорректный nonce');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const subscription = String(subscriptionUrl || '').trim();
|
||||
const id = String(gatewayId || '').trim();
|
||||
if (appMode !== 'gateway' || !subscriptionSecret(subscription) || !id) {
|
||||
return {
|
||||
success: true,
|
||||
available: false,
|
||||
product: 'harbor',
|
||||
role: appMode,
|
||||
protocolVersion: 1,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
available: true,
|
||||
product: 'harbor',
|
||||
role: 'gateway',
|
||||
protocolVersion: 1,
|
||||
gatewayId: id,
|
||||
transparentRouting: true,
|
||||
proof: presenceProof(subscription, nonce, id),
|
||||
};
|
||||
}
|
||||
|
||||
export function verifyGatewayPresence(payload, { subscriptionUrl, nonce }) {
|
||||
if (
|
||||
payload?.available !== true ||
|
||||
payload?.product !== 'harbor' ||
|
||||
payload?.role !== 'gateway' ||
|
||||
payload?.protocolVersion !== 1 ||
|
||||
payload?.transparentRouting !== true ||
|
||||
!payload.gatewayId ||
|
||||
!NONCE_RE.test(String(nonce || '')) ||
|
||||
!PROOF_RE.test(String(payload.proof || ''))
|
||||
) return false;
|
||||
|
||||
const actual = Buffer.from(payload.proof, 'hex');
|
||||
const expectedProof = presenceProof(subscriptionUrl, nonce, String(payload.gatewayId));
|
||||
if (!expectedProof) return false;
|
||||
const expected = Buffer.from(expectedProof, 'hex');
|
||||
return crypto.timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
export async function probeGatewayPresence({
|
||||
gateway,
|
||||
subscriptionUrl,
|
||||
port = 3456,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = 1000,
|
||||
nonce = crypto.randomBytes(16).toString('hex'),
|
||||
}) {
|
||||
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
|
||||
|
||||
const response = await fetchImpl(
|
||||
`http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`,
|
||||
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
|
||||
);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !verifyGatewayPresence(payload, { subscriptionUrl, nonce })) {
|
||||
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
||||
}
|
||||
|
||||
return { gatewayId: payload.gatewayId };
|
||||
}
|
||||
|
||||
export function normalizeHostNetworkState(value, {
|
||||
now = Date.now(),
|
||||
maxAgeMs = 15_000,
|
||||
} = {}) {
|
||||
const gateway = String(value?.gateway || '').trim();
|
||||
const networkInterface = String(value?.interface || '').trim();
|
||||
const mac = String(value?.mac || '').trim().toLowerCase();
|
||||
const observedAt = Date.parse(value?.observedAt || '');
|
||||
|
||||
// ponytail: IPv4-only matches the current Gateway; add IPv6 when its TProxy path supports it.
|
||||
if (
|
||||
!isIpv4(gateway) ||
|
||||
!INTERFACE_RE.test(networkInterface) ||
|
||||
!MAC_RE.test(mac) ||
|
||||
!Number.isFinite(observedAt) ||
|
||||
observedAt > now + 5_000 ||
|
||||
now - observedAt > maxAgeMs
|
||||
) return null;
|
||||
|
||||
return { gateway, interface: networkInterface, mac, observedAt };
|
||||
}
|
||||
|
||||
export function readHostNetworkState(filePath, options) {
|
||||
try {
|
||||
return normalizeHostNetworkState(
|
||||
JSON.parse(fs.readFileSync(filePath, 'utf8')),
|
||||
options,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sameGatewayRoute(previous, current) {
|
||||
return Boolean(
|
||||
previous &&
|
||||
current &&
|
||||
previous.gateway === current.gateway &&
|
||||
previous.interface === current.interface &&
|
||||
previous.mac === current.mac,
|
||||
);
|
||||
}
|
||||
|
||||
export function createGatewayAutoState() {
|
||||
return {
|
||||
mode: 'local-vpn',
|
||||
failures: 0,
|
||||
gateway: null,
|
||||
gatewayId: '',
|
||||
lastError: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function nextGatewayAutoState(current, {
|
||||
network,
|
||||
verifiedGateway = null,
|
||||
failureLimit = 3,
|
||||
error = 'Gateway presence check failed',
|
||||
}) {
|
||||
if (!network) return createGatewayAutoState();
|
||||
|
||||
const routeChanged = !sameGatewayRoute(current.gateway, network);
|
||||
const base = routeChanged
|
||||
? { ...createGatewayAutoState(), gateway: network }
|
||||
: { ...current, gateway: network };
|
||||
|
||||
if (verifiedGateway?.gatewayId) {
|
||||
return {
|
||||
...base,
|
||||
mode: 'gateway-direct',
|
||||
failures: 0,
|
||||
gatewayId: verifiedGateway.gatewayId,
|
||||
lastError: '',
|
||||
};
|
||||
}
|
||||
|
||||
const failures = base.failures + 1;
|
||||
return {
|
||||
...base,
|
||||
mode: base.mode === 'gateway-direct' && failures < failureLimit
|
||||
? 'gateway-direct'
|
||||
: 'local-vpn',
|
||||
failures,
|
||||
gatewayId: failures < failureLimit ? base.gatewayId : '',
|
||||
lastError: String(error || 'Gateway presence check failed'),
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,14 @@ import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { settings } from './config.js';
|
||||
import {
|
||||
buildGatewayPresence,
|
||||
createGatewayAutoState,
|
||||
nextGatewayAutoState,
|
||||
probeGatewayPresence,
|
||||
readHostNetworkState,
|
||||
sameGatewayRoute,
|
||||
} from './gatewayPresence.js';
|
||||
import { setGatewayInterception } from './gatewayRouting.js';
|
||||
import { tcpPing } from './ping.js';
|
||||
import { buildSharedProxyInfo } from './sharedProxy.js';
|
||||
@@ -12,10 +20,11 @@ import {
|
||||
removeSingboxConfig,
|
||||
writeSingboxConfig,
|
||||
} from './singbox.js';
|
||||
import { fetchSubscription, selectRefreshedServer } from './subscription.js';
|
||||
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
|
||||
|
||||
const MAX_BODY_BYTES = 1_000_000;
|
||||
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||
const GATEWAY_DISCOVERY_INTERVAL_MS = 5_000;
|
||||
|
||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||
|
||||
@@ -23,6 +32,10 @@ let singboxProcess = null;
|
||||
let singboxStartedAt = null;
|
||||
let subscriptionRefreshPromise = null;
|
||||
let subscriptionRefreshTimer = null;
|
||||
let gatewayDiscoveryPromise = null;
|
||||
let gatewayDiscoveryTimer = null;
|
||||
let gatewayAutoState = createGatewayAutoState();
|
||||
let controlOperation = Promise.resolve();
|
||||
|
||||
function readJson(filePath, fallback) {
|
||||
try {
|
||||
@@ -39,6 +52,12 @@ function writeJson(filePath, value) {
|
||||
fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
function serializeControl(operation) {
|
||||
const result = controlOperation.then(operation, operation);
|
||||
controlOperation = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, payload) {
|
||||
res.writeHead(statusCode, { 'content-type': 'application/json; charset=utf-8' });
|
||||
res.end(JSON.stringify(payload));
|
||||
@@ -84,6 +103,12 @@ function subscriptionHost(url) {
|
||||
}
|
||||
}
|
||||
|
||||
function buildActiveConfig(subscriptionConfig, selectedTag) {
|
||||
return buildGatewayConfig(subscriptionConfig, selectedTag, {
|
||||
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
|
||||
});
|
||||
}
|
||||
|
||||
function checkSingboxConfig() {
|
||||
const result = spawnSync('sing-box', ['check', '-c', settings.configPath], {
|
||||
encoding: 'utf8',
|
||||
@@ -170,6 +195,13 @@ function publicState() {
|
||||
selectedTag: state.selectedTag || '',
|
||||
userInfo: state.userInfo || {},
|
||||
fetchedAt: state.fetchedAt || null,
|
||||
gatewayAuto: settings.appMode === 'client' ? {
|
||||
mode: gatewayAutoState.mode,
|
||||
address: gatewayAutoState.gateway?.gateway || '',
|
||||
interface: gatewayAutoState.gateway?.interface || '',
|
||||
failures: gatewayAutoState.failures,
|
||||
lastError: gatewayAutoState.lastError,
|
||||
} : null,
|
||||
servers: (state.servers || []).map((server) => ({
|
||||
...server,
|
||||
tag: String(server.tag || '').trim(),
|
||||
@@ -177,6 +209,120 @@ function publicState() {
|
||||
};
|
||||
}
|
||||
|
||||
function writeCurrentConfig() {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!state.selectedTag || !cached?.config) return false;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag));
|
||||
return true;
|
||||
}
|
||||
|
||||
async function applyGatewayAutoState(nextState, { reconfigure = true } = {}) {
|
||||
const previousState = gatewayAutoState;
|
||||
const modeChanged = previousState.mode !== nextState.mode;
|
||||
gatewayAutoState = nextState;
|
||||
if (!modeChanged) return;
|
||||
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const wasRunning = Boolean(singboxProcess);
|
||||
try {
|
||||
const configured = writeCurrentConfig();
|
||||
if (reconfigure && configured && wasRunning) await startSingbox();
|
||||
} catch (error) {
|
||||
gatewayAutoState = previousState;
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
throw error;
|
||||
}
|
||||
|
||||
const route = nextState.gateway?.gateway ? ` (${nextState.gateway.gateway})` : '';
|
||||
console.log(`[control] client route: ${nextState.mode}${route}`);
|
||||
}
|
||||
|
||||
function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
if (settings.appMode !== 'client') return Promise.resolve(gatewayAutoState);
|
||||
if (gatewayDiscoveryPromise) return gatewayDiscoveryPromise;
|
||||
|
||||
gatewayDiscoveryPromise = serializeControl(async () => {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const network = state.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
|
||||
if (!network) {
|
||||
const nextState = nextGatewayAutoState(gatewayAutoState, { network: null });
|
||||
if (state.subscriptionUrl) {
|
||||
nextState.lastError = 'macOS default gateway недоступен или устарел';
|
||||
}
|
||||
await applyGatewayAutoState(
|
||||
nextState,
|
||||
{ reconfigure },
|
||||
);
|
||||
return gatewayAutoState;
|
||||
}
|
||||
|
||||
if (
|
||||
gatewayAutoState.mode === 'gateway-direct' &&
|
||||
!sameGatewayRoute(gatewayAutoState.gateway, network)
|
||||
) {
|
||||
await applyGatewayAutoState(
|
||||
nextGatewayAutoState(gatewayAutoState, { network }),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const verifiedGateway = await probeGatewayPresence({
|
||||
gateway: network.gateway,
|
||||
port: settings.gatewayPresencePort,
|
||||
subscriptionUrl: state.subscriptionUrl,
|
||||
});
|
||||
const latestState = readJson(settings.statePath, {});
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!sameGatewayRoute(network, latestNetwork)
|
||||
) {
|
||||
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
||||
return gatewayAutoState;
|
||||
}
|
||||
await applyGatewayAutoState(
|
||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
|
||||
{ reconfigure },
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error?.message || 'Gateway presence check failed';
|
||||
const latestState = readJson(settings.statePath, {});
|
||||
const latestNetwork = latestState.subscriptionUrl
|
||||
? readHostNetworkState(settings.hostNetworkStatePath)
|
||||
: null;
|
||||
if (
|
||||
latestState.subscriptionUrl !== state.subscriptionUrl ||
|
||||
!sameGatewayRoute(network, latestNetwork)
|
||||
) {
|
||||
await applyGatewayAutoState(createGatewayAutoState(), { reconfigure });
|
||||
return gatewayAutoState;
|
||||
}
|
||||
if (gatewayAutoState.lastError !== reason) {
|
||||
console.warn(`[control] Gateway не используется: ${reason}`);
|
||||
}
|
||||
await applyGatewayAutoState(
|
||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
|
||||
{ reconfigure },
|
||||
);
|
||||
}
|
||||
return gatewayAutoState;
|
||||
}).finally(() => {
|
||||
gatewayDiscoveryPromise = null;
|
||||
});
|
||||
|
||||
return gatewayDiscoveryPromise;
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedTag) {
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!cached?.config) throw new Error('Сначала загрузите подписку');
|
||||
@@ -184,7 +330,7 @@ async function applySelectedServer(selectedTag) {
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
writeSingboxConfig(buildGatewayConfig(cached.config, selectedTag));
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, selectedTag));
|
||||
try {
|
||||
await startSingbox();
|
||||
} catch (error) {
|
||||
@@ -212,55 +358,57 @@ function refreshSavedSubscription() {
|
||||
|
||||
const subscriptionUrl = initialState.subscriptionUrl;
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
const currentState = readJson(settings.statePath, {});
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new Error('Подписка была изменена во время обновления');
|
||||
}
|
||||
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
const previousCache = readJson(settings.subscriptionCachePath, null);
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
||||
!previousCache?.config || !isDeepStrictEqual(
|
||||
buildGatewayConfig(previousCache.config, currentState.selectedTag),
|
||||
buildGatewayConfig(parsed.config, selectedTag),
|
||||
)
|
||||
);
|
||||
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
||||
|
||||
try {
|
||||
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
|
||||
else if (selectedTag) {
|
||||
if (!singboxProcess) writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag));
|
||||
} else {
|
||||
removeSingboxConfig();
|
||||
return serializeControl(async () => {
|
||||
const currentState = readJson(settings.statePath, {});
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new Error('Подписка была изменена во время обновления');
|
||||
}
|
||||
} catch (error) {
|
||||
if (previousCache) writeJson(settings.subscriptionCachePath, previousCache);
|
||||
else fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
throw error;
|
||||
}
|
||||
|
||||
writeJson(settings.statePath, {
|
||||
...readJson(settings.statePath, {}),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
const previousCache = readJson(settings.subscriptionCachePath, null);
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
||||
!previousCache?.config || !isDeepStrictEqual(
|
||||
buildActiveConfig(previousCache.config, currentState.selectedTag),
|
||||
buildActiveConfig(parsed.config, selectedTag),
|
||||
)
|
||||
);
|
||||
writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed });
|
||||
|
||||
try {
|
||||
if (singboxProcess && activeConfigChanged) await applySelectedServer(selectedTag);
|
||||
else if (selectedTag) {
|
||||
if (!singboxProcess) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
|
||||
} else {
|
||||
removeSingboxConfig();
|
||||
}
|
||||
} catch (error) {
|
||||
if (previousCache) writeJson(settings.subscriptionCachePath, previousCache);
|
||||
else fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else fs.writeFileSync(settings.configPath, previousConfig, 'utf8');
|
||||
throw error;
|
||||
}
|
||||
|
||||
writeJson(settings.statePath, {
|
||||
...readJson(settings.statePath, {}),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
};
|
||||
})().finally(() => {
|
||||
subscriptionRefreshPromise = null;
|
||||
});
|
||||
@@ -283,6 +431,17 @@ async function handleApi(req, res) {
|
||||
}));
|
||||
}
|
||||
|
||||
const requestUrl = new URL(req.url, `http://localhost:${settings.port}`);
|
||||
if (req.method === 'GET' && requestUrl.pathname === '/api/gateway-presence') {
|
||||
const state = readJson(settings.statePath, {});
|
||||
return sendJson(res, 200, buildGatewayPresence({
|
||||
appMode: settings.appMode,
|
||||
subscriptionUrl: state.subscriptionUrl,
|
||||
gatewayId: getHwid(),
|
||||
nonce: requestUrl.searchParams.get('nonce'),
|
||||
}));
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||
const state = readJson(settings.statePath, {});
|
||||
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
||||
@@ -297,15 +456,18 @@ async function handleApi(req, res) {
|
||||
const { url = '' } = await readBody(req);
|
||||
const normalizedUrl = String(url).trim();
|
||||
const parsed = await fetchSubscription(normalizedUrl);
|
||||
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
||||
writeJson(settings.statePath, {
|
||||
subscriptionUrl: normalizedUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
await serializeControl(async () => {
|
||||
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
||||
writeJson(settings.statePath, {
|
||||
subscriptionUrl: normalizedUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
});
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
});
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
return sendJson(res, 200, { success: true, ...parsed });
|
||||
}
|
||||
|
||||
@@ -320,10 +482,13 @@ async function handleApi(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
writeJson(settings.statePath, {});
|
||||
await serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
fs.rmSync(settings.subscriptionCachePath, { force: true });
|
||||
writeJson(settings.statePath, {});
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
});
|
||||
return sendJson(res, 200, { success: true });
|
||||
}
|
||||
|
||||
@@ -331,20 +496,24 @@ async function handleApi(req, res) {
|
||||
const { selectedTag = '' } = await readBody(req);
|
||||
const tag = String(selectedTag).trim();
|
||||
if (!tag) return sendJson(res, 400, { success: false, error: 'Выберите сервер' });
|
||||
await applySelectedServer(tag);
|
||||
await serializeControl(() => applySelectedServer(tag));
|
||||
return sendJson(res, 200, { success: true, selectedTag: tag });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||
await stopSingbox();
|
||||
await serializeControl(() => stopSingbox());
|
||||
return sendJson(res, 200, { success: true, singboxRunning: false });
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/restart') {
|
||||
if (!fs.existsSync(settings.configPath)) {
|
||||
return sendJson(res, 400, { success: false, error: 'Сначала выберите сервер' });
|
||||
}
|
||||
await startSingbox();
|
||||
await serializeControl(async () => {
|
||||
if (!fs.existsSync(settings.configPath)) {
|
||||
const error = new Error('Сначала выберите сервер');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
await startSingbox();
|
||||
});
|
||||
return sendJson(res, 200, { success: true, singboxRunning: true });
|
||||
}
|
||||
|
||||
@@ -391,17 +560,18 @@ const server = http.createServer(async (req, res) => {
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(subscriptionRefreshTimer);
|
||||
await stopSingbox();
|
||||
clearInterval(gatewayDiscoveryTimer);
|
||||
await serializeControl(() => stopSingbox());
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
const state = readJson(settings.statePath, {});
|
||||
const cached = readJson(settings.subscriptionCachePath, null);
|
||||
if (!fs.existsSync(settings.configPath) && state.selectedTag && cached?.config) {
|
||||
writeSingboxConfig(buildGatewayConfig(cached.config, state.selectedTag));
|
||||
await refreshGatewayAutoMode({ reconfigure: false })
|
||||
.catch((error) => console.warn(`[control] Gateway не определён: ${error.message}`));
|
||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||
writeCurrentConfig();
|
||||
}
|
||||
await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
||||
|
||||
@@ -415,3 +585,9 @@ subscriptionRefreshTimer = setInterval(() => {
|
||||
.catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`));
|
||||
}, SUBSCRIPTION_REFRESH_INTERVAL_MS);
|
||||
subscriptionRefreshTimer.unref();
|
||||
|
||||
gatewayDiscoveryTimer = setInterval(() => {
|
||||
refreshGatewayAutoMode()
|
||||
.catch((error) => console.warn(`[control] Gateway detection failed: ${error.message}`));
|
||||
}, GATEWAY_DISCOVERY_INTERVAL_MS);
|
||||
gatewayDiscoveryTimer.unref();
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
function normalizeControlUrl(value) {
|
||||
const raw = String(value || "").trim();
|
||||
if (!raw) return "";
|
||||
const withProtocol = /^https?:\/\//i.test(raw) ? raw : `http://${raw}`;
|
||||
const url = new URL(withProtocol);
|
||||
if (!["http:", "https:"].includes(url.protocol)) {
|
||||
throw new Error("Gateway URL must use http or https");
|
||||
}
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
url.pathname = url.pathname.replace(/\/api\/shared-proxy\/?$/, "") || "/";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function proxyHostFromHeader(hostHeader) {
|
||||
const raw = String(hostHeader || "").trim();
|
||||
if (!raw) return "";
|
||||
@@ -22,17 +8,6 @@ function proxyHostFromHeader(hostHeader) {
|
||||
return raw.split(":")[0];
|
||||
}
|
||||
|
||||
function normalizeProxyInfo(proxy) {
|
||||
if (!proxy || typeof proxy !== "object") return null;
|
||||
const host = String(proxy.host || "").trim();
|
||||
const port = Number.parseInt(proxy.port, 10);
|
||||
const protocol = proxy.protocol === "http" ? "http" : "socks5";
|
||||
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) {
|
||||
return null;
|
||||
}
|
||||
return { host, port, protocol };
|
||||
}
|
||||
|
||||
export function buildSharedProxyInfo({
|
||||
appMode,
|
||||
proxyPort,
|
||||
@@ -67,28 +42,3 @@ export function buildSharedProxyInfo({
|
||||
proxy,
|
||||
};
|
||||
}
|
||||
|
||||
export async function checkSharedProxyGateway(controlUrl, fetchImpl = fetch) {
|
||||
const baseUrl = normalizeControlUrl(controlUrl);
|
||||
const response = await fetchImpl(`${baseUrl}/api/shared-proxy`, {
|
||||
headers: { accept: "application/json" },
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || `Gateway returned ${response.status}`);
|
||||
}
|
||||
if (!data.available) {
|
||||
throw new Error("Gateway shared proxy is not available");
|
||||
}
|
||||
|
||||
const sharedProxy = normalizeProxyInfo(data.proxy);
|
||||
if (!sharedProxy) {
|
||||
throw new Error("Gateway returned invalid shared proxy settings");
|
||||
}
|
||||
|
||||
return {
|
||||
sharedProxyEnabled: true,
|
||||
sharedProxyControlUrl: baseUrl,
|
||||
sharedProxy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -16,14 +16,18 @@ function findOutbound(subscriptionConfig, selectedTag) {
|
||||
));
|
||||
}
|
||||
|
||||
export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||
export function buildGatewayConfig(subscriptionConfig, selectedTag, { clientDirect = false } = {}) {
|
||||
const clientMode = settings.appMode === 'client';
|
||||
const vpnOutbound = structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||
if (!vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||
if (!vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||
if (vpnOutbound.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||
const directClient = clientMode && clientDirect;
|
||||
const vpnOutbound = directClient
|
||||
? null
|
||||
: structuredClone(findOutbound(subscriptionConfig, selectedTag));
|
||||
if (!directClient && !vpnOutbound) throw new Error(`Outbound не найден: ${selectedTag}`);
|
||||
if (vpnOutbound && !vpnOutbound.tag) vpnOutbound.tag = 'vpn-out';
|
||||
if (vpnOutbound?.type === 'vless' && !vpnOutbound.packet_encoding) {
|
||||
vpnOutbound.packet_encoding = 'xudp';
|
||||
}
|
||||
const outboundTag = directClient ? 'direct' : vpnOutbound.tag;
|
||||
|
||||
const inbounds = [
|
||||
...(!clientMode ? [{
|
||||
@@ -44,10 +48,10 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||
},
|
||||
];
|
||||
const rules = clientMode
|
||||
? [{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag }]
|
||||
? [{ inbound: [MIXED_INBOUND], outbound: outboundTag }]
|
||||
: [
|
||||
{ inbound: [TPROXY_INBOUND], outbound: vpnOutbound.tag },
|
||||
{ inbound: [MIXED_INBOUND], outbound: vpnOutbound.tag },
|
||||
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
|
||||
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -58,14 +62,14 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag) {
|
||||
dns: { independent_cache: true },
|
||||
inbounds,
|
||||
outbounds: [
|
||||
vpnOutbound,
|
||||
...(vpnOutbound ? [vpnOutbound] : []),
|
||||
{ type: 'direct', tag: 'direct' },
|
||||
{ type: 'block', tag: 'block' },
|
||||
],
|
||||
route: {
|
||||
rule_set: [],
|
||||
rules,
|
||||
final: vpnOutbound.tag,
|
||||
final: outboundTag,
|
||||
...(clientMode ? {} : { auto_detect_interface: true }),
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user