From ef4c450091fff4002db8c7bc281be275add06e62 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Fri, 3 Apr 2026 20:33:07 +0900 Subject: [PATCH 1/4] =?UTF-8?q?fix:=20Windows=20Bridge=E3=81=AEXTEA?= =?UTF-8?q?=E6=9A=97=E5=8F=B7=E6=96=87=E3=83=90=E3=82=A4=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=83=90=E3=83=BC=E3=82=B9=E3=81=AB=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows BridgeはTCNASDP認証のXTEA暗号文(auth[4:12])をバイトリバースして 読み取る。pingのTTL値からBridge OSを自動検出し、Windows判定時に暗号文を 事前リバースして送信することで、Mac/Win両Bridgeに対応する。 - detectBridgeIsWindows: ローカルはos.platform()、リモートはping TTLで判定 - sendAuthSequence: Windows Bridge時にciphertext.reverse()を適用 - execFileベースの非同期実装でイベントループブロッキングを回避 - IPアドレスバリデーション追加でコマンドインジェクション防止 - server未設定時のキャッシュ回避で再検出を保証 --- src/tcnet.ts | 68 +++++++++ tests/auth.test.ts | 101 +++++++++++++ tests/bridge-os-detection.test.ts | 229 ++++++++++++++++++++++++++++++ 3 files changed, 398 insertions(+) create mode 100644 tests/bridge-os-detection.test.ts diff --git a/src/tcnet.ts b/src/tcnet.ts index d5b40a5..44f1bf7 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を初期化する @@ -797,6 +800,65 @@ 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に渡す前の防御) + if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(bridgeIp)) { + this.bridgeIsWindows = false; + 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 { + this.bridgeIsWindows = false; + this.log?.debug("Bridge OS detection: TTL not found in ping output, assuming non-Windows"); + } + } catch (err) { + this.bridgeIsWindows = false; + const msg = err instanceof Error ? err.message : String(err); + this.log?.debug(`Bridge OS detection: ping failed (${msg}), assuming non-Windows`); + } + + return this.bridgeIsWindows; + } + /** * AppDataパケットを生成する * @param cmd - コマンド番号 @@ -831,6 +893,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 +937,11 @@ export class TCNetClient extends EventEmitter { } const ciphertext = Buffer.from(ct, "hex"); + // Windows BridgeはXTEA暗号文をバイトリバースして読み取るため、事前にリバースして送信する + // 初回呼び出し時のみpingが発生し最大3秒かかる (AUTH_RESPONSE_TIMEOUT=5秒内に収まる想定) + if (await this.detectBridgeIsWindows()) { + ciphertext.reverse(); + } 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..1e35dd6 --- /dev/null +++ b/tests/bridge-os-detection.test.ts @@ -0,0 +1,229 @@ +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); + }); + + 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); + }); + + 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); + }); + + 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), + ); + }); +}); From c60273b0f1416407659f0804bf138832eec336dd Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Fri, 3 Apr 2026 23:41:16 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix:=20=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=E3=81=A8changeset=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ping失敗/TTL未検出時にbridgeIsWindowsをキャッシュしない (次回再検出を許可) - detectBridgeIsWindows() await後に状態再確認ガードを追加 - patch changeset追加 --- .changeset/blue-emus-doubt.md | 5 +++++ src/tcnet.ts | 13 +++++++++++-- tests/bridge-os-detection.test.ts | 6 ++++-- 3 files changed, 20 insertions(+), 4 deletions(-) create mode 100644 .changeset/blue-emus-doubt.md 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 44f1bf7..a356e9f 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -847,13 +847,15 @@ export class TCNetClient extends EventEmitter { this.bridgeIsWindows = ttl > 64; this.log?.debug(`Bridge OS detected (TTL=${ttl}): ${this.bridgeIsWindows ? "Windows" : "non-Windows"}`); } else { - this.bridgeIsWindows = false; + // TTL未検出は確定的でないためキャッシュせず、次回再検出を許可する this.log?.debug("Bridge OS detection: TTL not found in ping output, assuming non-Windows"); + return false; } } catch (err) { - this.bridgeIsWindows = false; + // 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; @@ -942,6 +944,13 @@ export class TCNetClient extends EventEmitter { if (await this.detectBridgeIsWindows()) { ciphertext.reverse(); } + + // detectBridgeIsWindowsのawait中に状態が変わった場合のガード + if (!this.server || !this.broadcastSocket || this.sessionToken === null) { + 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/bridge-os-detection.test.ts b/tests/bridge-os-detection.test.ts index 1e35dd6..67a9dba 100644 --- a/tests/bridge-os-detection.test.ts +++ b/tests/bridge-os-detection.test.ts @@ -142,22 +142,24 @@ describe("detectBridgeIsWindows", () => { expect(await client.callDetectBridgeIsWindows()).toBe(true); }); - it("ping失敗時はfalseにフォールバックする", async () => { + 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 () => { + 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 () => { From dc71413d983d5bf2c687975b738eec9fb54e7645 Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Sat, 4 Apr 2026 02:05:37 +0900 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20=E4=B8=8D=E6=AD=A3IP=E3=83=95?= =?UTF-8?q?=E3=82=A9=E3=83=BC=E3=83=9E=E3=83=83=E3=83=88=E6=99=82=E3=81=AE?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=E5=8B=95=E4=BD=9C?= =?UTF-8?q?=E3=82=92ping=E5=A4=B1=E6=95=97=E6=99=82=E3=81=A8=E7=B5=B1?= =?UTF-8?q?=E4=B8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tcnet.ts | 2 +- tests/bridge-os-detection.test.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tcnet.ts b/src/tcnet.ts index a356e9f..6d9d39c 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -820,8 +820,8 @@ export class TCNetClient extends EventEmitter { } // IPv4フォーマットバリデーション (execFileに渡す前の防御) + // 不正IPは確定的でないためキャッシュしない if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(bridgeIp)) { - this.bridgeIsWindows = false; return false; } diff --git a/tests/bridge-os-detection.test.ts b/tests/bridge-os-detection.test.ts index 67a9dba..7aeca97 100644 --- a/tests/bridge-os-detection.test.ts +++ b/tests/bridge-os-detection.test.ts @@ -189,12 +189,13 @@ describe("detectBridgeIsWindows", () => { expect(await client.callDetectBridgeIsWindows()).toBe(true); }); - it("不正なIPアドレス形式の場合はfalseを返す", async () => { + 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 () => { From 0b7f96cc24375beae3985803c0e0bfeb85a9800b Mon Sep 17 00:00:00 2001 From: xin <98406118+9c5s@users.noreply.github.com> Date: Sat, 4 Apr 2026 02:36:20 +0900 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20Bridge=20IP=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E6=99=82=E3=81=AEOS=E6=A4=9C=E5=87=BA=E3=82=AD=E3=83=A3?= =?UTF-8?q?=E3=83=83=E3=82=B7=E3=83=A5=E3=82=AF=E3=83=AA=E3=82=A2=E3=81=A8?= =?UTF-8?q?=E3=82=BB=E3=83=83=E3=82=B7=E3=83=A7=E3=83=B3=E5=90=8C=E4=B8=80?= =?UTF-8?q?=E6=80=A7=E3=83=81=E3=82=A7=E3=83=83=E3=82=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/tcnet.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/tcnet.ts b/src/tcnet.ts index 6d9d39c..790bd1d 100644 --- a/src/tcnet.ts +++ b/src/tcnet.ts @@ -473,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; } @@ -939,6 +942,7 @@ 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()) { @@ -946,7 +950,7 @@ export class TCNetClient extends EventEmitter { } // detectBridgeIsWindowsのawait中に状態が変わった場合のガード - if (!this.server || !this.broadcastSocket || this.sessionToken === null) { + if (!this.server || !this.broadcastSocket || this.sessionToken !== tokenBeforePing) { this.resetAuthSession(); return; }