diff --git a/.changeset/blue-emus-doubt.md b/.changeset/blue-emus-doubt.md new file mode 100644 index 0000000..c862633 --- /dev/null +++ b/.changeset/blue-emus-doubt.md @@ -0,0 +1,5 @@ +--- +"@9c5s/node-tcnet": patch +--- + +fix: Windows BridgeのXTEA暗号文バイトリバースに対応 diff --git a/src/tcnet.ts b/src/tcnet.ts index d5b40a5..790bd1d 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -1,5 +1,7 @@ import { Socket, createSocket, RemoteInfo } from "dgram"; import { EventEmitter } from "events"; +import { execFile } from "child_process"; +import { platform } from "os"; import * as nw from "./network"; import { MultiPacketAssembler } from "./multi-packet"; import { interfaceAddress, listNetworkAdapters, findIPv4Address, type NetworkAdapterInfo } from "./utils"; @@ -91,6 +93,7 @@ export class TCNetClient extends EventEmitter { private _authState: AuthState = "none"; private sessionToken: number | null = null; private authTimeoutId: NodeJS.Timeout | null = null; + private bridgeIsWindows: boolean | null = null; /** * TCNetClientを初期化する @@ -470,6 +473,9 @@ export class TCNetClient extends EventEmitter { } } else if (this.connected) { // 確定後: 従来通りserver更新 + if (this.server?.address !== rinfo.address) { + this.bridgeIsWindows = null; + } this.server = rinfo; this.server.port = packet.nodeListenerPort; } @@ -797,6 +803,67 @@ export class TCNetClient extends EventEmitter { return null; } + /** + * BridgeのOSがWindowsであるかをpingのTTL値から検出する + * + * Bridge IPが自分のIPと一致する場合はos.platform()で判定する。 + * リモートの場合はpingを1回実行しTTL値をパースする (TTL > 64 → Windows)。 + * WindowsのデフォルトTTLは128、macOS/LinuxのデフォルトTTLは64であるため、 + * 同一LAN (0-1ホップ) ではこの閾値で正確に判定できる。 + * 結果はインスタンス変数にキャッシュし、セッション中1回だけ検出する。 + * @returns Windowsならtrue、それ以外ならfalse + */ + private async detectBridgeIsWindows(): Promise { + if (this.bridgeIsWindows !== null) return this.bridgeIsWindows; + + const bridgeIp = this.server?.address; + if (!bridgeIp) { + // serverが未設定の場合はキャッシュせず、次回の呼び出しで再検出を許可する + return false; + } + + // IPv4フォーマットバリデーション (execFileに渡す前の防御) + // 不正IPは確定的でないためキャッシュしない + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(bridgeIp)) { + return false; + } + + const clientIp = this.getClientIp(); + if (clientIp && bridgeIp === clientIp) { + this.bridgeIsWindows = platform() === "win32"; + this.log?.debug(`Bridge OS detected (local): ${this.bridgeIsWindows ? "Windows" : "non-Windows"}`); + return this.bridgeIsWindows; + } + + try { + const isWin = platform() === "win32"; + const args = isWin ? ["-n", "1", "-w", "1000", bridgeIp] : ["-c", "1", "-W", "1", bridgeIp]; + const output = await new Promise((resolve, reject) => { + execFile("ping", args, { timeout: 3000 }, (err, stdout) => { + if (err) reject(err); + else resolve(stdout); + }); + }); + const match = output.match(/ttl[=:](\d+)/i); + if (match) { + const ttl = Number.parseInt(match[1], 10); + this.bridgeIsWindows = ttl > 64; + this.log?.debug(`Bridge OS detected (TTL=${ttl}): ${this.bridgeIsWindows ? "Windows" : "non-Windows"}`); + } else { + // TTL未検出は確定的でないためキャッシュせず、次回再検出を許可する + this.log?.debug("Bridge OS detection: TTL not found in ping output, assuming non-Windows"); + return false; + } + } catch (err) { + // ping失敗は確定的でないためキャッシュせず、次回再検出を許可する + const msg = err instanceof Error ? err.message : String(err); + this.log?.debug(`Bridge OS detection: ping failed (${msg}), assuming non-Windows`); + return false; + } + + return this.bridgeIsWindows; + } + /** * AppDataパケットを生成する * @param cmd - コマンド番号 @@ -831,6 +898,7 @@ export class TCNetClient extends EventEmitter { private resetAuthSession(): void { this._authState = "none"; this.sessionToken = null; + this.bridgeIsWindows = null; if (this.authTimeoutId) { clearTimeout(this.authTimeoutId); this.authTimeoutId = null; @@ -874,6 +942,19 @@ export class TCNetClient extends EventEmitter { } const ciphertext = Buffer.from(ct, "hex"); + const tokenBeforePing = this.sessionToken; + // Windows BridgeはXTEA暗号文をバイトリバースして読み取るため、事前にリバースして送信する + // 初回呼び出し時のみpingが発生し最大3秒かかる (AUTH_RESPONSE_TIMEOUT=5秒内に収まる想定) + if (await this.detectBridgeIsWindows()) { + ciphertext.reverse(); + } + + // detectBridgeIsWindowsのawait中に状態が変わった場合のガード + if (!this.server || !this.broadcastSocket || this.sessionToken !== tokenBeforePing) { + this.resetAuthSession(); + return; + } + const payload = generateAuthPayload(this.sessionToken, clientIp, ciphertext); const auth = this.createAppDataPacket(2, this.sessionToken, payload); await this.sendPacket(auth, this.broadcastSocket, TCNET_BROADCAST_PORT, this.config.broadcastAddress); diff --git a/tests/auth.test.ts b/tests/auth.test.ts index ad0721b..12056c2 100644 --- a/tests/auth.test.ts +++ b/tests/auth.test.ts @@ -460,6 +460,18 @@ class AuthSequenceTestClient extends TCNetClient { public setSelectedAdapter(adapter: any): void { (this as any)._selectedAdapter = adapter; } + public callDetectBridgeIsWindows(): Promise { + return (this as any).detectBridgeIsWindows(); + } + public getBridgeIsWindows(): boolean | null { + return (this as any).bridgeIsWindows; + } + public setBridgeIsWindows(value: boolean | null): void { + (this as any).bridgeIsWindows = value; + } + public setServer(server: any): void { + (this as any).server = server; + } } describe("sendAuthSequence 状態リセット", () => { @@ -703,3 +715,92 @@ describe("TCNetApplicationDataPacket.write() payload長検証", () => { expect(() => packet.write()).toThrow("ApplicationData payload must be 12 bytes"); }); }); + +describe("sendAuthSequence XTEA暗号文バイトリバース", () => { + /** + * テスト用アダプタ情報を生成する + * @param ip - IPv4アドレス文字列 + */ + function createAdapter(ip: string) { + return { + name: "test0", + addresses: [ + { + address: ip, + netmask: "255.255.255.0", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${ip}/24`, + }, + ], + }; + } + + it("Windows Bridge判定時に暗号文がバイトリバースされて送信される", async () => { + const client = new AuthSequenceTestClient("8ee0dc051b1ddf8b"); + client.setSessionToken(0xdeec6dfc); + client.setAuthState("pending"); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + const sentBuffers: Buffer[] = []; + client.setBroadcastSocket({ + send: vi.fn((buf: Buffer, _port: number, _addr: string, cb: (err: Error | null) => void) => { + sentBuffers.push(Buffer.from(buf)); + cb(null); + }), + }); + (client as any).config.broadcastAddress = "255.255.255.255"; + + // BridgeをWindowsとして事前設定する + client.setBridgeIsWindows(true); + + await (client as any).sendAuthSequence(); + + // 2番目のパケット (cmd=2) のpayloadを検査する + expect(sentBuffers.length).toBe(2); + const authBuf = sentBuffers[1]; + // payloadはoffset 50から12バイト + const payload = authBuf.subarray(50, 62); + // auth[4:12]がリバースされた暗号文であることを確認 + const reversedCiphertext = Buffer.from("8ee0dc051b1ddf8b", "hex").reverse(); + expect(payload.subarray(4, 12)).toEqual(reversedCiphertext); + }); + + it("non-Windows Bridge判定時に暗号文がそのまま送信される", async () => { + const client = new AuthSequenceTestClient("8ee0dc051b1ddf8b"); + client.setSessionToken(0xdeec6dfc); + client.setAuthState("pending"); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + const sentBuffers: Buffer[] = []; + client.setBroadcastSocket({ + send: vi.fn((buf: Buffer, _port: number, _addr: string, cb: (err: Error | null) => void) => { + sentBuffers.push(Buffer.from(buf)); + cb(null); + }), + }); + (client as any).config.broadcastAddress = "255.255.255.255"; + + // Bridgeをnon-Windowsとして事前設定する + client.setBridgeIsWindows(false); + + await (client as any).sendAuthSequence(); + + expect(sentBuffers.length).toBe(2); + const authBuf = sentBuffers[1]; + const payload = authBuf.subarray(50, 62); + const originalCiphertext = Buffer.from("8ee0dc051b1ddf8b", "hex"); + expect(payload.subarray(4, 12)).toEqual(originalCiphertext); + }); + + it("resetAuthSessionでbridgeIsWindowsキャッシュがクリアされる", () => { + const client = new AuthSequenceTestClient(); + client.setBridgeIsWindows(true); + client.setAuthState("pending"); + + (client as any).resetAuthSession(); + + expect(client.getBridgeIsWindows()).toBeNull(); + }); +}); diff --git a/tests/bridge-os-detection.test.ts b/tests/bridge-os-detection.test.ts new file mode 100644 index 0000000..7aeca97 --- /dev/null +++ b/tests/bridge-os-detection.test.ts @@ -0,0 +1,232 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from "vitest"; +import { isolateXteaEnv } from "./helpers"; + +// os.platformとchild_process.execFileをモックする +vi.mock("os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, platform: vi.fn(() => "win32") }; +}); + +vi.mock("child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, execFile: vi.fn() }; +}); + +import { platform } from "os"; +import { execFile } from "child_process"; +import { TCNetClient } from "../src/tcnet"; + +const platformMock = platform as unknown as Mock; +const execFileMock = execFile as unknown as Mock; + +isolateXteaEnv(); + +const MASTER_RINFO = { address: "192.168.0.100", port: 65207, family: "IPv4", size: 0 }; + +class BridgeOsTestClient extends TCNetClient { + constructor() { + super(); + (this as any).config.xteaCiphertext = "8ee0dc051b1ddf8b"; + (this as any).server = { address: MASTER_RINFO.address, port: MASTER_RINFO.port }; + } + public callDetectBridgeIsWindows(): Promise { + return (this as any).detectBridgeIsWindows(); + } + public getBridgeIsWindows(): boolean | null { + return (this as any).bridgeIsWindows; + } + public setBridgeIsWindows(value: boolean | null): void { + (this as any).bridgeIsWindows = value; + } + public setServer(server: any): void { + (this as any).server = server; + } + public setSelectedAdapter(adapter: any): void { + (this as any)._selectedAdapter = adapter; + } +} + +/** + * テスト用アダプタ情報を生成する + * @param ip - IPv4アドレス文字列 + */ +function createAdapter(ip: string) { + return { + name: "test0", + addresses: [ + { + address: ip, + netmask: "255.255.255.0", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: false, + cidr: `${ip}/24`, + }, + ], + }; +} + +/** + * execFileモックのコールバックを成功として呼び出すヘルパー + * @param stdout - 標準出力文字列 + */ +function mockExecFileSuccess(stdout: string): void { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: object, cb: (err: Error | null, stdout: string) => void) => { + cb(null, stdout); + }, + ); +} + +/** + * execFileモックのコールバックをエラーとして呼び出すヘルパー + * @param error - エラーオブジェクト + */ +function mockExecFileError(error: Error): void { + execFileMock.mockImplementation( + (_cmd: string, _args: string[], _opts: object, cb: (err: Error | null, stdout: string) => void) => { + cb(error, ""); + }, + ); +} + +describe("detectBridgeIsWindows", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("Bridge IPがクライアントIPと一致しos.platform()がwin32ならtrueを返す", async () => { + platformMock.mockReturnValue("win32"); + const client = new BridgeOsTestClient(); + client.setServer({ address: "192.168.0.10", port: 65207 }); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(true); + expect(client.getBridgeIsWindows()).toBe(true); + }); + + it("Bridge IPがクライアントIPと一致しos.platform()がdarwinならfalseを返す", async () => { + platformMock.mockReturnValue("darwin"); + const client = new BridgeOsTestClient(); + client.setServer({ address: "192.168.0.10", port: 65207 }); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(false); + expect(client.getBridgeIsWindows()).toBe(false); + }); + + it("リモートBridgeでTTL=128ならWindowsと判定する", async () => { + platformMock.mockReturnValue("win32"); + mockExecFileSuccess("Reply from 192.168.0.100: bytes=32 time<1ms TTL=128\n"); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(true); + }); + + it("リモートBridgeでTTL=64ならnon-Windowsと判定する", async () => { + platformMock.mockReturnValue("darwin"); + mockExecFileSuccess("64 bytes from 192.168.0.100: icmp_seq=1 ttl=64 time=0.5 ms\n"); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(false); + }); + + it("リモートBridgeでTTL=65ならWindowsと判定する (境界値)", async () => { + platformMock.mockReturnValue("win32"); + mockExecFileSuccess("Reply from 192.168.0.100: bytes=32 time<1ms TTL=65\n"); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(true); + }); + + it("ping失敗時はfalseを返しキャッシュしない", async () => { + platformMock.mockReturnValue("win32"); + mockExecFileError(new Error("ping failed")); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(false); + expect(client.getBridgeIsWindows()).toBeNull(); + }); + + it("ping出力にTTLが含まれない場合はfalseを返しキャッシュしない", async () => { + platformMock.mockReturnValue("win32"); + mockExecFileSuccess("Request timed out.\n"); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(false); + expect(client.getBridgeIsWindows()).toBeNull(); + }); + + it("結果がキャッシュされ2回目以降はpingを実行しない", async () => { + platformMock.mockReturnValue("win32"); + mockExecFileSuccess("Reply from 192.168.0.100: bytes=32 time<1ms TTL=128\n"); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + await client.callDetectBridgeIsWindows(); + await client.callDetectBridgeIsWindows(); + + expect(execFileMock).toHaveBeenCalledTimes(1); + }); + + it("serverがnullの場合はfalseを返しキャッシュしない", async () => { + const client = new BridgeOsTestClient(); + client.setServer(null); + + expect(await client.callDetectBridgeIsWindows()).toBe(false); + // キャッシュされていないことを確認 (nullのまま) + expect(client.getBridgeIsWindows()).toBeNull(); + + // server設定後に再検出が行われることを確認 + platformMock.mockReturnValue("win32"); + client.setServer({ address: "192.168.0.10", port: 65207 }); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + expect(await client.callDetectBridgeIsWindows()).toBe(true); + }); + + it("不正なIPアドレス形式の場合はfalseを返しキャッシュしない", async () => { + const client = new BridgeOsTestClient(); + client.setServer({ address: "invalid-ip", port: 65207 }); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + expect(await client.callDetectBridgeIsWindows()).toBe(false); + expect(client.getBridgeIsWindows()).toBeNull(); + }); + + it("Windowsではping -n 1 -w 1000引数を使用する", async () => { + platformMock.mockReturnValue("win32"); + mockExecFileSuccess("Reply from 192.168.0.100: bytes=32 time<1ms TTL=128\n"); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + await client.callDetectBridgeIsWindows(); + + expect(execFileMock).toHaveBeenCalledWith( + "ping", + ["-n", "1", "-w", "1000", "192.168.0.100"], + expect.objectContaining({ timeout: 3000 }), + expect.any(Function), + ); + }); + + it("macOS/Linuxではping -c 1 -W 1引数を使用する", async () => { + platformMock.mockReturnValue("darwin"); + mockExecFileSuccess("64 bytes from 192.168.0.100: icmp_seq=1 ttl=64 time=0.5 ms\n"); + const client = new BridgeOsTestClient(); + client.setSelectedAdapter(createAdapter("192.168.0.10")); + + await client.callDetectBridgeIsWindows(); + + expect(execFileMock).toHaveBeenCalledWith( + "ping", + ["-c", "1", "-W", "1", "192.168.0.100"], + expect.objectContaining({ timeout: 3000 }), + expect.any(Function), + ); + }); +});