Skip to content

Commit 27437c3

Browse files
feat: report mapping change
1 parent d745376 commit 27437c3

File tree

5 files changed

+219
-231
lines changed

5 files changed

+219
-231
lines changed

.vscode/settings.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
"[typescript]": {
33
"editor.tabSize": 2
44
},
5-
"cSpell.words": ["natmap"]
5+
"cSpell.words": ["natmap"],
6+
"typescript.tsdk": "node_modules\\typescript\\lib"
67
}

src/natmap.ts

+161-172
Original file line numberDiff line numberDiff line change
@@ -1,193 +1,182 @@
1-
import { ChildProcess, spawn } from "node:child_process";
1+
import { spawn } from "node:child_process";
2+
import { EventEmitter } from "node:events";
23
import { publicIp } from "./ip.js";
34
import { config } from "./config.js";
45
import { logger } from "./logger.js";
56
import { Protocol } from "./constants.js";
67

78
const l = logger.child({}, { msgPrefix: "[natmap] " });
89

9-
const holder: Mapping[] = [];
10-
11-
interface Mapping {
12-
sourceAddr: string;
13-
sourcePort: number;
14-
bindPort: number;
15-
publicAddr: string;
16-
publicPort: number;
17-
protocol: Protocol;
18-
lifetime: number;
19-
service: ChildProcess;
20-
ready: Promise<void>;
21-
timeout?: NodeJS.Timer;
10+
interface MappingKey {
11+
readonly sourceAddr: string;
12+
readonly sourcePort: number;
13+
readonly protocol: Protocol;
2214
}
2315

24-
function stringify({
25-
protocol,
26-
sourceAddr,
27-
sourcePort,
28-
publicAddr,
29-
publicPort,
30-
bindPort,
31-
}: Mapping) {
32-
const p = Protocol[protocol];
33-
return `[${p}]${sourceAddr}:${sourcePort} => ${bindPort} => ${
34-
publicAddr || "?"
35-
}:${publicPort > 0 ? publicPort : "?"}`;
36-
}
16+
export class Mapping extends EventEmitter implements MappingKey {
17+
private static readonly currentPort: Record<string, number | undefined> = {
18+
TCP: -1,
19+
UDP: -1,
20+
};
21+
private static getPort(protocol: Protocol) {
22+
let current = Mapping.currentPort[Protocol[protocol]];
23+
if (current === undefined) throw new Error(`Invalid protocol ${protocol}`);
3724

38-
const currentPort: Record<string, number | undefined> = { TCP: -1, UDP: -1 };
39-
function getPort(protocol: Protocol) {
40-
let current = currentPort[Protocol[protocol]];
41-
if (current === undefined) throw new Error(`Invalid protocol ${protocol}`);
25+
if (current < 0) {
26+
// start from random port
27+
current =
28+
config.bindPort[0] +
29+
Math.floor(Math.random() * (config.bindPort[1] - config.bindPort[0]));
30+
}
4231

43-
if (current < 0) {
44-
// start from random port
45-
current =
46-
config.bindPort[0] +
47-
Math.floor(Math.random() * (config.bindPort[1] - config.bindPort[0]));
32+
if (current < config.bindPort[0] || current >= config.bindPort[1]) {
33+
// cycle
34+
current = config.bindPort[0];
35+
} else {
36+
// next
37+
current++;
38+
}
39+
Mapping.currentPort[Protocol[protocol]] = current;
40+
return current;
4841
}
4942

50-
if (current < config.bindPort[0] || current >= config.bindPort[1]) {
51-
// cycle
52-
current = config.bindPort[0];
53-
} else {
54-
// next
55-
current++;
43+
private static readonly mappings = new Map<
44+
`${Protocol}/${string}/${number}`,
45+
Mapping
46+
>();
47+
48+
private static keyOf(key: MappingKey) {
49+
return `${key.protocol}/${key.sourceAddr}/${key.sourcePort}` as const;
5650
}
57-
currentPort[Protocol[protocol]] = current;
58-
return current;
59-
}
6051

61-
function createTimeout(info: Mapping, lifetime: number) {
62-
clearTimeout(info.timeout);
63-
info.timeout = setTimeout(() => {
64-
if (stop(info.sourceAddr, info.sourcePort, info.protocol)) {
65-
l.info(`${stringify(info)}: Mapping removed due to timeout`);
66-
}
67-
}, lifetime * 1000);
68-
}
52+
static find(key: MappingKey): Mapping | undefined {
53+
return Mapping.mappings.get(Mapping.keyOf(key));
54+
}
6955

70-
export function start(
71-
sourceAddr: string,
72-
sourcePort: number,
73-
protocol: Protocol,
74-
lifetime: number
75-
): Mapping {
76-
const exist = findIndex(sourceAddr, sourcePort, protocol);
77-
if (exist >= 0) {
78-
const info = holder[exist];
79-
createTimeout(info, lifetime);
80-
l.info(
81-
`${stringify(info)}: Mapping lifetime updated to ${lifetime} seconds`
82-
);
83-
return info;
56+
static start(key: MappingKey, lifetime: number): Mapping {
57+
const mapping =
58+
Mapping.find(key) ??
59+
new Mapping(key.sourceAddr, key.sourcePort, key.protocol);
60+
mapping.setTimeout(lifetime);
61+
return mapping;
8462
}
8563

86-
const bindPort = getPort(protocol);
87-
const commonArgs = `-s ${config.stunServer} -h ${config.holdServer} -b ${bindPort} -t ${sourceAddr} -p ${sourcePort}`;
88-
const protocolArgs =
89-
protocol === Protocol.UDP ? config.udpArgs : config.tcpArgs;
90-
const command = `${config.exec} ${commonArgs} ${protocolArgs}`;
91-
92-
l.debug(`> ${command}`);
93-
94-
const service = spawn(command, {
95-
shell: true,
96-
stdio: ["ignore", "pipe", "inherit"],
97-
});
98-
const info: Mapping = {
99-
service,
100-
sourceAddr,
101-
sourcePort,
102-
bindPort,
103-
lifetime,
104-
protocol,
105-
publicAddr: "",
106-
publicPort: -1,
107-
ready: new Promise<void>((resolve, reject) => {
108-
service.stdout.on("data", (ch: Buffer) => {
109-
const [publicAddr, publicPortStr, ip4p, bindPort, protocol] =
110-
String(ch).split(" ");
111-
if (protocol.trim().toUpperCase() !== Protocol[info.protocol]) {
112-
l.warn(
113-
`${stringify(info)}: Protocol mismatch: get ${protocol} from ${
114-
config.exec
115-
}`
116-
);
117-
return;
118-
}
119-
const fields = publicAddr.split(".").map((s) => Number.parseInt(s));
120-
if (fields.length !== 4) {
121-
l.warn(
122-
`${stringify(info)}: Invalid public ip: got ${publicAddr} from ${
123-
config.exec
124-
}`
125-
);
126-
return;
127-
}
128-
const publicPort = Number.parseInt(publicPortStr);
129-
if (Number.isNaN(publicPort)) {
130-
l.warn(
131-
`${stringify(
132-
info
133-
)}: Invalid public port: got ${publicPortStr} from ${config.exec}`
134-
);
135-
return;
136-
}
137-
if (Number(bindPort) !== info.bindPort) {
138-
l.warn(
139-
`${stringify(info)}: Bind port mismatch: got ${bindPort} from ${
140-
config.exec
141-
}`
142-
);
143-
return;
144-
}
145-
info.publicAddr = publicAddr;
146-
info.publicPort = publicPort;
147-
publicIp.ip = fields;
148-
l.info(`${stringify(info)}: Mapping updated`);
149-
resolve();
150-
});
151-
service.on("exit", (code, signal) => {
152-
l.debug(`Process exited with code ${code}, signal ${signal}`);
153-
reject(new Error(`Process exited`));
154-
});
155-
}),
156-
};
157-
holder.push(info);
158-
createTimeout(info, lifetime);
159-
l.info(`${stringify(info)}: Mapping created`);
160-
return info;
161-
}
64+
static stop(key: MappingKey, reason: string): Mapping | undefined {
65+
const mapping = Mapping.find(key);
66+
if (!mapping) return undefined;
67+
mapping.stop(reason);
68+
return mapping;
69+
}
16270

163-
export function stop(
164-
sourceAddr: string,
165-
sourcePort: number,
166-
protocol: Protocol
167-
): Mapping | undefined {
168-
const infoIndex = findIndex(sourceAddr, sourcePort, protocol);
169-
if (infoIndex < 0) return undefined;
170-
const info = holder[infoIndex];
171-
info.service.kill("SIGINT");
172-
clearTimeout(info.timeout);
173-
info.timeout = undefined;
174-
holder.splice(infoIndex, 1);
175-
l.info(
176-
`Mapping removed [${Protocol[protocol]}]${sourceAddr}:${sourcePort} => ${info.bindPort} => ${info.publicPort}`
177-
);
178-
return info;
179-
}
71+
constructor(
72+
readonly sourceAddr: string,
73+
readonly sourcePort: number,
74+
readonly protocol: Protocol
75+
) {
76+
super();
77+
this.bindPort = Mapping.getPort(protocol);
78+
Mapping.mappings.set(Mapping.keyOf(this), this);
18079

181-
function findIndex(
182-
sourceAddr: string,
183-
sourcePort: number,
184-
protocol: Protocol
185-
): number {
186-
return holder.findIndex(
187-
(i) =>
188-
i &&
189-
i.sourceAddr === sourceAddr &&
190-
i.sourcePort === sourcePort &&
191-
i.protocol === protocol
192-
);
80+
const commonArgs = `-s ${config.stunServer} -h ${config.holdServer} -b ${this.bindPort} -t ${sourceAddr} -p ${sourcePort}`;
81+
const protocolArgs =
82+
protocol === Protocol.UDP ? config.udpArgs : config.tcpArgs;
83+
const command = `${config.exec} ${commonArgs} ${protocolArgs}`;
84+
85+
l.debug(`${this}> ${command}`);
86+
87+
this.service = spawn(command, {
88+
shell: true,
89+
stdio: ["ignore", "pipe", "pipe"],
90+
});
91+
this.service.on("exit", (code, signal) => {
92+
l.debug(`${this}: Process exited with code ${code}, signal ${signal}`);
93+
this.emit("exit", code, signal);
94+
});
95+
this.service.stdout.on("data", (ch: Buffer) => {
96+
const str = String(ch);
97+
l.debug(`${this}< ${str}`);
98+
const [publicAddr, publicPortStr, ip4p, bindPort, protocol] =
99+
str.split(" ");
100+
if (protocol.trim().toUpperCase() !== Protocol[this.protocol]) {
101+
l.warn(
102+
`${this}: Protocol mismatch: got ${protocol} from ${config.exec}`
103+
);
104+
return;
105+
}
106+
const fields = publicAddr.split(".").map((s) => Number.parseInt(s));
107+
if (fields.length !== 4) {
108+
l.warn(
109+
`${this}: Invalid public ip: got ${publicAddr} from ${config.exec}`
110+
);
111+
return;
112+
}
113+
const publicPort = Number.parseInt(publicPortStr);
114+
if (Number.isNaN(publicPort)) {
115+
l.warn(
116+
`${this}: Invalid public port: got ${publicPortStr} from ${config.exec}`
117+
);
118+
return;
119+
}
120+
if (Number(bindPort) !== this.bindPort) {
121+
l.warn(
122+
`${this}: Bind port mismatch: got ${bindPort} from ${config.exec}`
123+
);
124+
return;
125+
}
126+
this.publicAddr = publicAddr;
127+
this.publicPort = publicPort;
128+
publicIp.ip = fields;
129+
l.info(`${this}: Mapping updated`);
130+
this.emit("change", publicAddr, publicPort);
131+
});
132+
this.service.stderr.on("data", (ch: Buffer) => {
133+
l.warn(`${this}! ${ch}`);
134+
});
135+
l.info(`${this}: Mapping created`);
136+
}
137+
readonly bindPort;
138+
private readonly service;
139+
publicAddr?: string;
140+
publicPort?: number;
141+
private timeout?: NodeJS.Timer;
142+
private timeoutTime?: number;
143+
/** lifetime in seconds */
144+
get lifetime(): number {
145+
if (!this.timeoutTime) return 0;
146+
return Math.floor((this.timeoutTime - Date.now()) / 1000);
147+
}
148+
149+
setTimeout(lifetime: number) {
150+
if (this.timeout) {
151+
clearTimeout(this.timeout);
152+
this.timeout = undefined;
153+
this.timeoutTime = undefined;
154+
}
155+
if (lifetime <= 0) return;
156+
157+
this.timeout = setTimeout(() => {
158+
this.stop("timeout");
159+
}, lifetime * 1000);
160+
l.info(`${this}: Mapping lifetime updated to ${lifetime} seconds`);
161+
this.timeoutTime = Date.now() + lifetime * 1000;
162+
this.emit("timeout", lifetime);
163+
}
164+
165+
stop(reason: string) {
166+
if (this.service.killed) return;
167+
this.service.kill("SIGINT");
168+
this.setTimeout(0);
169+
l.info(`${this}: Mapping removed: ${reason}`);
170+
this.emit("stop", reason);
171+
Mapping.mappings.delete(Mapping.keyOf(this));
172+
this.removeAllListeners();
173+
}
174+
175+
override toString() {
176+
const p = Protocol[this.protocol];
177+
const source = `${this.sourceAddr}:${this.sourcePort}`;
178+
const bind = `${this.bindPort}`;
179+
const pub = `${this.publicAddr || "?"}:${this.publicPort || "?"}`;
180+
return `[${p} ${source} => ${bind} => ${pub}]`;
181+
}
193182
}

src/server/index.ts

+7-4
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,16 @@ const l = logger.child({}, { msgPrefix: "[server] " });
1515
export class Server {
1616
constructor() {}
1717
readonly socket = createSocket("udp4", this.onMessage.bind(this));
18-
19-
startTime = 0;
18+
/** Seconds since start time */
19+
get epochTime(): number {
20+
return Math.floor((Date.now() - this.startTime) / 1000);
21+
}
22+
private startTime = 0;
2023
listen(): Promise<void> {
2124
if (this.startTime) {
2225
return Promise.resolve();
2326
}
27+
this.startTime = Date.now();
2428
return new Promise<void>((res, rej) => {
2529
this.socket.on("error", rej);
2630
this.socket.bind(config.port, config.host, () => {
@@ -39,10 +43,9 @@ export class Server {
3943

4044
// https://datatracker.ietf.org/doc/html/rfc6886#section-3.2.1
4145
protected async announceAddressChanges(): Promise<void> {
42-
this.startTime = Date.now();
4346
let delay = 250;
4447
for (let index = 0; index < 10; index++) {
45-
const buf = allocPublicAddressResponse(this.startTime, publicIp.ip);
48+
const buf = allocPublicAddressResponse(this.epochTime, publicIp.ip);
4649
l.trace(
4750
`Announcing address changes [${String(index + 1).padStart(
4851
2

0 commit comments

Comments
 (0)