mirror of
https://github.com/remnawave/node.git
synced 2026-08-31 23:53:30 +00:00
fix: probe alternate WARP endpoints
This commit is contained in:
parent
c48679f94b
commit
ad6e71ffef
5 changed files with 184 additions and 9 deletions
|
|
@ -19,7 +19,9 @@
|
|||
},
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
"format": "prettier --write package.json \"src/**/*.ts\" \"test/**/*.{ts,mjs,cjs}\"",
|
||||
"format:check": "prettier --check package.json \"src/**/*.ts\" \"test/**/*.{ts,mjs,cjs}\"",
|
||||
"test": "node --require ts-node/register --test test/*.test.*",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch",
|
||||
"start:debug": "NODE_ENV=development nest start --debug --watch",
|
||||
|
|
@ -102,4 +104,4 @@
|
|||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
26
src/modules/warp/warp-endpoint-policy.ts
Normal file
26
src/modules/warp/warp-endpoint-policy.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
export const WARP_ENDPOINT_CANDIDATES = [
|
||||
'162.159.192.1:2408',
|
||||
'162.159.192.1:500',
|
||||
'162.159.192.1:1701',
|
||||
'162.159.192.1:4500',
|
||||
] as const;
|
||||
|
||||
type TWarpTraceState = null | { warp: string };
|
||||
|
||||
export function getWarpEndpointCandidates(configuredEndpoint: string | null): string[] {
|
||||
if (
|
||||
!configuredEndpoint ||
|
||||
!WARP_ENDPOINT_CANDIDATES.some((endpoint) => endpoint === configuredEndpoint)
|
||||
) {
|
||||
return [...WARP_ENDPOINT_CANDIDATES];
|
||||
}
|
||||
|
||||
return [
|
||||
configuredEndpoint,
|
||||
...WARP_ENDPOINT_CANDIDATES.filter((endpoint) => endpoint !== configuredEndpoint),
|
||||
];
|
||||
}
|
||||
|
||||
export function hasDualStackWarpTrace(ipv4: TWarpTraceState, ipv6: TWarpTraceState): boolean {
|
||||
return ipv4?.warp === 'on' && ipv6?.warp === 'on';
|
||||
}
|
||||
|
|
@ -5,6 +5,11 @@ import type { THostConnectivity, TWarpOperation, TWarpStatus } from '@libs/contr
|
|||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
getWarpEndpointCandidates,
|
||||
hasDualStackWarpTrace,
|
||||
WARP_ENDPOINT_CANDIDATES,
|
||||
} from './warp-endpoint-policy';
|
||||
import { TWarpCommandResult } from './warp.types';
|
||||
|
||||
const WARP_INTERFACE = 'warp';
|
||||
|
|
@ -12,7 +17,7 @@ const WARP_CONFIG_PATH = '/etc/wireguard/warp.conf';
|
|||
const WARP_TOOL_PATH = '/usr/local/bin/wgcf';
|
||||
const WARP_TRACE_URL = 'https://www.cloudflare.com/cdn-cgi/trace';
|
||||
const WGCF_RELEASES_API_URL = 'https://api.github.com/repos/ViRb3/wgcf/releases/latest';
|
||||
const WARP_ENDPOINT = '162.159.192.1:2408';
|
||||
const WARP_DEFAULT_ENDPOINT = WARP_ENDPOINT_CANDIDATES[0];
|
||||
const WARP_IPV6_ROUTE_METRIC = 4242;
|
||||
const WARP_IPV6_ROUTE_POST_UP = `PostUp = ip -6 route replace ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC}`;
|
||||
const WARP_IPV6_ROUTE_PRE_DOWN = `PreDown = ip -6 route del ::/0 dev %i metric ${WARP_IPV6_ROUTE_METRIC} 2>/dev/null || true`;
|
||||
|
|
@ -67,7 +72,7 @@ test -s wgcf-profile.conf
|
|||
|
||||
echo "[warp] normalizing WireGuard profile"
|
||||
sed -i -E '/^DNS =/d' wgcf-profile.conf
|
||||
sed -i -E 's#^Endpoint = .*$#Endpoint = ${WARP_ENDPOINT}#' wgcf-profile.conf
|
||||
sed -i -E 's#^Endpoint = .*$#Endpoint = ${WARP_DEFAULT_ENDPOINT}#' wgcf-profile.conf
|
||||
grep -q '^Table = off$' wgcf-profile.conf || sed -i '/^MTU =/a Table = off' wgcf-profile.conf
|
||||
grep -q '^PersistentKeepalive = ' wgcf-profile.conf \
|
||||
|| sed -i '/^Endpoint =/a PersistentKeepalive = 25' wgcf-profile.conf
|
||||
|
|
@ -213,6 +218,7 @@ export class WarpService {
|
|||
await this.execFixed('/usr/bin/wg-quick', ['up', WARP_INTERFACE], 20_000, {
|
||||
onOutput: (line) => this.appendOperationLog(line),
|
||||
});
|
||||
await this.ensureReachableWarpEndpoint();
|
||||
this.finishOperation('WARP enabled');
|
||||
return await this.getStatus();
|
||||
} catch (error) {
|
||||
|
|
@ -335,7 +341,9 @@ export class WarpService {
|
|||
if (!this.hasWarpConfig()) return;
|
||||
|
||||
const original = readFileSync(WARP_CONFIG_PATH, 'utf8');
|
||||
let updated = original.replace(/^Endpoint = .*$/m, `Endpoint = ${WARP_ENDPOINT}`);
|
||||
const configuredEndpoint = this.getConfiguredWarpEndpoint(original);
|
||||
const preferredEndpoint = getWarpEndpointCandidates(configuredEndpoint)[0];
|
||||
let updated = original.replace(/^Endpoint = .*$/m, `Endpoint = ${preferredEndpoint}`);
|
||||
|
||||
if (!/^Table = off$/m.test(updated)) {
|
||||
updated = updated.replace(/^MTU = .*$/m, (line) => `${line}\nTable = off`);
|
||||
|
|
@ -384,6 +392,38 @@ export class WarpService {
|
|||
}
|
||||
}
|
||||
|
||||
private async ensureReachableWarpEndpoint(): Promise<void> {
|
||||
const configuredEndpoint = this.getConfiguredWarpEndpoint();
|
||||
const peer = (
|
||||
await this.execFixed('/usr/bin/wg', ['show', WARP_INTERFACE, 'peers'], 5_000)
|
||||
).stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
|
||||
if (!peer) {
|
||||
throw new Error('WARP peer is missing');
|
||||
}
|
||||
|
||||
for (const endpoint of getWarpEndpointCandidates(configuredEndpoint)) {
|
||||
this.appendOperationLog(`Testing WARP endpoint ${endpoint}`);
|
||||
await this.execFixed(
|
||||
'/usr/bin/wg',
|
||||
['set', WARP_INTERFACE, 'peer', peer, 'endpoint', endpoint],
|
||||
5_000,
|
||||
);
|
||||
|
||||
const [ipv4, ipv6] = await Promise.all([this.getTrace('4'), this.getTrace('6')]);
|
||||
if (!hasDualStackWarpTrace(ipv4, ipv6)) continue;
|
||||
|
||||
this.persistWarpEndpoint(endpoint);
|
||||
this.appendOperationLog(`Using WARP endpoint ${endpoint}`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('No WARP endpoint provided working IPv4 and IPv6 traces');
|
||||
}
|
||||
|
||||
private async installIfMissingOrSingleStack(): Promise<void> {
|
||||
if (this.hasWarpConfig() && this.hasDualStackConfig()) {
|
||||
this.appendOperationLog('Existing WARP profile is already dual-stack');
|
||||
|
|
@ -446,7 +486,24 @@ export class WarpService {
|
|||
if (config === null && !this.hasWarpConfig()) return false;
|
||||
|
||||
const warpConfig = config ?? readFileSync(WARP_CONFIG_PATH, 'utf8');
|
||||
return warpConfig.includes(`Endpoint = ${WARP_ENDPOINT}`);
|
||||
const configuredEndpoint = this.getConfiguredWarpEndpoint(warpConfig);
|
||||
return getWarpEndpointCandidates(configuredEndpoint)[0] === configuredEndpoint;
|
||||
}
|
||||
|
||||
private getConfiguredWarpEndpoint(config: string | null = null): string | null {
|
||||
if (config === null && !this.hasWarpConfig()) return null;
|
||||
|
||||
const warpConfig = config ?? readFileSync(WARP_CONFIG_PATH, 'utf8');
|
||||
return warpConfig.match(/^Endpoint = (.+)$/m)?.[1].trim() ?? null;
|
||||
}
|
||||
|
||||
private persistWarpEndpoint(endpoint: string): void {
|
||||
const original = readFileSync(WARP_CONFIG_PATH, 'utf8');
|
||||
const updated = original.replace(/^Endpoint = .*$/m, `Endpoint = ${endpoint}`);
|
||||
|
||||
if (updated !== original) {
|
||||
writeFileSync(WARP_CONFIG_PATH, updated, { mode: 0o600 });
|
||||
}
|
||||
}
|
||||
|
||||
private hasDeprecatedIpv6EndpointRouteConfig(config: string | null = null): boolean {
|
||||
|
|
|
|||
93
test/warp-endpoint-policy.test.cjs
Normal file
93
test/warp-endpoint-policy.test.cjs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
const assert = require('node:assert/strict');
|
||||
const { describe, it } = require('node:test');
|
||||
|
||||
let endpointPolicy;
|
||||
|
||||
try {
|
||||
endpointPolicy = require('../src/modules/warp/warp-endpoint-policy.ts');
|
||||
} catch (error) {
|
||||
assert.fail(`WARP endpoint policy is not implemented: ${error.message}`);
|
||||
}
|
||||
|
||||
const { getWarpEndpointCandidates, hasDualStackWarpTrace } = endpointPolicy;
|
||||
const { WarpService } = require('../src/modules/warp/warp.service.ts');
|
||||
|
||||
const createServiceHarness = (tracesByEndpoint) => {
|
||||
const service = new WarpService();
|
||||
const attemptedEndpoints = [];
|
||||
const persistedEndpoints = [];
|
||||
let activeEndpoint = null;
|
||||
|
||||
service.getConfiguredWarpEndpoint = () => '162.159.192.1:2408';
|
||||
service.appendOperationLog = () => {};
|
||||
service.execFixed = async (_command, args) => {
|
||||
if (args[0] === 'show') {
|
||||
return { stdout: 'public-peer-key\n', stderr: '' };
|
||||
}
|
||||
|
||||
activeEndpoint = args.at(-1);
|
||||
attemptedEndpoints.push(activeEndpoint);
|
||||
return { stdout: '', stderr: '' };
|
||||
};
|
||||
service.getTrace = async (ipVersion) => tracesByEndpoint[activeEndpoint]?.[ipVersion] ?? null;
|
||||
service.persistWarpEndpoint = (endpoint) => persistedEndpoints.push(endpoint);
|
||||
|
||||
return { attemptedEndpoints, persistedEndpoints, service };
|
||||
};
|
||||
|
||||
describe('WARP endpoint policy', () => {
|
||||
it('keeps a supported configured endpoint first and falls back to alternate UDP ports', () => {
|
||||
assert.deepEqual(getWarpEndpointCandidates('162.159.192.1:500'), [
|
||||
'162.159.192.1:500',
|
||||
'162.159.192.1:2408',
|
||||
'162.159.192.1:1701',
|
||||
'162.159.192.1:4500',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the default candidate order for an unsupported endpoint', () => {
|
||||
assert.deepEqual(getWarpEndpointCandidates('engage.cloudflareclient.com:2408'), [
|
||||
'162.159.192.1:2408',
|
||||
'162.159.192.1:500',
|
||||
'162.159.192.1:1701',
|
||||
'162.159.192.1:4500',
|
||||
]);
|
||||
});
|
||||
|
||||
it('accepts an endpoint only when both WARP traces are on', () => {
|
||||
assert.equal(hasDualStackWarpTrace({ warp: 'on' }, { warp: 'on' }), true);
|
||||
assert.equal(hasDualStackWarpTrace({ warp: 'on' }, null), false);
|
||||
assert.equal(hasDualStackWarpTrace({ warp: 'on' }, { warp: 'off' }), false);
|
||||
});
|
||||
|
||||
it('retries a failed default endpoint and persists the first dual-stack endpoint', async () => {
|
||||
const harness = createServiceHarness({
|
||||
'162.159.192.1:2408': { 4: { warp: 'on' }, 6: null },
|
||||
'162.159.192.1:500': { 4: { warp: 'on' }, 6: { warp: 'on' } },
|
||||
});
|
||||
|
||||
await harness.service.ensureReachableWarpEndpoint();
|
||||
|
||||
assert.deepEqual(harness.attemptedEndpoints, ['162.159.192.1:2408', '162.159.192.1:500']);
|
||||
assert.deepEqual(harness.persistedEndpoints, ['162.159.192.1:500']);
|
||||
});
|
||||
|
||||
it('does not persist an endpoint when every candidate fails dual-stack verification', async () => {
|
||||
const harness = createServiceHarness(
|
||||
Object.fromEntries(
|
||||
getWarpEndpointCandidates(null).map((endpoint) => [
|
||||
endpoint,
|
||||
{ 4: { warp: 'on' }, 6: { warp: 'off' } },
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
harness.service.ensureReachableWarpEndpoint(),
|
||||
/No WARP endpoint provided working IPv4 and IPv6 traces/,
|
||||
);
|
||||
|
||||
assert.deepEqual(harness.attemptedEndpoints, getWarpEndpointCandidates(null));
|
||||
assert.deepEqual(harness.persistedEndpoints, []);
|
||||
});
|
||||
});
|
||||
|
|
@ -70,9 +70,6 @@ describe('WARP contract shape', () => {
|
|||
assert.match(service, /wgcf generate/);
|
||||
assert.doesNotMatch(service, /s#\^\(Address = \[\^,\]\+\),\.\*#\\1#/);
|
||||
assert.match(service, /WGCF_RELEASES_API_URL/);
|
||||
assert.match(service, /WARP_ENDPOINT = '162\.159\.192\.1:2408'/);
|
||||
assert.match(service, /Endpoint = \$\{WARP_ENDPOINT\}/);
|
||||
assert.doesNotMatch(service, /WARP_ENDPOINT = 'engage\.cloudflareclient\.com:2408'/);
|
||||
assert.doesNotMatch(service, /WARP_IPV6_ENDPOINT_ROUTE_POST_UP/);
|
||||
assert.match(service, /getTrace\('4'\)/);
|
||||
assert.match(service, /getTrace\('6'\)/);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue