Skip to content

Commit 8f8f148

Browse files
committed
Feature: healthcheck and ip announce
1 parent a51d917 commit 8f8f148

File tree

3 files changed

+250
-0
lines changed

3 files changed

+250
-0
lines changed

src/common/EnvConfig.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,22 @@ interface EnvConfig {
2222
ROUTING_TARGET_PORT: string;
2323
/** [requester] provider connexion string <url>,<userid>,<secret> */
2424
PROVIDER: string;
25+
26+
/** [requester] Backend URL for route registration (v2 API) */
27+
BACKEND_URL: string;
28+
/** [requester] Route priority for tunnel routes (default: 2, lower than direct) */
29+
ROUTE_PRIORITY: number;
30+
/** [requester] Route refresh interval in seconds (default: 300 = 5 minutes) */
31+
ROUTE_REFRESH_INTERVAL: number;
32+
/** [requester] Optional health check HTTP path */
33+
HEALTH_CHECK_PATH: string;
34+
/** [requester] Optional health check Host header override */
35+
HEALTH_CHECK_HOST: string;
36+
}
37+
38+
export interface HealthCheckConfig {
39+
path: string;
40+
host?: string;
2541
}
2642

2743
/**
@@ -36,4 +52,24 @@ export const config: EnvConfig = {
3652
ROUTING_TARGET_HOST: process.env.ROUTING_TARGET_HOST || "caddy",
3753
ROUTING_TARGET_PORT: process.env.ROUTING_TARGET_PORT || "80",
3854
PROVIDER: process.env.PROVIDER!,
55+
// Route registration config (v2 API)
56+
BACKEND_URL: process.env.BACKEND_URL || '',
57+
ROUTE_PRIORITY: parseInt(process.env.ROUTE_PRIORITY || '2', 10),
58+
ROUTE_REFRESH_INTERVAL: parseInt(process.env.ROUTE_REFRESH_INTERVAL || '300', 10),
59+
HEALTH_CHECK_PATH: process.env.HEALTH_CHECK_PATH || '',
60+
HEALTH_CHECK_HOST: process.env.HEALTH_CHECK_HOST || '',
3961
};
62+
63+
/**
64+
* Build health check config from environment if configured
65+
*/
66+
export function getHealthCheckConfig(): HealthCheckConfig | undefined {
67+
if (!config.HEALTH_CHECK_PATH) {
68+
return undefined;
69+
}
70+
71+
return {
72+
path: config.HEALTH_CHECK_PATH,
73+
...(config.HEALTH_CHECK_HOST && { host: config.HEALTH_CHECK_HOST }),
74+
};
75+
}

src/requester/Requester.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {HandshakesWatcher} from './HandshakesWatcher.js';
88
import {getConfigPath} from "./WireGuard.js";
99
import {ProviderTools, registerProvider, waitForProvider} from "./ProviderTools.js";
1010
import {getOrGenerateKeyPair} from "./KeyPair.js";
11+
import {registerTunnelRoute, startRouteRefreshLoop, stopRouteRefreshLoop} from "./RouteRegistrar.js";
1112

1213
const exec = promisify(execCallback);
1314

@@ -74,6 +75,10 @@ async function stopRequester(providerString: string) {
7475
try {
7576
const [providerURL] = providerString.split(',');
7677
console.log(`Stopping requester for ${providerURL}`);
78+
79+
// Stop route refresh loop
80+
stopRouteRefreshLoop(providerString);
81+
7782
const configPath = await getConfigPath(providerURL);
7883

7984
// Bring down the interface if it exists
@@ -139,6 +144,21 @@ async function startRequester(provider:ProviderTools) {
139144
console.error(result);
140145
console.error(`Error executing ping: ${error.message}`);
141146
}
147+
148+
// Register tunnel route with mesh-router-backend (v2 API)
149+
// This allows the gateway to route traffic through this tunnel
150+
const routeResult = await registerTunnelRoute(provider.provider, 443);
151+
if (routeResult.success) {
152+
console.log(`Tunnel route registered successfully`);
153+
if (routeResult.domain) {
154+
console.log(` Domain: ${routeResult.domain}`);
155+
}
156+
// Start route refresh loop to keep the route alive
157+
startRouteRefreshLoop(provider.provider, 443);
158+
} else if (routeResult.error) {
159+
console.warn(`Tunnel route registration failed: ${routeResult.error}`);
160+
// Don't exit - tunnel still works, just no route failover support
161+
}
142162
} catch (err) {
143163
console.error(err);
144164
process.exit(51);

src/requester/RouteRegistrar.ts

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { exec as execCallback } from 'child_process';
2+
import { promisify } from 'util';
3+
import { config, getHealthCheckConfig, HealthCheckConfig } from '../common/EnvConfig.js';
4+
5+
const exec = promisify(execCallback);
6+
7+
export interface Route {
8+
ip: string;
9+
port: number;
10+
priority: number;
11+
healthCheck?: HealthCheckConfig;
12+
}
13+
14+
export interface RouteRegistrationResult {
15+
success: boolean;
16+
message: string;
17+
routes?: Route[];
18+
domain?: string;
19+
error?: string;
20+
}
21+
22+
interface ProviderInfo {
23+
providerUrl: string;
24+
userId: string;
25+
signature: string;
26+
}
27+
28+
// Store active route refresh intervals
29+
const refreshIntervals = new Map<string, NodeJS.Timeout>();
30+
31+
/**
32+
* Parse provider string into components
33+
*/
34+
function parseProviderString(providerString: string): ProviderInfo {
35+
const [providerUrl, userId = '', signature = ''] = providerString.split(',');
36+
return { providerUrl, userId, signature };
37+
}
38+
39+
/**
40+
* Extract the provider's public IP from the provider URL
41+
* The provider URL format is typically: https://provider.domain.com:port
42+
* We need to resolve this to get the actual IP, or use the VPN_ENDPOINT_ANNOUNCE if available
43+
*/
44+
async function getProviderPublicIp(providerUrl: string): Promise<string> {
45+
// Extract hostname from URL
46+
const url = new URL(providerUrl);
47+
const hostname = url.hostname;
48+
49+
// If it's already an IP, return it
50+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(hostname)) {
51+
return hostname;
52+
}
53+
54+
// Try to resolve the hostname to an IP
55+
try {
56+
const { stdout } = await exec(`getent hosts ${hostname} | awk '{ print $1 }' | head -1`);
57+
const ip = stdout.trim();
58+
if (ip) {
59+
return ip;
60+
}
61+
} catch {
62+
// Fall through to use hostname
63+
}
64+
65+
// Return hostname if we can't resolve (gateway will resolve it)
66+
return hostname;
67+
}
68+
69+
/**
70+
* Register tunnel route with mesh-router-backend
71+
* POST /router/api/routes/:userid/:sig { routes: Route[] }
72+
*/
73+
export async function registerTunnelRoute(
74+
providerString: string,
75+
tunnelPort: number = 443
76+
): Promise<RouteRegistrationResult> {
77+
const backendUrl = config.BACKEND_URL;
78+
79+
if (!backendUrl) {
80+
console.log('[RouteRegistrar] BACKEND_URL not configured, skipping route registration');
81+
return {
82+
success: true,
83+
message: 'Route registration skipped (no BACKEND_URL)',
84+
};
85+
}
86+
87+
const { providerUrl, userId, signature } = parseProviderString(providerString);
88+
89+
if (!userId || !signature) {
90+
return {
91+
success: false,
92+
message: 'Route registration failed',
93+
error: 'Missing userId or signature in provider string',
94+
};
95+
}
96+
97+
try {
98+
// Get the provider's public IP (this is where the gateway will route traffic)
99+
const providerIp = await getProviderPublicIp(providerUrl);
100+
const healthCheck = getHealthCheckConfig();
101+
102+
const route: Route = {
103+
ip: providerIp,
104+
port: tunnelPort,
105+
priority: config.ROUTE_PRIORITY,
106+
};
107+
108+
if (healthCheck) {
109+
route.healthCheck = healthCheck;
110+
}
111+
112+
const url = `${backendUrl}/router/api/routes/${encodeURIComponent(userId)}/${encodeURIComponent(signature)}`;
113+
const jsonData = JSON.stringify({ routes: [route] }).replace(/"/g, '\\"');
114+
const curlCommand = `curl -s -X POST -H "Content-Type: application/json" -d "${jsonData}" "${url}"`;
115+
116+
const { stdout } = await exec(curlCommand);
117+
const response = JSON.parse(stdout);
118+
119+
if (response.error) {
120+
return {
121+
success: false,
122+
message: 'Route registration failed',
123+
error: response.error,
124+
};
125+
}
126+
127+
console.log(`[RouteRegistrar] Route registered: ${providerIp}:${tunnelPort} (priority: ${config.ROUTE_PRIORITY})`);
128+
129+
return {
130+
success: true,
131+
message: response.message || 'Route registered successfully',
132+
routes: response.routes,
133+
domain: response.domain,
134+
};
135+
} catch (error) {
136+
return {
137+
success: false,
138+
message: 'Route registration request failed',
139+
error: error instanceof Error ? error.message : String(error),
140+
};
141+
}
142+
}
143+
144+
/**
145+
* Start the route refresh loop for a provider
146+
*/
147+
export function startRouteRefreshLoop(providerString: string, tunnelPort: number = 443): void {
148+
// Stop any existing refresh loop for this provider
149+
stopRouteRefreshLoop(providerString);
150+
151+
if (!config.BACKEND_URL) {
152+
console.log('[RouteRegistrar] BACKEND_URL not configured, not starting refresh loop');
153+
return;
154+
}
155+
156+
const refreshInterval = config.ROUTE_REFRESH_INTERVAL * 1000;
157+
console.log(`[RouteRegistrar] Starting route refresh loop (interval: ${config.ROUTE_REFRESH_INTERVAL}s)`);
158+
159+
const interval = setInterval(async () => {
160+
try {
161+
const result = await registerTunnelRoute(providerString, tunnelPort);
162+
if (!result.success) {
163+
console.error(`[RouteRegistrar] Route refresh failed: ${result.error}`);
164+
}
165+
} catch (error) {
166+
console.error('[RouteRegistrar] Route refresh error:', error);
167+
}
168+
}, refreshInterval);
169+
170+
refreshIntervals.set(providerString, interval);
171+
}
172+
173+
/**
174+
* Stop the route refresh loop for a provider
175+
*/
176+
export function stopRouteRefreshLoop(providerString: string): void {
177+
const interval = refreshIntervals.get(providerString);
178+
if (interval) {
179+
clearInterval(interval);
180+
refreshIntervals.delete(providerString);
181+
console.log('[RouteRegistrar] Stopped route refresh loop');
182+
}
183+
}
184+
185+
/**
186+
* Stop all route refresh loops
187+
*/
188+
export function stopAllRouteRefreshLoops(): void {
189+
for (const [providerString, interval] of refreshIntervals) {
190+
clearInterval(interval);
191+
console.log(`[RouteRegistrar] Stopped route refresh loop for ${providerString.split(',')[0]}`);
192+
}
193+
refreshIntervals.clear();
194+
}

0 commit comments

Comments
 (0)