From bacc2e91d357afdf5aa85753711d0ec89783cc1f Mon Sep 17 00:00:00 2001 From: EJClaw Date: Mon, 28 Sep 2026 11:29:25 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B2=84=EC=A0=84=20=EB=8F=85=EB=A6=BD?= =?UTF-8?q?=20P2P=20=ED=84=B0=EB=84=90=20=EB=AA=A8=EB=93=9C=20=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jar 하나로 모든 마인크래프트 버전을 지원해야 한다는 요구를 만족시키기 위해 마인크래프트 클래스를 전혀 참조하지 않는 구조로 설계했다. 26.1부터 게임이 비난독화되면서 Yarn/intermediary 가 폐지돼, 게임 클래스를 참조하는 jar 는 1.21.x 와 26.x 경계를 넘을 수 없다 — 버전별 빌드를 피할 유일한 길이다. 게임에서 필요한 두 가지를 자바 API 대신 네트워크 프로토콜로 얻는다. 호스트의 통합 서버 포트는 바닐라 "LAN 에 공개" 가 쏘는 멀티캐스트 비콘에서 읽고, 참가자에게는 같은 비콘을 우리가 쏘아 LAN 목록에 방을 띄운다. 덕분에 믹스인 0개, 버전 분기 0개, Loom·매핑 없는 평범한 자바 빌드가 됐다. 전송은 순수 자바 QUIC(Kwik)을 골랐다. 네이티브 WebRTC 대비 번들이 30MB → 0.71MB 로 줄고 아키텍처 제약이 사라진다. NAT 우회는 후보를 병렬로 시도하되 직결에 600ms 유예를 줘서, 항상 먼저 성공하는 중계가 직결을 밀어내지 않게 했다. 중계는 UDP 데이터그램을 그대로 넘기므로 직결과 터널 코드가 동일하고 QUIC TLS 1.3 이 종단간이라 중계 서버는 트래픽을 읽을 수 없다. 초대코드는 120비트 난수 자체이고 서버에 등록되는 방 ID 는 그 해시에서 파생된다. 시그널링 서버 운영자도 방에 참가하거나 호스트를 사칭할 수 없다. 검증: 57개 테스트 전부 통과. 직결·중계 양쪽에서 터널이 실제 소켓으로 왕복하고, 512KB 전송, 초대코드 불일치 거절, 지문 불일치 차단, 실제 멀티캐스트 비콘 송수신, 시그널링 프로토콜, 웹 콘솔 HTTP 를 포함한다. 마인크래프트 클래스 참조 0개는 컴파일된 클래스의 상수 풀을 뒤지는 테스트로 강제한다. 실제 게임 클라이언트 접속과 실 NAT 홀펀칭 성공률은 아직 미검증이며 docs/DESIGN.md 에 남은 과제로 정리했다. Co-Authored-By: Claude Opus 4.7 --- .github/workflows/build.yml | 36 ++ .gitignore | 8 + LICENSE | 21 + README.md | 177 ++++++++ build.gradle.kts | 101 +++++ docs/DESIGN.md | 257 ++++++++++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 +++++++++++ gradlew.bat | 93 +++++ settings.gradle.kts | 1 + src/main/java/kr/tkrmagid/mcp2p/Config.java | 153 +++++++ .../java/kr/tkrmagid/mcp2p/InviteCode.java | 158 +++++++ src/main/java/kr/tkrmagid/mcp2p/Log.java | 53 +++ src/main/java/kr/tkrmagid/mcp2p/P2PMod.java | 61 +++ src/main/java/kr/tkrmagid/mcp2p/Session.java | 318 ++++++++++++++ .../tkrmagid/mcp2p/lan/HostPortWatcher.java | 127 ++++++ .../kr/tkrmagid/mcp2p/lan/LanAdvertiser.java | 88 ++++ .../java/kr/tkrmagid/mcp2p/lan/LanBeacon.java | 106 +++++ .../kr/tkrmagid/mcp2p/lan/LanMulticast.java | 115 ++++++ .../java/kr/tkrmagid/mcp2p/net/Candidate.java | 104 +++++ .../java/kr/tkrmagid/mcp2p/net/P2PSocket.java | 373 +++++++++++++++++ src/main/java/kr/tkrmagid/mcp2p/net/Pipe.java | 58 +++ .../kr/tkrmagid/mcp2p/net/SelfSignedCert.java | 176 ++++++++ src/main/java/kr/tkrmagid/mcp2p/net/Stun.java | 146 +++++++ .../java/kr/tkrmagid/mcp2p/net/Tunnel.java | 18 + .../kr/tkrmagid/mcp2p/net/TunnelClient.java | 170 ++++++++ .../kr/tkrmagid/mcp2p/net/TunnelHost.java | 159 +++++++ .../tkrmagid/mcp2p/rendezvous/Protocol.java | 42 ++ .../mcp2p/rendezvous/RendezvousClient.java | 184 +++++++++ .../mcp2p/rendezvous/RendezvousServer.java | 390 ++++++++++++++++++ src/main/java/kr/tkrmagid/mcp2p/ui/Page.java | 155 +++++++ .../java/kr/tkrmagid/mcp2p/ui/WebConsole.java | 262 ++++++++++++ src/main/resources/fabric.mod.json | 19 + .../kr/tkrmagid/mcp2p/ArchitectureTest.java | 124 ++++++ .../kr/tkrmagid/mcp2p/InviteCodeTest.java | 86 ++++ .../kr/tkrmagid/mcp2p/lan/LanBeaconTest.java | 93 +++++ .../lan/LanMulticastIntegrationTest.java | 89 ++++ .../kr/tkrmagid/mcp2p/net/EchoServer.java | 75 ++++ .../tkrmagid/mcp2p/net/NetPrimitivesTest.java | 170 ++++++++ .../tkrmagid/mcp2p/net/TunnelDirectTest.java | 250 +++++++++++ .../mcp2p/rendezvous/RelayPathTest.java | 96 +++++ .../mcp2p/rendezvous/RendezvousTest.java | 131 ++++++ .../kr/tkrmagid/mcp2p/ui/WebConsoleTest.java | 158 +++++++ 44 files changed, 5656 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 build.gradle.kts create mode 100644 docs/DESIGN.md create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts create mode 100644 src/main/java/kr/tkrmagid/mcp2p/Config.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/InviteCode.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/Log.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/P2PMod.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/Session.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/lan/HostPortWatcher.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/lan/LanAdvertiser.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/lan/LanBeacon.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/lan/LanMulticast.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/Candidate.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/P2PSocket.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/Pipe.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/SelfSignedCert.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/Stun.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/Tunnel.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/TunnelClient.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/net/TunnelHost.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/rendezvous/Protocol.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousClient.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousServer.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/ui/Page.java create mode 100644 src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java create mode 100644 src/main/resources/fabric.mod.json create mode 100644 src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/InviteCodeTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/lan/LanBeaconTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/lan/LanMulticastIntegrationTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/net/EchoServer.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/net/NetPrimitivesTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/net/TunnelDirectTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/rendezvous/RelayPathTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousTest.java create mode 100644 src/test/java/kr/tkrmagid/mcp2p/ui/WebConsoleTest.java diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..1b14a24 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,36 @@ +# 버전별 빌드 매트릭스가 없다. 마인크래프트 클래스를 참조하지 않으므로 +# 산출물이 모든 버전에 공통인 jar 하나뿐이다(docs/DESIGN.md 2절 참고). +name: build + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - name: setup jdk + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + + - name: validate gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: build and test + run: ./gradlew build --no-daemon + + - name: upload jars + uses: actions/upload-artifact@v4 + with: + name: jars + path: build/libs/*.jar + + - name: upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5c4f345 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +build/ +.gradle/ +*.class +.idea/ +*.iml +.vscode/ +run/ +config/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1cbd2f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 tkrmagid + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fdca3a3..0552aa6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,179 @@ # mc_p2p_mod +초대코드 하나로 싱글플레이 월드를 친구와 함께 플레이하는 마인크래프트 P2P 모드. + +**jar 파일 하나가 모든 마인크래프트 버전에서 동작한다.** 1.21 용, 1.21.8 용, 26.3 용을 따로 +내려받지 않는다. 버전별 빌드도, 매핑도, 믹스인도 없다. 어떻게 가능한지는 +[docs/DESIGN.md](docs/DESIGN.md) 에 근거와 함께 적어 두었다. + +| | | +|---|---| +| 전송 | QUIC (TLS 1.3 종단간 암호화) | +| 네이티브 라이브러리 | 0개 | +| jar 크기 | 약 0.71 MB | +| 지원 버전 | Fabric 이 도는 모든 버전 (JVM 17 이상) | +| 믹스인 | 없음 | +| 마인크래프트 클래스 참조 | 0개 (테스트로 강제) | + +--- + +## 설치 + +1. [Fabric Loader](https://fabricmc.net/use/installer/) 0.14 이상을 설치한다. +2. `mc_p2p_mod-<버전>.jar` 을 `mods` 폴더에 넣는다. +3. 게임을 켠다. 로그에 조작 주소가 찍힌다. + +``` +[mc_p2p] 준비 완료 — 조작은 http://127.0.0.1:25585 에서 한다 +``` + +Fabric API 는 필요하지 않다. 다른 모드와 충돌할 여지도 사실상 없다 — 이 모드는 게임 코드를 +한 줄도 건드리지 않는다. + +--- + +## 처음 한 번: 시그널링 서버 준비 + +P2P 는 두 사람이 서로의 주소를 알아야 시작된다. 그 주소를 교환해 주는 작은 서버가 필요하다. +남의 서버에 의존하지 않도록, 이 저장소에 서버가 같이 들어 있다. + +아무 VPS 한 대에서: + +```bash +java -jar mc_p2p_mod-<버전>-rendezvous.jar 25580 40000-40100 +``` + +- `25580` — 시그널링용 TCP 포트 +- `40000-40100` — 중계용 UDP 포트 범위. 방화벽에서 이 범위만 열면 된다. + 생략하면 임의 포트를 쓰므로 방화벽 설정이 번거로워진다. + +메모리는 수십 MB 면 충분하다. 홀펀칭이 성공하는 한 트래픽은 이 서버를 지나지 않는다. + +서버를 띄웠으면 게임에서 `http://127.0.0.1:25585` 를 열고 주소를 넣고 저장한다. +예: `p2p.example.com:25580` + +--- + +## 방 만들기 (호스트) + +1. 웹 콘솔에서 **호스트 준비** 를 누른다. +2. 게임에서 월드에 들어가 **일시정지 → LAN 에 공개** 를 누른다. +3. 웹 콘솔에 초대코드가 나타난다. 친구에게 준다. + +``` +K7Q2M9-F4M1TX-9VB0RS-T3WXZ8 +``` + +바닐라의 "LAN 에 공개" 를 그대로 쓴다. 모드가 그 순간을 감지해서 방을 연다. 월드를 닫으면 +방도 알아서 정리된다. + +## 방 들어가기 (참가자) + +1. 웹 콘솔에 초대코드를 붙여넣고 **참가** 를 누른다. +2. 게임의 **멀티플레이** 화면을 연다. +3. LAN 목록에 방이 나타난다. 누르면 접속된다. + +주소를 입력하거나 서버를 추가할 필요가 없다. 목록에 직결/중계와 지연 시간이 같이 표시된다. + +``` +P2P 방 · 직결 23ms +``` + +--- + +## 연결 방식 + +경로를 동시에 시도해서 되는 것을 쓴다. + +1. **직결 (host)** — 같은 공유기 안이면 가장 빠르다. +2. **직결 (srflx)** — STUN 으로 공인 주소를 알아내 양쪽이 동시에 패킷을 보내 NAT 에 구멍을 + 뚫는다. 가정용 공유기 대부분에서 성공한다. +3. **중계 (relay)** — 대칭 NAT·CGNAT 처럼 구멍이 안 뚫리는 환경의 보험. 시그널링 서버가 + UDP 데이터그램을 그대로 넘긴다. + +중계는 거의 항상 즉시 성공하므로, 아무 장치 없이 "먼저 된 것" 을 쓰면 직결이 가능한데도 +중계로 굳어버린다. 그래서 직결에 기본 600ms 의 유예를 준다. 그 안에 홀펀칭이 되면 직결을 +쓰고, 안 되면 중계로 넘어간다. + +중계를 지나더라도 QUIC 의 TLS 1.3 이 종단간이라 **중계 서버는 게임 트래픽을 읽을 수 없다.** + +--- + +## 설정 + +`config/mc_p2p.properties`. 웹 콘솔에서 바꾸는 값도 여기 저장된다. + +| 키 | 기본값 | 설명 | +|---|---|---| +| `rendezvous` | (비어 있음) | 시그널링 서버 `호스트:포트` | +| `stun` | `stun.l.google.com:19302` | 비우면 같은 LAN 전용이 된다 | +| `webPort` | `25585` | 웹 콘솔 포트. 루프백에만 열린다 | +| `relayOnly` | `false` | 직결을 건너뛰고 중계만 쓴다(디버깅용) | +| `directPreferenceMs` | `600` | 직결에 주는 유예 시간 | +| `pathTimeoutMs` | `8000` | 경로 탐색 포기 시각 | +| `debug` | `false` | 상세 로그 | + +--- + +## 보안 + +- **방 비밀** — 초대코드는 120비트 난수다. 호스트는 QUIC 스트림 첫머리에서 이 값을 대조한 + 뒤에야 통합 서버로 연결을 넘긴다. 코드가 없으면 방 ID 를 알아도 들어올 수 없다. +- **시그널링 서버는 초대코드를 모른다** — 서버에 등록되는 방 ID 는 코드의 SHA-256 에서 + 파생된 값이다. 서버 운영자도 방에 몰래 들어오거나 호스트를 사칭할 수 없다. +- **중간자 차단** — 호스트는 매번 새 인증서를 만들고, 참가자는 시그널링으로 받은 SHA-256 + 지문과 실제 핸드셰이크의 인증서를 대조한다. 다르면 즉시 끊는다. +- **웹 콘솔은 127.0.0.1 에만 바인딩된다** — 방을 열고 닫을 수 있는 창구이므로 LAN 에도 + 노출하지 않는다. +- **참가자 리스너도 127.0.0.1 전용이다** — 같은 공유기의 다른 기기가 초대코드 없이 + 올라타지 못한다. + +시그널링 연결 자체는 아직 평문 TCP 다. 중간자가 이 경로를 장악하면 지문을 바꿔칠 수 있다. +TLS 적용은 남은 과제로 [docs/DESIGN.md](docs/DESIGN.md) 에 적어 두었다. + +--- + +## 빌드 + +```bash +./gradlew build +``` + +Fabric Loom 을 쓰지 않으므로 마인크래프트를 내려받지 않고 몇 초 안에 끝난다. +산출물은 `build/libs/` 에 두 개가 나온다. + +- `mc_p2p_mod-<버전>.jar` — 모드 +- `mc_p2p_mod-<버전>-rendezvous.jar` — 시그널링/중계 서버 + +테스트만 돌리려면 `./gradlew test`. + +--- + +## 검증 상태 + +솔직하게 적는다. + +**기계로 확인된 것** (`./gradlew test`, 57개): + +- 터널 전 구간 — 펀칭 프로브, 경로 선택, QUIC 핸드셰이크, 인증서 지문 고정, 방 비밀 대조, + TCP↔QUIC 양방향 운반. 직결과 중계 양쪽 모두 실제 소켓으로 왕복한다. +- 512KB 연속 전송이 손실 없이 도착한다(청크 전송 상황). +- 틀린 초대코드는 호스트가 거절하고 통합 서버에 닿지 않는다. +- 지문이 다르면 참가자가 연결을 끊는다. +- 직결과 중계가 동시에 유효할 때 직결이 이긴다. +- LAN 비콘을 실제 멀티캐스트로 쏘고 받아 포트를 읽는다. +- 시그널링 프로토콜 — 등록, 참가, 방 중복, 호스트 이탈 시 방 정리, 잘못된 입력. +- 웹 콘솔 — 실제 HTTP 요청/응답, 루프백 전용 바인딩. +- **마인크래프트 클래스 참조 0개** — 컴파일된 클래스의 상수 풀을 뒤져서 강제한다. + +**아직 확인되지 않은 것:** + +- 실제 마인크래프트 클라이언트로 접속해 본 검증. 루프백 테스트는 프로토콜 계층까지만 본다. +- 실제 NAT 를 넘는 홀펀칭 성공률. 테스트는 같은 기기 안이라 NAT 가 없다. +- 1.21.x 와 26.x 양쪽에서의 실제 로드 확인. + +--- + +## 라이선스 + +MIT. [LICENSE](LICENSE) 참고. diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..a5f26c6 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,101 @@ +plugins { + java +} + +group = "kr.tkrmagid" +version = "0.1.0" + +// ───────────────────────────────────────────────────────────────────────────── +// 이 프로젝트는 Fabric Loom 을 쓰지 않는다. 의도된 선택이다. +// +// Loom 은 마인크래프트 jar 를 내려받아 매핑(Yarn/Mojang)을 적용해 컴파일 대상으로 +// 제공하는 도구다. 즉 "마인크래프트 클래스를 참조하는 모드"를 위한 것이다. +// 이 모드는 마인크래프트 클래스를 단 하나도 참조하지 않으므로(docs/DESIGN.md 참고) +// Loom, 매핑, 버전별 소스셋, Stonecutter 가 전부 불필요하다. +// +// 결과: 평범한 자바 빌드 하나로 모든 마인크래프트 버전용 단일 jar 가 나온다. +// ───────────────────────────────────────────────────────────────────────────── + +repositories { + mavenCentral() + maven("https://maven.fabricmc.net/") { name = "FabricMC" } +} + +dependencies { + // Fabric Loader 는 런타임에 이미 존재한다 — 번들하면 안 된다. + // 우리가 쓰는 것은 net.fabricmc.api.ClientModInitializer 하나뿐이고, + // 이 인터페이스는 로더 소유라 리매핑 대상이 아니며 모든 버전에서 동일하다. + compileOnly("net.fabricmc:fabric-loader:0.18.4") + + // 순수 자바 QUIC. 네이티브 라이브러리 0개 (docs/DESIGN.md 의 전송 계층 비교 참고). + implementation("tech.kwik:kwik:0.11") + + testImplementation(platform("org.junit:junit-bom:5.14.1")) + testImplementation("org.junit.jupiter:junit-jupiter") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +java { + // 17 로 고정한다. 마인크래프트 1.18 이상은 JVM 17+ 로 실행되므로 그대로 동작하고, + // 1.17 이하도 사용자가 JVM 17+ 로 띄우면 동작한다. Kwik 자체는 major 55(Java 11) + // 로 컴파일돼 있어 하한을 더 낮출 수도 있지만, record/switch 식 등을 쓰기 위해 17 을 택했다. + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 +} + +tasks.withType().configureEach { + options.release.set(17) + options.encoding = "UTF-8" + options.compilerArgs.add("-Xlint:all,-serial,-this-escape") +} + +tasks.test { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + showStandardStreams = false + } +} + +tasks.processResources { + filesMatching("fabric.mod.json") { + expand("version" to project.version) + } +} + +// 팻 jar. shadow 플러그인을 쓰지 않는 이유는 의존성이 Kwik 계열 4개뿐이라 +// 재배치(relocation)가 필요 없고, 플러그인 하나를 덜 받는 쪽이 빌드가 단순해서다. +tasks.jar { + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + exclude( + "META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/*.MF", + "META-INF/LICENSE*", "META-INF/NOTICE*", + // 번들 라이브러리의 module-info 는 우리 jar 안에서 의미가 없고 충돌만 만든다. + "module-info.class", "META-INF/versions/*/module-info.class", + ) + manifest { + attributes( + "Implementation-Title" to "mc_p2p_mod", + "Implementation-Version" to project.version, + ) + } +} + +// 시그널링/릴레이 서버를 따로 돌릴 수 있게 실행 가능한 jar 도 같이 만든다. +// 모드 jar 와 클래스는 동일하고 Main-Class 만 다르다. +tasks.register("rendezvousJar") { + archiveClassifier.set("rendezvous") + from(sourceSets.main.get().output) + from(configurations.runtimeClasspath.get().map { if (it.isDirectory) it else zipTree(it) }) + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + exclude( + "META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA", "META-INF/*.MF", + "module-info.class", "META-INF/versions/*/module-info.class", + ) + manifest { + attributes("Main-Class" to "kr.tkrmagid.mcp2p.rendezvous.RendezvousServer") + } +} + +tasks.build { dependsOn("rendezvousJar") } diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..4a472c3 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,257 @@ +# 설계 기록 + +왜 이렇게 만들었는지, 무엇을 버렸는지, 무엇이 아직 안 됐는지. + +--- + +## 1. 요구사항 + +1. 리소스를 덜 먹고 효율적일 것 +2. **jar 하나로 모든 마인크래프트 버전을 지원할 것** + +2번이 설계 전체를 결정했다. 그래서 그것부터 정리한다. + +--- + +## 2. "jar 하나로 모든 버전" 이 왜 어려운가 + +기존 P2P 모드들(참고 대상: `KITE2459/kfcudp-instant-p2p`)은 Stonecutter 로 17개 버전을 +각각 빌드한다. 게을러서가 아니라, 마인크래프트 클래스를 참조하면 그 방법밖에 없기 때문이다. + +결정적인 단절이 2026년에 생겼다. **마인크래프트 26.1부터 코드가 비난독화되면서 Fabric 의 +Yarn/intermediary 매핑이 폐지됐다.** + +| | 1.21.x 이하 | 26.1 이상 | +|---|---|---| +| 게임 코드 | 난독화됨 | 비난독화 | +| 매핑 | Yarn / intermediary | Mojang 공식 이름 그대로 | +| 모드가 참조하는 이름 | intermediary (`class_310`) | 공식 (`net.minecraft.client.Minecraft`) | +| 런타임 리매핑 | 로더가 intermediary → 난독화 | 없음 | + +즉 마인크래프트 클래스를 참조하는 jar 는 이 경계를 넘을 수 없다. intermediary 이름을 담은 +jar 는 26.x 에서 해석되지 않고, 공식 이름을 담은 jar 는 1.21.x 의 난독화 런타임에 그런 +클래스가 없다. Fabric 문서도 26.1 포팅에 "모든 기존 모드는 최소한 재컴파일이 필요하다" 고 +못 박는다. + +`FabricMultiVersionHelper` 같은 도구는 **믹스인을 조건부로 로드**해 주지만, 위의 이름 체계 +단절 자체를 해결하지는 못한다. 버전별 믹스인 클래스를 여전히 사람이 따로 써야 한다. + +### 결론: 마인크래프트 클래스를 0개 참조한다 + +우회로가 아니라 유일한 길이다. 그리고 P2P 터널링에는 실현 가능하다. 게임에서 정말로 필요한 +것이 두 가지뿐이고, 둘 다 **자바 API 가 아니라 네트워크 프로토콜**로 노출돼 있다. + +**(1) 호스트의 통합 서버 포트를 알아내기** + +바닐라 "LAN 에 공개" 는 1.5초마다 멀티캐스트로 방송한다. + +``` +224.0.2.60:4445 (UDP) IPv6: ff75:230::60 +[MOTD]월드 이름[/MOTD][AD]25565[/AD] +``` + +이걸 들으면 사용자가 LAN 공개를 누른 순간과 그 포트를 알 수 있다. 믹스인이 필요 없다. +비콘이 끊기는 것은 월드가 닫혔다는 신호이기도 해서, 월드 종료 감지까지 같은 경로로 된다. + +**(2) 참가자에게 방을 보여 주기** + +같은 비콘을 우리가 쏘면 바닐라 클라이언트의 LAN 목록에 우리 터널이 그대로 나타난다. +GUI 코드가 한 줄도 필요 없다. + +여기서 중요한 사실: **페이로드에 IP 가 없다.** 포트만 있다. 따라서 클라이언트는 UDP 패킷의 +발신 주소를 서버 주소로 쓸 수밖에 없다. 우리가 어느 인터페이스로 쏘는지가 곧 클라이언트가 +접속할 IP 를 결정한다는 뜻이고, `LanMulticast.openSender()` 가 인터페이스를 명시적으로 +고정하는 이유다. + +이 프로토콜은 마인크래프트 1.3(2012) 이후 바뀐 적이 없고 난독화와 무관하다. +`LAN Announcer` 같은 기존 모드가 이미 같은 방식으로 원격 서버를 LAN 월드로 띄운다 — 선례가 있다. + +### 유일한 예외 + +`P2PMod` 가 구현하는 `net.fabricmc.api.ClientModInitializer` 와 `Config` 가 쓰는 +`FabricLoader.getConfigDir()`. 둘 다 **Fabric Loader 소유**라서 마인크래프트 클래스가 아니고 +리매핑 대상도 아니며, 로더가 존재하는 모든 버전에서 같은 이름·같은 시그니처다. + +`ArchitectureTest` 가 컴파일된 클래스의 상수 풀을 직접 뒤져서 이 약속을 강제한다. +누가 마인크래프트 타입을 하나라도 참조하면 빌드가 깨진다. import 를 grep 하는 것과 달리 +바이트코드 수준의 보증이다. 로더 API 참조도 위 두 클래스 밖으로 새는 순간 실패한다. + +### 얻은 것 + +- 버전별 빌드 17개 → 1개 +- Stonecutter 조건부 주석 (참고 모드의 `RoomListScreen.java` 한 파일에만 96개) → 0개 +- 믹스인 14개 → 0개. 다른 모드와 충돌할 지점도 함께 사라졌다. +- Fabric Loom·매핑·마인크래프트 jar 다운로드 없음 → 빌드가 몇 초로 끝나고 CI 매트릭스도 1칸 +- 게임 API 를 안 건드리므로 새 마인크래프트 버전이 나와도 할 일이 없다 + +### 치른 비용 + +조작 UI 를 게임 안에 넣을 수 없다. 그래서 웹 콘솔(`http://127.0.0.1:25585`)로 뺐다. +게임 화면을 벗어나야 하는 것은 분명한 단점이다. 대신 GUI 유지보수 비용이 통째로 사라졌고, +클립보드 붙여넣기 같은 조작은 오히려 브라우저가 낫다. + +--- + +## 3. 전송: 순수 자바 QUIC + +두 후보를 비교했다. 기준은 사용자가 요구한 "효율과 리소스" 다. + +| | 순수 자바 QUIC (Kwik) | 네이티브 WebRTC (dev.onvoid.webrtc) | +|---|---|---| +| 네이티브 라이브러리 | 0개 | 플랫폼별 필수 | +| 번들 크기 | **0.71 MB** (실측) | 약 30 MB (win 7.9 + linux 8.8 + mac 7.0 + mac-arm 5.8) | +| 플랫폼 제약 | 없음 (JVM 만) | 번들한 아키텍처만 | +| 계층 | QUIC = TLS 1.3 + 혼잡제어 + 스트림 다중화 | DTLS + SCTP + ICE 별도 | +| NAT traversal | 직접 구현 | libwebrtc 내장 (검증 충분) | + +**순수 자바 QUIC을 선택했다.** 크기가 42배 차이 나고, 아키텍처 제약이 사라지는 것이 결정적이다. +참고 모드의 WebRTC 빌드는 `linux-aarch64` 를 번들에서 빠뜨려서 ARM 리눅스에서 아예 실패하는데, +네이티브가 없으면 그런 실수를 할 여지 자체가 없다. "jar 하나로 모든 버전" 목표와도 맞는다. +QUIC 은 암호화·혼잡제어·다중화를 프로토콜 한 겹에서 주므로 DTLS+SCTP+ICE 조합보다 얇다. + +바꿔 말하면 NAT traversal 을 직접 써야 한다. libwebrtc 가 수년간 다진 ICE 예외 처리를 포기하는 +것이라 이 부분이 이 프로젝트에서 가장 위험한 코드다. 그래서 여기에 테스트를 몰아 두었다. + +라이브러리는 `tech.kwik:kwik:0.11` (Maven Central 최신). 의존성은 agent15(TLS 1.3), hkdf, +siphash 뿐이고 모두 순수 자바다. 클래스 파일 major 55 = Java 11 이라 우리 하한(17)을 제약하지 않는다. + +알려진 한계: Kwik 은 연결당 스레드를 쓰는 블로킹 모델이다. 수만 연결을 받는 서버라면 문제가 +되겠지만 방 하나에 참가자 몇 명인 상황에서는 무관하다. 논블로킹이 필요해질 만큼 커지면 +그때 다시 볼 일이다. + +--- + +## 4. NAT traversal 설계 + +조사 결과 현재 정설은 두 가지였다. + +- **QUIC 기반 홀펀칭이 TCP 기반보다 낫다.** 특히 열악한 회선에서 차이가 크고, + IETF 초안(`ADD_ADDRESS` / `PUNCH_ME_NOW` / `REMOVE_ADDRESS`)은 QUIC 의 경로 검증을 그대로 + 써서 네이티브로 구멍을 뚫고 직결로 옮겨가는 방향을 제시한다. +- **Tailscale·iroh 는 "중계로 먼저 붙이고 나중에 직결로 갈아탄다."** 연결이 즉시 성립하고 + 홀펀칭이 성공하면 조용히 승격된다. iroh 는 이 방식으로 약 9/10 성공률을 보고한다. + 권장 폴백 순서는 직결 → QUIC 프록시 → TURN → 최후에 TCP/443 이다. + +### 채택: 병렬 시도 + 직결 유예 + +갈아타기(migration)는 스트림을 끊김 없이 이전하는 장치가 추가로 필요하다. 그 복잡도를 지금 +감당할 이유가 없다고 판단했다. 대신 모든 후보를 동시에 찔러 보되 **직결에 600ms 유예**를 준다. + +- 직결이 되면 즉시 쓴다 (유예를 다 기다리지 않는다) +- 직결이 안 되면 600ms 후 중계로 넘어간다 + +유예가 없으면 중계가 항상 이긴다. 중계 서버는 NAT 뒤에 없어서 첫 프로브에 바로 응답하기 +때문이다. 최악의 대기가 600ms 인 대신 구현이 훨씬 단순하다. +`P2PSocket.awaitPath` 에 이 판단을 적어 두었고, 갈아타기는 아래 남은 과제로 넘겼다. + +참고 모드는 이걸 다르게 풀었다. 1차는 TURN 후보를 아예 만들지 않고, 실패하면 2차에 릴레이를 +허용해 재협상한다. 목적은 같지만 세션을 두 번 만들고, 양쪽이 같은 단계로 맞춰야 해서 +"조인자의 OFFER 재협상 횟수로 호스트가 단계를 유추" 하는 조율이 들어간다. 유예 방식은 +세션 하나로 끝난다. + +### 소켓을 하나로 묶는 이유 + +NAT 매핑은 (내부IP:포트 → 공인IP:포트) 단위로 생긴다. 홀펀칭으로 구멍을 뚫고 QUIC 을 다른 +소켓으로 열면 그 구멍은 쓸모가 없다. 그래서 STUN 질의, 펀칭 프로브, QUIC 데이터가 전부 +`P2PSocket` 하나를 지난다. + +구현은 `DatagramSocket` 을 상속해 수신을 가로채는 방식이다. 전용 읽기 스레드 하나가 들어온 +데이터그램을 프로브와 나머지로 나눠, 프로브는 직접 처리하고 나머지는 큐에 넣어 Kwik 에게 +넘긴다. 읽기 주체가 하나라 "펀칭 루프와 Kwik 수신 스레드가 패킷을 서로 훔쳐가는" 경쟁이 +생기지 않는다. + +프로브 첫 바이트는 `0x11` 이다. QUIC 패킷은 헤더 고정 비트(`0x40`)가 반드시 1 이므로 +`0x40` 비트가 꺼진 첫 바이트는 어떤 유효한 QUIC 패킷과도 겹치지 않는다. + +### 중계는 UDP 데이터그램을 그대로 넘긴다 + +TCP 로 중계하면 QUIC 을 그 위에 올릴 수 없다. TURN 처럼 데이터그램을 넘기면 +**직결과 중계에서 터널 코드가 완전히 같아진다** — 목적지 주소만 다르다. 그리고 QUIC 의 +TLS 1.3 이 종단간이라 중계 서버는 지나가는 트래픽을 읽을 수 없다. + +중계는 **방마다가 아니라 참가 세션마다** 포트 한 쌍을 배정한다. 방 단위로 하면 여러 참가자가 +같은 포트를 공유해 서로의 주소를 덮어쓴다. + +--- + +## 5. 초대코드가 시그널링 서버로부터 비밀인 구조 + +초대코드 = 120비트 난수 그 자체. 서버에 등록되는 방 ID 는 그 난수의 SHA-256 앞 40비트에서 +파생된다. + +``` +초대코드(비밀) ──SHA-256──> 방 ID (서버가 보는 값) + │ │ + └─ 코드 소유자는 방 ID 를 └─ 서버는 이 값에서 코드를 + 직접 계산할 수 있다 되돌릴 수 없다 +``` + +효과가 두 가지다. 코드를 아는 사람은 조회 과정 없이 방 ID 를 바로 계산하고, **서버 운영자는 +방에 몰래 참가하거나 호스트를 사칭할 수 없다.** 호스트가 스트림 첫머리에서 원본 비밀을 +대조하기 때문이다(비교는 `MessageDigest.isEqual` 로 타이밍 노출을 막는다). + +표기는 Crockford Base32 24자를 6자씩 4묶음으로 끊는다. I·L·O·U 를 뺀 알파벳이라 1·0 과 +눈으로 헷갈리지 않고, 입력받을 때는 그 혼동을 되돌려 준다(I·L→1, O→0). + +--- + +## 6. 의존성을 줄인 선택들 + +전부 같은 이유다 — 마인크래프트가 어떤 라이브러리를 클래스패스에 올려 두는지는 버전마다 다르다. +JDK 안에서 끝내면 그 변수가 사라진다. + +| 안 쓴 것 | 대신 | 이유 | +|---|---|---| +| SLF4J | `System.out` 래퍼 | 1.17 미만에는 없다 | +| GSON / JSON 라이브러리 | `java.util.Properties` | 버전마다 존재 여부가 다르다 | +| BouncyCastle | 손으로 조립한 X.509 DER (약 100줄) | 위와 같음 | +| `com.sun.net.httpserver` | 직접 쓴 HTTP (약 80줄) | `jdk.httpserver` 모듈이 항상 있다고 보장할 수 없다 | +| WebSocket | 줄 단위 TCP 텍스트 | 메시지가 4종류뿐. 서버가 200줄 아래로 떨어진다 | +| STUN 라이브러리 | Binding 요청만 직접 (약 150줄) | 필요한 게 요청 1종·속성 1개다 | +| Gradle Shadow 플러그인 | `jar` 태스크에서 직접 번들 | 의존성 4개라 재배치가 필요 없다 | + +직접 쓴 것에는 전부 테스트를 붙였다. 손으로 쓴 프로토콜 코드는 검증 없이 두면 안 된다. +인증서 테스트는 조립한 DER 이 실제로 파싱되고 서명 검증까지 통과하는지 본다. + +--- + +## 7. 남은 과제 + +우선순위 순. + +1. **실제 게임 검증.** 루프백 테스트는 프로토콜 계층까지만 본다. 1.21.x 와 26.x 양쪽에서 + 실제로 로드되고 접속되는지 확인해야 한다. 나머지 항목보다 이게 먼저다. +2. **실제 NAT 뒤에서의 홀펀칭 성공률 측정.** 같은 기기 안 테스트에는 NAT 가 없다. 대칭 NAT· + CGNAT 비율에 따라 중계 의존도가 결정되므로 숫자를 봐야 다음 판단을 할 수 있다. +3. **시그널링 TLS.** 지금은 평문 TCP 라 이 경로를 장악한 중간자가 인증서 지문을 바꿔칠 수 + 있다. 참고 모드도 같은 약점을 갖고 있다(기본 `ws://`). TLS 를 씌우거나 초대코드에서 + 파생한 키로 시그널링 메시지를 인증하면 된다. 후자가 서버 인증서 없이 되므로 더 맞을 수 있다. +4. **중계 → 직결 승격.** 4절에서 미룬 것. 중계로 먼저 붙여 대기를 0 으로 만들고, 홀펀칭이 + 성공하면 조용히 갈아탄다. QUIC 의 연결 마이그레이션(CID 유지)을 쓰면 재핸드셰이크 없이 + 가능하고 측정치로는 QUIC 재펀칭 대비 2 RTT, TCP 재펀칭 대비 3 RTT 를 아낀다. + Kwik 의 마이그레이션 지원 범위를 먼저 확인해야 한다. +5. **호스트 측 방 관리.** 화이트리스트·킥·밴. 중계를 지나면 모든 참가자가 같은 주소로 보이므로 + IP 기준이 아니라 초대코드/연결 단위로 설계해야 한다. +6. **NeoForge 지원.** 코어가 게임과 로더를 참조하지 않으므로 진입점만 추가하면 된다. 다만 + 단일 jar 에 두 로더 매니페스트를 같이 넣으면 일부 도구가 잘못 판단하는 사례가 있어, + jar 를 나누는 편이 나을 수 있다. +7. **IPv6 후보.** 지금은 IPv4 전용이다. IPv6 는 NAT 가 없는 경우가 많아 홀펀칭 성공률을 + 크게 올릴 수 있다. + +--- + +## 참고 자료 + +설계 근거로 삼은 것들. + +- [Porting to 26.1 Snapshots — Fabric Documentation](https://docs.fabricmc.net/26.1/develop/porting/) — 비난독화와 Yarn/intermediary 폐지 +- [Migrating Mappings — Fabric Documentation](https://docs.fabricmc.net/develop/porting/mappings/) +- [Fabric for Minecraft 26.1](https://fabricmc.net/2026/03/14/261.html) +- [FabricMultiVersionHelper](https://github.com/Klotzi111/FabricMultiVersionHelper) — 믹스인 조건부 로드 접근과 그 한계 +- [LAN Server Discovery — mclauncher-api wiki](https://github.com/tomsik68/mclauncher-api/wiki/LAN-Server-Discovery) — 비콘 포맷 +- [LAN Announcer](https://modrinth.com/mod/lan-announcer) — 멀티캐스트 주소·포트·간격 실측 선례 +- [Implementing NAT Hole Punching with QUIC (arXiv 2408.01791)](https://arxiv.org/abs/2408.01791) — QUIC 홀펀칭과 마이그레이션의 RTT 이득 +- [NAT Traversal — iroh](https://docs.iroh.computer/concepts/nat-traversal) — 중계 우선 + 직결 승격, 성공률 +- [How Tailscale is improving NAT traversal](https://tailscale.com/blog/nat-traversal-improvements-pt-1) — DERP 폴백 모델 +- [Kwik](https://github.com/ptrd/kwik) — 순수 자바 QUIC +- 참고 구현: `KITE2459/kfcudp-instant-p2p` (CC0) — 문제 정의와 UX 를 참고했다. 코드는 가져오지 않았다. diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c42672d --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..e509b2d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..59c1876 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "mc_p2p_mod" diff --git a/src/main/java/kr/tkrmagid/mcp2p/Config.java b/src/main/java/kr/tkrmagid/mcp2p/Config.java new file mode 100644 index 0000000..f3e0954 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/Config.java @@ -0,0 +1,153 @@ +package kr.tkrmagid.mcp2p; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Properties; + +/** + * 설정. {@code java.util.Properties} 를 쓴다. + * + *

GSON 같은 JSON 라이브러리를 쓰지 않은 이유는 마인크래프트 버전마다 클래스패스에 올라오는 + * 라이브러리가 다를 수 있기 때문이다. JDK 안에 있는 것만 쓰면 그 변수가 사라진다. + */ +public final class Config { + + private static final String FILE_NAME = "mc_p2p.properties"; + + /** 공개 STUN 서버. 자체 STUN 을 운영하면 바꾸면 된다. */ + private static final String DEFAULT_STUN = "stun.l.google.com:19302"; + + private final Path file; + private final Properties properties = new Properties(); + + public Config(Path configDir) { + this.file = configDir.resolve(FILE_NAME); + load(); + } + + /** + * Fabric Loader 가 알려 주는 설정 디렉터리를 쓴다. {@code FabricLoader} 는 로더 소유 + * 클래스라 리매핑 대상이 아니고 모든 버전에서 같다. 로더 밖(단위 테스트 등)에서는 + * 상대 경로로 물러난다. + */ + public static Config inGameDirectory() { + Path dir; + try { + dir = net.fabricmc.loader.api.FabricLoader.getInstance().getConfigDir(); + } catch (Throwable t) { + Log.debug("Fabric 설정 경로를 쓸 수 없다 — ./config 로 대체한다"); + dir = Paths.get("config"); + } + return new Config(dir); + } + + private void load() { + if (!Files.isRegularFile(file)) { + save(); // 사용자가 무엇을 고칠 수 있는지 보이도록 기본 파일을 만들어 둔다. + return; + } + try (InputStream in = Files.newInputStream(file)) { + properties.load(in); + } catch (IOException e) { + Log.warn("설정을 읽지 못했다 — 기본값으로 진행한다", e); + } + } + + public void save() { + try { + Files.createDirectories(file.getParent()); + try (OutputStream out = Files.newOutputStream(file)) { + properties.store(out, "mc_p2p_mod 설정. rendezvous 는 직접 띄운 시그널링 서버 주소다."); + } + } catch (IOException e) { + Log.warn("설정을 저장하지 못했다", e); + } + } + + // ── 값 ────────────────────────────────────────────────────────────────── + + /** {@code host:port}. 비어 있으면 아직 설정되지 않았다는 뜻이다. */ + public String rendezvous() { + return properties.getProperty("rendezvous", "").trim(); + } + + public void setRendezvous(String value) { + properties.setProperty("rendezvous", value == null ? "" : value.trim()); + save(); + } + + public boolean hasRendezvous() { + return !rendezvous().isEmpty(); + } + + public String rendezvousHost() { + String value = rendezvous(); + int colon = value.lastIndexOf(':'); + return colon > 0 ? value.substring(0, colon) : value; + } + + public int rendezvousPort() { + String value = rendezvous(); + int colon = value.lastIndexOf(':'); + if (colon < 0) return kr.tkrmagid.mcp2p.rendezvous.RendezvousServer.DEFAULT_PORT; + try { + return Integer.parseInt(value.substring(colon + 1)); + } catch (NumberFormatException e) { + return kr.tkrmagid.mcp2p.rendezvous.RendezvousServer.DEFAULT_PORT; + } + } + + /** STUN 을 끄려면 값을 비워 둔다. 그러면 같은 LAN 안에서만 직결이 된다. */ + public InetSocketAddress stunServer() { + String value = properties.getProperty("stun", DEFAULT_STUN).trim(); + if (value.isEmpty()) return null; + int colon = value.lastIndexOf(':'); + if (colon < 0) return new InetSocketAddress(value, 3478); + try { + return new InetSocketAddress(value.substring(0, colon), Integer.parseInt(value.substring(colon + 1))); + } catch (NumberFormatException e) { + Log.warn("stun 설정 형식이 잘못됐다: " + value); + return null; + } + } + + /** 디버깅용. 직결을 아예 시도하지 않고 중계만 쓴다. */ + public boolean relayOnly() { + return Boolean.parseBoolean(properties.getProperty("relayOnly", "false")); + } + + /** 웹 콘솔이 들을 로컬 포트. */ + public int webPort() { + return intValue("webPort", 25585); + } + + /** + * 직결 후보에게 줄 유예 시간(ms). 이 시간 안에 홀펀칭이 되면 직결을, 안 되면 중계를 쓴다 + * ({@link kr.tkrmagid.mcp2p.net.P2PSocket#awaitPath} 주석 참고). + */ + public long directPreferenceMs() { + return intValue("directPreferenceMs", 600); + } + + /** 경로를 하나도 못 찾았다고 포기할 때까지의 시간(ms). */ + public long pathTimeoutMs() { + return intValue("pathTimeoutMs", 8000); + } + + public boolean debug() { + return Boolean.parseBoolean(properties.getProperty("debug", "false")); + } + + private int intValue(String key, int fallback) { + try { + return Integer.parseInt(properties.getProperty(key, String.valueOf(fallback)).trim()); + } catch (NumberFormatException e) { + return fallback; + } + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/InviteCode.java b/src/main/java/kr/tkrmagid/mcp2p/InviteCode.java new file mode 100644 index 0000000..ffd418e --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/InviteCode.java @@ -0,0 +1,158 @@ +package kr.tkrmagid.mcp2p; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Locale; + +/** + * 초대코드 = 방의 공유 비밀 그 자체. + * + *

설계 의도가 하나 있다. 시그널링 서버는 초대코드를 절대 알지 못한다. + * 코드는 120비트 난수(비밀)이고, 서버에 등록되는 방 ID 는 그 비밀의 SHA-256 앞부분에서 + * 파생된 값이다. 서버는 방 ID 만 보므로 코드를 복원할 수 없고, 따라서 방에 몰래 + * 참가하거나 호스트를 사칭할 수 없다. 반대로 코드를 가진 사람은 방 ID 를 직접 계산할 수 + * 있어서 별도의 조회 과정이 필요 없다. + * + *

표기는 Crockford Base32 대문자 24자를 6자씩 끊어 {@code ABCDEF-GHJKMN-PQRSTV-WXYZ01} + * 형태로 보여준다. Crockford 를 쓰는 이유는 I/L/O/U 를 빼서 1·0 과 눈으로 혼동되지 않게 + * 하려는 것이고, 입력을 받을 때는 그 혼동을 되돌려 준다(I·L→1, O→0). + */ +public final class InviteCode { + + /** Crockford Base32 — I, L, O, U 제외. */ + private static final String ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + /** 15바이트 = 120비트. Base32 로 정확히 24자가 되어 6자 4묶음으로 깔끔하게 나뉜다. */ + public static final int SECRET_BYTES = 15; + + private static final int GROUP = 6; + + private final byte[] secret; + + private InviteCode(byte[] secret) { + if (secret.length != SECRET_BYTES) { + throw new IllegalArgumentException("비밀은 " + SECRET_BYTES + "바이트여야 한다: " + secret.length); + } + this.secret = secret.clone(); + } + + public static InviteCode generate() { + byte[] b = new byte[SECRET_BYTES]; + new SecureRandom().nextBytes(b); + return new InviteCode(b); + } + + /** + * 사용자가 입력한 문자열을 코드로 해석한다. 대소문자·하이픈·공백은 무시하고, + * 손으로 옮겨 적을 때 흔한 I/L→1, O→0 혼동을 되돌린다. + * + * @throws IllegalArgumentException 길이나 문자가 코드 형식이 아닐 때 + */ + public static InviteCode parse(String input) { + if (input == null) throw new IllegalArgumentException("코드가 비어 있다"); + StringBuilder cleaned = new StringBuilder(24); + for (char c : input.trim().toUpperCase(Locale.ROOT).toCharArray()) { + if (c == '-' || c == ' ' || c == '_') continue; + char fixed = switch (c) { + case 'I', 'L' -> '1'; + case 'O' -> '0'; + default -> c; + }; + if (ALPHABET.indexOf(fixed) < 0) { + throw new IllegalArgumentException("코드에 쓸 수 없는 문자다: '" + c + "'"); + } + cleaned.append(fixed); + } + if (cleaned.length() != 24) { + throw new IllegalArgumentException("코드는 24자여야 한다(하이픈 제외). 받은 길이: " + cleaned.length()); + } + return new InviteCode(decode(cleaned.toString())); + } + + /** QUIC 스트림에서 호스트가 대조할 원본 비밀. */ + public byte[] secret() { + return secret.clone(); + } + + /** 시그널링 서버에 등록/조회할 방 ID. 비밀에서 단방향으로 파생된다. */ + public String roomId() { + byte[] digest = sha256(secret); + StringBuilder sb = new StringBuilder(8); + // 상위 40비트 → Base32 8자. + long v = 0; + for (int i = 0; i < 5; i++) v = (v << 8) | (digest[i] & 0xFF); + for (int i = 7; i >= 0; i--) { + sb.append(ALPHABET.charAt((int) ((v >>> (i * 5)) & 0x1F))); + } + return sb.toString(); + } + + /** 사용자에게 보여줄 하이픈 포함 표기. */ + public String display() { + String raw = encode(secret); + StringBuilder sb = new StringBuilder(27); + for (int i = 0; i < raw.length(); i += GROUP) { + if (i > 0) sb.append('-'); + sb.append(raw, i, Math.min(i + GROUP, raw.length())); + } + return sb.toString(); + } + + @Override + public String toString() { + // 로그에 비밀이 그대로 남지 않게 한다. 방 ID 는 공개 정보라 괜찮다. + return "InviteCode(room=" + roomId() + ")"; + } + + // ── Base32 (Crockford 알파벳, 패딩 없음) ──────────────────────────────── + + static String encode(byte[] data) { + StringBuilder sb = new StringBuilder((data.length * 8 + 4) / 5); + int buffer = 0; + int bits = 0; + for (byte b : data) { + buffer = (buffer << 8) | (b & 0xFF); + bits += 8; + while (bits >= 5) { + bits -= 5; + sb.append(ALPHABET.charAt((buffer >>> bits) & 0x1F)); + } + } + if (bits > 0) { + sb.append(ALPHABET.charAt((buffer << (5 - bits)) & 0x1F)); + } + return sb.toString(); + } + + static byte[] decode(String text) { + int buffer = 0; + int bits = 0; + byte[] out = new byte[text.length() * 5 / 8]; + int pos = 0; + for (char c : text.toCharArray()) { + int index = ALPHABET.indexOf(c); + if (index < 0) throw new IllegalArgumentException("Base32 문자가 아니다: '" + c + "'"); + buffer = (buffer << 5) | index; + bits += 5; + if (bits >= 8) { + bits -= 8; + out[pos++] = (byte) ((buffer >>> bits) & 0xFF); + } + } + return out; + } + + static byte[] sha256(byte[] input) { + try { + return MessageDigest.getInstance("SHA-256").digest(input); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 이 없는 JVM 은 지원하지 않는다", e); + } + } + + static byte[] utf8(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/Log.java b/src/main/java/kr/tkrmagid/mcp2p/Log.java new file mode 100644 index 0000000..95a0cc1 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/Log.java @@ -0,0 +1,53 @@ +package kr.tkrmagid.mcp2p; + +/** + * 최소 로거. + * + *

SLF4J 를 쓰지 않는다. SLF4J 는 마인크래프트 1.17 이상에만 클래스패스에 있어서 + * 그보다 낮은 버전에서 {@code NoClassDefFoundError} 가 난다. 이 모드는 모든 버전에서 + * 같은 jar 로 동작하는 것이 목표이므로 JDK 밖의 로깅 의존성을 두지 않는다. + */ +public final class Log { + + private static volatile boolean debug = Boolean.getBoolean("mcp2p.debug"); + + private Log() {} + + public static void setDebug(boolean value) { + debug = value; + } + + public static boolean isDebug() { + return debug; + } + + public static void info(String message) { + System.out.println("[mc_p2p] " + message); + } + + public static void warn(String message) { + System.out.println("[mc_p2p] WARN " + message); + } + + public static void warn(String message, Throwable t) { + System.out.println("[mc_p2p] WARN " + message + " — " + describe(t)); + if (debug) t.printStackTrace(System.out); + } + + public static void debug(String message) { + if (debug) System.out.println("[mc_p2p] debug " + message); + } + + public static void debug(String message, Throwable t) { + if (debug) { + System.out.println("[mc_p2p] debug " + message + " — " + describe(t)); + t.printStackTrace(System.out); + } + } + + private static String describe(Throwable t) { + if (t == null) return "null"; + String m = t.getMessage(); + return m == null || m.isBlank() ? t.getClass().getSimpleName() : t.getClass().getSimpleName() + ": " + m; + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/P2PMod.java b/src/main/java/kr/tkrmagid/mcp2p/P2PMod.java new file mode 100644 index 0000000..9578481 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/P2PMod.java @@ -0,0 +1,61 @@ +package kr.tkrmagid.mcp2p; + +import kr.tkrmagid.mcp2p.ui.WebConsole; +import net.fabricmc.api.ClientModInitializer; + +/** + * 모드 진입점. 이 프로젝트에서 마인크래프트·모드로더 쪽 타입을 언급하는 파일은 여기와 + * {@link Config#inGameDirectory()} 둘뿐이다. + * + *

그리고 여기서 쓰는 {@link ClientModInitializer} 조차 마인크래프트 클래스가 아니다. + * Fabric Loader 소유의 인터페이스라 리매핑 대상이 아니고, 로더가 존재하는 모든 마인크래프트 + * 버전에서 같은 이름·같은 시그니처다. 그래서 이 jar 하나가 1.21 에서도 26.3 에서도 그대로 + * 로드된다. 자세한 근거는 docs/DESIGN.md 에 적어 두었다. + * + *

초기화는 가볍게 끝난다. 네트워크 자원은 사용자가 웹 콘솔에서 방을 열거나 참가할 때 + * 비로소 만들어지므로, 모드를 깔아 두기만 한 상태의 비용은 거의 0 이다. + */ +public final class P2PMod implements ClientModInitializer { + + private static volatile Session session; + private static volatile WebConsole console; + + @Override + public void onInitializeClient() { + Config config = Config.inGameDirectory(); + Log.setDebug(config.debug()); + + Session created = new Session(config); + session = created; + + try { + WebConsole web = new WebConsole(created, config.webPort()); + console = web; + Log.info("준비 완료 — 조작은 " + web.url() + " 에서 한다"); + if (!config.hasRendezvous()) { + Log.info("시그널링 서버 주소가 비어 있다. 위 주소를 열어 먼저 설정해라."); + } + } catch (Exception e) { + Log.warn("웹 콘솔을 " + config.webPort() + " 포트에 열지 못했다 — " + + "설정 파일의 webPort 를 바꿔 보라", e); + } + + // 게임이 닫힐 때 소켓과 스레드를 정리한다. 종료 훅은 JVM 기능이라 버전과 무관하다. + Runtime.getRuntime().addShutdownHook(new Thread(P2PMod::shutdown, "mcp2p-shutdown")); + } + + /** 테스트와 외부 도구용. 로더가 초기화하기 전이면 null 이다. */ + public static Session session() { + return session; + } + + private static void shutdown() { + WebConsole web = console; + console = null; + if (web != null) web.close(); + + Session current = session; + session = null; + if (current != null) current.close(); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/Session.java b/src/main/java/kr/tkrmagid/mcp2p/Session.java new file mode 100644 index 0000000..2f3effb --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/Session.java @@ -0,0 +1,318 @@ +package kr.tkrmagid.mcp2p; + +import kr.tkrmagid.mcp2p.lan.HostPortWatcher; +import kr.tkrmagid.mcp2p.lan.LanAdvertiser; +import kr.tkrmagid.mcp2p.net.Candidate; +import kr.tkrmagid.mcp2p.net.P2PSocket; +import kr.tkrmagid.mcp2p.net.SelfSignedCert; +import kr.tkrmagid.mcp2p.net.TunnelClient; +import kr.tkrmagid.mcp2p.net.TunnelHost; +import kr.tkrmagid.mcp2p.rendezvous.RendezvousClient; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +/** + * 모드 전체의 상태 기계. 호스트 한 개와 참가 한 개를 동시에 들 수 있다(호스트가 다른 + * 방에 놀러 갈 수도 있으니까). + * + *

여기에도 마인크래프트 클래스 참조가 없다. 호스트 시작 신호는 LAN 비콘으로 받고, + * 참가자에게 방을 보여 주는 것도 LAN 비콘으로 한다. + */ +public final class Session implements AutoCloseable { + + /** 월드를 닫으면 비콘이 끊긴다. 이 시간만큼 끊기면 방을 정리한다. */ + private static final long BEACON_GRACE_MS = 12_000L; + + private final Config config; + private final LanAdvertiser advertiser = new LanAdvertiser(); + private final ScheduledExecutorService watchdog = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "mcp2p-watchdog"); + t.setDaemon(true); + return t; + }); + + // 호스트 쪽 상태 + private volatile HostPortWatcher watcher; + private volatile boolean hostArmed; + private volatile InviteCode hostCode; + private volatile P2PSocket hostSocket; + private volatile TunnelHost tunnelHost; + private volatile RendezvousClient hostSignal; + private volatile int hostLanPort = -1; + private final List probes = new ArrayList<>(); + + // 참가 쪽 상태 + private volatile P2PSocket joinSocket; + private volatile TunnelClient tunnelClient; + private volatile RendezvousClient joinSignal; + private volatile String joinRoomId; + private volatile String joinError; + + public Session(Config config) { + this.config = config; + watchdog.scheduleAtFixedRate(this::checkHostAlive, 5, 5, TimeUnit.SECONDS); + } + + public Config config() { + return config; + } + + // ── 호스트 ────────────────────────────────────────────────────────────── + + /** + * 호스트 대기 상태로 들어간다. 실제 방은 사용자가 바닐라 "LAN 에 공개" 를 누른 순간 + * 열린다 — 그때 비로소 통합 서버 포트를 알 수 있기 때문이다. + */ + public synchronized void armHost() { + if (hostArmed) return; + if (!config.hasRendezvous()) { + throw new IllegalStateException("시그널링 서버 주소를 먼저 설정해라"); + } + hostArmed = true; + HostPortWatcher w = new HostPortWatcher(LanAdvertiser.MARKER, this::onLanPortDetected); + w.start(); + watcher = w; + Log.info("호스트 대기 중 — 마인크래프트에서 \"LAN 에 공개\" 를 누르면 방이 열린다"); + } + + public synchronized void disarmHost() { + hostArmed = false; + closeQuietly(watcher); + watcher = null; + closeRoom(); + } + + public boolean isHostArmed() { + return hostArmed; + } + + public String hostInviteCode() { + InviteCode code = hostCode; + return code == null ? null : code.display(); + } + + public int hostPlayerCount() { + TunnelHost host = tunnelHost; + return host == null ? 0 : host.connectionCount(); + } + + private void onLanPortDetected(int lanPort) { + synchronized (this) { + if (!hostArmed) return; + if (tunnelHost != null && lanPort == hostLanPort) return; + closeRoom(); + try { + openRoom(lanPort); + } catch (Exception e) { + Log.warn("방을 열지 못했다", e); + closeRoom(); + } + } + } + + private void openRoom(int lanPort) throws Exception { + InviteCode code = InviteCode.generate(); + P2PSocket socket = new P2PSocket(); + SelfSignedCert certificate = SelfSignedCert.generate(); + + List candidates = socket.gather(config.stunServer()); + RendezvousClient signal = new RendezvousClient(config.rendezvousHost(), config.rendezvousPort(), 8000); + + TunnelHost host = new TunnelHost(socket, lanPort, code.secret(), certificate); + host.start(); + + signal.registerHost(code.roomId(), certificate.fingerprint(), candidates, + peer -> onPeerArrived(socket, signal, peer)); + + hostCode = code; + hostSocket = socket; + tunnelHost = host; + hostSignal = signal; + hostLanPort = lanPort; + + Log.info("방이 열렸다 — 초대코드 " + code.display()); + } + + /** + * 참가자가 들어오려 한다. 호스트는 상대 후보들을 향해 프로브를 쏘아야 한다. + * 양쪽이 동시에 밖으로 패킷을 보내야 NAT 에 구멍이 생기기 때문이다(홀펀칭). + * 중계 포트에도 같이 쏜다 — 그래야 중계 서버가 호스트 주소를 학습한다. + */ + private void onPeerArrived(P2PSocket socket, RendezvousClient signal, RendezvousClient.PeerInfo peer) { + List targets = new ArrayList<>(); + if (!config.relayOnly()) targets.addAll(peer.candidates()); + if (peer.relayPort() > 0) { + targets.add(new Candidate(Candidate.Type.RELAY, signal.relayAddress(peer.relayPort()))); + } + if (targets.isEmpty()) return; + AutoCloseable probe = socket.startProbing(targets, 60_000); + synchronized (probes) { + probes.add(probe); + // 끝난 프로브 핸들을 계속 들고 있을 이유가 없다. + if (probes.size() > 32) probes.remove(0); + } + } + + /** + * 월드가 닫혔는지 확인한다. 마인크래프트는 월드를 닫을 때 LAN 비콘을 멈추므로 + * 비콘이 끊긴 것이 곧 "월드 종료" 신호다. + */ + private void checkHostAlive() { + try { + HostPortWatcher w = watcher; + if (!hostArmed || w == null || tunnelHost == null) return; + long last = w.lastBeaconAt(); + if (last > 0 && System.currentTimeMillis() - last > BEACON_GRACE_MS) { + Log.info("LAN 비콘이 끊겼다 — 월드가 닫힌 것으로 보고 방을 정리한다"); + synchronized (this) { + closeRoom(); + } + } + } catch (RuntimeException e) { + Log.debug("호스트 상태 점검 중 오류", e); + } + } + + private synchronized void closeRoom() { + synchronized (probes) { + probes.forEach(Session::closeQuietly); + probes.clear(); + } + closeQuietly(tunnelHost); + closeQuietly(hostSignal); + closeQuietly(hostSocket); + tunnelHost = null; + hostSignal = null; + hostSocket = null; + hostCode = null; + hostLanPort = -1; + } + + // ── 참가 ──────────────────────────────────────────────────────────────── + + /** + * 초대코드로 방에 들어간다. 성공하면 바닐라 멀티플레이 화면의 LAN 목록에 방이 나타난다. + * + * @return LAN 목록에 올린 로컬 포트 + */ + public synchronized int join(String rawCode) throws Exception { + if (!config.hasRendezvous()) { + throw new IllegalStateException("시그널링 서버 주소를 먼저 설정해라"); + } + leave(); + joinError = null; + + InviteCode code = InviteCode.parse(rawCode); + P2PSocket socket = new P2PSocket(); + RendezvousClient signal = null; + try { + signal = new RendezvousClient(config.rendezvousHost(), config.rendezvousPort(), 8000); + List mine = socket.gather(config.stunServer()); + RendezvousClient.HostInfo info = signal.join(code.roomId(), mine); + + List targets = new ArrayList<>(); + if (!config.relayOnly()) targets.addAll(info.candidates()); + if (info.relayPort() > 0) { + targets.add(new Candidate(Candidate.Type.RELAY, signal.relayAddress(info.relayPort()))); + } + if (targets.isEmpty()) { + throw new IOException("시도할 수 있는 경로가 없다 — 호스트 후보와 중계가 모두 비었다"); + } + + P2PSocket.Validated path = socket.awaitPath(targets, config.pathTimeoutMs(), config.directPreferenceMs()); + if (path == null) { + throw new IOException("어떤 경로도 열리지 않았다 — 중계 서버가 살아 있는지, 방화벽이 UDP 를 막는지 확인해라"); + } + + TunnelClient client = new TunnelClient(socket, code.secret(), info.fingerprint()); + int localPort = client.connect(path); + + joinSocket = socket; + joinSignal = signal; + tunnelClient = client; + joinRoomId = code.roomId(); + + String label = (path.candidate().isRelay() ? "중계" : "직결") + " " + path.rttMillis() + "ms"; + advertiser.put(localPort, "P2P 방 · " + label); + Log.info("참가 완료 — 멀티플레이 화면의 LAN 목록에서 방을 누르면 접속된다 (" + label + ")"); + return localPort; + } catch (Exception e) { + joinError = e.getMessage(); + closeQuietly(signal); + closeQuietly(socket); + throw e; + } + } + + public synchronized void leave() { + TunnelClient client = tunnelClient; + if (client != null && client.localPort() > 0) { + advertiser.remove(client.localPort()); + } + closeQuietly(client); + closeQuietly(joinSignal); + closeQuietly(joinSocket); + tunnelClient = null; + joinSignal = null; + joinSocket = null; + joinRoomId = null; + } + + public boolean isJoined() { + TunnelClient client = tunnelClient; + return client != null && client.isConnected(); + } + + public String joinRoomId() { + return joinRoomId; + } + + public String joinError() { + return joinError; + } + + public Boolean joinUsesRelay() { + TunnelClient client = tunnelClient; + return client == null ? null : client.usesRelay(); + } + + public long joinRttMillis() { + TunnelClient client = tunnelClient; + return client == null ? -1 : client.pathRttMillis(); + } + + public int joinLocalPort() { + TunnelClient client = tunnelClient; + return client == null ? -1 : client.localPort(); + } + + // ── 정리 ──────────────────────────────────────────────────────────────── + + @Override + public void close() { + watchdog.shutdownNow(); + disarmHost(); + leave(); + closeQuietly(advertiser); + } + + private static void closeQuietly(AutoCloseable closeable) { + if (closeable == null) return; + try { + closeable.close(); + } catch (Exception e) { + Log.debug("정리 중 오류", e); + } + } + + /** 참고용. STUN 이 꺼져 있으면 공인 주소를 못 구한다는 안내에 쓴다. */ + public InetSocketAddress stunServer() { + return config.stunServer(); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/lan/HostPortWatcher.java b/src/main/java/kr/tkrmagid/mcp2p/lan/HostPortWatcher.java new file mode 100644 index 0000000..247904f --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/lan/HostPortWatcher.java @@ -0,0 +1,127 @@ +package kr.tkrmagid.mcp2p.lan; + +import kr.tkrmagid.mcp2p.Log; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.InetAddress; +import java.net.MulticastSocket; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; +import java.util.function.IntConsumer; + +/** + * 사용자가 바닐라 "LAN 에 공개" 를 누른 순간을 감지해서 통합 서버 포트를 알아낸다. + * + *

믹스인이나 마인크래프트 클래스 참조 없이 이 일을 할 수 있는 이유는 + * {@link LanBeacon} 의 주석에 적어 두었다. 요약하면 마인크래프트가 그 정보를 + * 멀티캐스트로 방송해 주기 때문이다. + * + *

같은 기기에서 올라온 비콘만 받아들인다. 그렇지 않으면 같은 공유기에 붙은 친구의 + * 월드를 내 월드로 착각해서 엉뚱한 방을 열게 된다. + */ +public final class HostPortWatcher implements AutoCloseable { + + private final IntConsumer onPortFound; + private final String ignoreMotdMarker; + private volatile Thread thread; + private volatile MulticastSocket socket; + private volatile boolean running; + private volatile int lastPort = -1; + private volatile long lastBeaconAt; + + /** + * @param ignoreMotdMarker 우리가 참가자로서 쏘는 비콘의 표식. 자기 자신이 쏜 비콘을 + * 다시 읽어 "호스트가 열렸다" 고 오인하는 되먹임을 막는다. + * @param onPortFound 새 포트를 찾을 때마다 호출된다. 같은 포트로는 다시 부르지 않는다. + */ + public HostPortWatcher(String ignoreMotdMarker, IntConsumer onPortFound) { + this.ignoreMotdMarker = ignoreMotdMarker; + this.onPortFound = onPortFound; + } + + public synchronized void start() { + if (running) return; + running = true; + Thread t = new Thread(this::run, "mcp2p-lan-watch"); + t.setDaemon(true); + thread = t; + t.start(); + } + + /** 마지막으로 감지한 통합 서버 포트. 아직 못 찾았으면 -1. */ + public int lastPort() { + return lastPort; + } + + /** + * 마지막 비콘 수신 시각(epoch ms). 0 이면 아직 아무것도 못 들었다. + * + *

월드를 닫으면 마인크래프트가 비콘을 멈춘다. 즉 이 값이 오래됐다는 것은 방을 + * 닫아야 한다는 신호다. "월드 종료" 이벤트를 마인크래프트 API 없이 알아내는 방법이다. + */ + public long lastBeaconAt() { + return lastBeaconAt; + } + + private void run() { + try (MulticastSocket s = LanMulticast.openReceiver()) { + socket = s; + Set mine = localAddresses(); + byte[] buffer = new byte[512]; + while (running) { + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + try { + s.receive(packet); + } catch (IOException e) { + if (running) Log.debug("LAN 비콘 수신 오류", e); + continue; + } + if (!mine.contains(packet.getAddress())) continue; + + LanBeacon beacon = LanBeacon.decode(packet.getData(), packet.getLength()); + if (beacon == null) continue; + if (ignoreMotdMarker != null && beacon.motd().contains(ignoreMotdMarker)) continue; + + lastBeaconAt = System.currentTimeMillis(); + if (beacon.port() != lastPort) { + lastPort = beacon.port(); + Log.info("LAN 공개 감지 — 통합 서버 포트 " + beacon.port() + " (\"" + beacon.motd() + "\")"); + try { + onPortFound.accept(beacon.port()); + } catch (RuntimeException e) { + Log.warn("포트 감지 처리 중 오류", e); + } + } + } + } catch (IOException e) { + Log.warn("LAN 비콘을 들을 수 없다 — 호스트 자동 감지가 동작하지 않는다", e); + } finally { + socket = null; + } + } + + private static Set localAddresses() { + Set out = new HashSet<>(); + try { + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + out.addAll(Collections.list(nic.getInetAddresses())); + } + } catch (SocketException e) { + Log.debug("로컬 주소 목록을 읽지 못했다", e); + } + return out; + } + + @Override + public void close() { + running = false; + MulticastSocket s = socket; + if (s != null) s.close(); + Thread t = thread; + if (t != null) t.interrupt(); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/lan/LanAdvertiser.java b/src/main/java/kr/tkrmagid/mcp2p/lan/LanAdvertiser.java new file mode 100644 index 0000000..6e19862 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/lan/LanAdvertiser.java @@ -0,0 +1,88 @@ +package kr.tkrmagid.mcp2p.lan; + +import kr.tkrmagid.mcp2p.Log; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.InetSocketAddress; +import java.net.MulticastSocket; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 우리 터널을 바닐라 클라이언트의 LAN 목록에 올린다. + * + *

참가자 쪽 사용자 경험 전체가 여기에 달려 있다. 로컬 TCP 리스너를 열고 그 포트를 + * 비콘으로 방송하면, 사용자는 멀티플레이 화면에서 방이 저절로 나타나는 것을 보고 누르면 + * 된다. 주소를 입력하거나 서버를 추가할 필요가 없고, 우리가 만들어야 할 GUI 도 없다. + * + *

여러 방을 동시에 올릴 수 있게 되어 있다. 참가 중인 방 하나와 안내용 항목 하나를 + * 같이 띄우는 식으로 쓴다. + */ +public final class LanAdvertiser implements AutoCloseable { + + /** 우리가 쏜 비콘임을 표시하는 글자. {@link HostPortWatcher} 가 이걸 보고 무시한다. */ + public static final String MARKER = "§r​"; + + private final Map entries = new ConcurrentHashMap<>(); + private volatile Thread thread; + private volatile boolean running; + + /** + * @param port 바닐라 클라이언트가 접속할 로컬 TCP 포트 + * @param motd LAN 목록에 보일 이름. 표식이 자동으로 붙는다. + */ + public void put(int port, String motd) { + entries.put(port, MARKER + motd); + ensureRunning(); + } + + public void remove(int port) { + entries.remove(port); + } + + public boolean isEmpty() { + return entries.isEmpty(); + } + + private synchronized void ensureRunning() { + if (running) return; + running = true; + Thread t = new Thread(this::run, "mcp2p-lan-advertise"); + t.setDaemon(true); + thread = t; + t.start(); + } + + private void run() { + try (MulticastSocket socket = LanMulticast.openSender()) { + InetSocketAddress group = LanMulticast.groupV4(); + while (running) { + for (Map.Entry e : entries.entrySet()) { + byte[] payload = new LanBeacon(e.getValue(), e.getKey()).encode(); + try { + socket.send(new DatagramPacket(payload, payload.length, group)); + } catch (IOException ex) { + Log.debug("LAN 비콘 송신 실패 port=" + e.getKey(), ex); + } + } + try { + Thread.sleep(LanBeacon.INTERVAL_MS); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + return; + } + } + } catch (IOException e) { + Log.warn("LAN 목록에 방을 올릴 수 없다 — 멀티플레이 화면에 자동으로 뜨지 않는다", e); + } + } + + @Override + public void close() { + running = false; + entries.clear(); + Thread t = thread; + if (t != null) t.interrupt(); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/lan/LanBeacon.java b/src/main/java/kr/tkrmagid/mcp2p/lan/LanBeacon.java new file mode 100644 index 0000000..4674086 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/lan/LanBeacon.java @@ -0,0 +1,106 @@ +package kr.tkrmagid.mcp2p.lan; + +import java.nio.charset.StandardCharsets; + +/** + * 마인크래프트 LAN 검색 비콘의 인코딩/디코딩. + * + *

이 클래스가 이 모드 전체의 핵심이다. 마인크래프트의 "LAN 에 공개" 기능은 자바 API 가 + * 아니라 네트워크 프로토콜로 노출된다. 호스트는 1.5초마다 멀티캐스트로 아래 형태의 + * UDP 페이로드를 뿌리고, 클라이언트는 멀티플레이 화면이 열려 있는 동안 그것을 듣는다. + * + *

[MOTD]월드 이름[/MOTD][AD]25565[/AD]
+ * + *

중요한 점: 페이로드에는 IP 가 없다. 포트만 들어 있다. 따라서 클라이언트는 + * UDP 패킷의 발신 주소를 서버 주소로 쓸 수밖에 없다. 이 사실이 우리에게 두 가지를 준다. + * + *

    + *
  1. 호스트 쪽: 사용자가 바닐라 "LAN 에 공개" 를 누르면 우리는 믹스인 없이 그 포트를 알 수 있다.
  2. + *
  3. 참가자 쪽: 우리가 직접 비콘을 쏘면 바닐라 클라이언트의 LAN 목록에 우리 터널이 + * 그대로 나타난다. GUI 코드가 한 줄도 필요 없다.
  4. + *
+ * + *

이 프로토콜은 마인크래프트 1.3(2012) 이후 바뀐 적이 없고 난독화와 무관하다. + * 그래서 jar 하나로 모든 버전을 지원할 수 있다. + */ +public final class LanBeacon { + + /** 마인크래프트가 쓰는 IPv4 멀티캐스트 그룹. */ + public static final String GROUP_V4 = "224.0.2.60"; + + /** IPv6 쪽 그룹. 바닐라도 같이 쏜다. */ + public static final String GROUP_V6 = "ff75:230::60"; + + public static final int PORT = 4445; + + /** 바닐라 호스트의 송신 간격. 우리도 같은 간격을 쓴다. */ + public static final long INTERVAL_MS = 1500L; + + private static final String MOTD_OPEN = "[MOTD]"; + private static final String MOTD_CLOSE = "[/MOTD]"; + private static final String AD_OPEN = "[AD]"; + private static final String AD_CLOSE = "[/AD]"; + + private final String motd; + private final int port; + + public LanBeacon(String motd, int port) { + this.motd = motd == null ? "" : motd; + this.port = port; + } + + public String motd() { + return motd; + } + + public int port() { + return port; + } + + /** + * 바닐라가 쓰는 순서대로(MOTD 먼저) 직렬화한다. 클라이언트 파서는 순서를 따지지 + * 않지만, 굳이 다르게 만들 이유가 없다. + */ + public byte[] encode() { + String payload = MOTD_OPEN + motd + MOTD_CLOSE + AD_OPEN + port + AD_CLOSE; + return payload.getBytes(StandardCharsets.UTF_8); + } + + /** + * 수신한 페이로드를 해석한다. + * + * @return 형식이 맞지 않거나 포트가 범위를 벗어나면 {@code null}. 멀티캐스트 그룹에는 + * 다른 프로그램의 트래픽도 섞일 수 있으므로 예외를 던지지 않고 조용히 버린다. + */ + public static LanBeacon decode(byte[] data, int length) { + if (data == null || length <= 0) return null; + String text = new String(data, 0, length, StandardCharsets.UTF_8); + + String ad = between(text, AD_OPEN, AD_CLOSE); + if (ad == null) return null; + int port; + try { + port = Integer.parseInt(ad.trim()); + } catch (NumberFormatException e) { + return null; + } + if (port < 1 || port > 65535) return null; + + String motd = between(text, MOTD_OPEN, MOTD_CLOSE); + return new LanBeacon(motd == null ? "" : motd, port); + } + + private static String between(String text, String open, String close) { + int start = text.indexOf(open); + if (start < 0) return null; + int from = start + open.length(); + int end = text.indexOf(close, from); + if (end < 0) return null; + return text.substring(from, end); + } + + @Override + public String toString() { + return "LanBeacon{motd='" + motd + "', port=" + port + '}'; + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/lan/LanMulticast.java b/src/main/java/kr/tkrmagid/mcp2p/lan/LanMulticast.java new file mode 100644 index 0000000..49f1cef --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/lan/LanMulticast.java @@ -0,0 +1,115 @@ +package kr.tkrmagid.mcp2p.lan; + +import kr.tkrmagid.mcp2p.Log; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.MulticastSocket; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** 멀티캐스트 소켓 준비와 네트워크 인터페이스 선택. */ +final class LanMulticast { + + private LanMulticast() {} + + /** + * 비콘 수신용 소켓. 마인크래프트 본체도 같은 포트에 바인딩하므로 반드시 + * 주소 재사용이 켜져 있어야 한다({@link MulticastSocket} 은 기본으로 켜 준다). + * 가입은 멀티캐스트를 지원하는 모든 인터페이스에 시도한다 — 어느 인터페이스로 + * 들어올지 미리 알 수 없기 때문이다. + */ + static MulticastSocket openReceiver() throws IOException { + MulticastSocket socket = new MulticastSocket(null); + socket.setReuseAddress(true); + socket.bind(new InetSocketAddress(LanBeacon.PORT)); + InetSocketAddress group = new InetSocketAddress(InetAddress.getByName(LanBeacon.GROUP_V4), LanBeacon.PORT); + int joined = 0; + for (NetworkInterface nic : multicastCapable(true)) { + try { + socket.joinGroup(group, nic); + joined++; + } catch (IOException e) { + Log.debug("멀티캐스트 가입 실패: " + nic.getName(), e); + } + } + if (joined == 0) { + socket.close(); + throw new IOException("멀티캐스트 그룹에 가입할 수 있는 인터페이스가 없다"); + } + Log.debug("LAN 비콘 수신 시작 — 인터페이스 " + joined + "개"); + return socket; + } + + /** + * 비콘 송신용 소켓. 송신 인터페이스를 명시적으로 고정한다. + * + *

이게 중요한 이유: 마인크래프트 클라이언트는 비콘 페이로드가 아니라 UDP 발신 + * 주소를 서버 주소로 쓴다. 그러므로 우리가 어느 인터페이스로 쏘는지가 곧 클라이언트가 + * 접속을 시도할 IP 를 결정한다. 여러 인터페이스로 뿌리면 LAN 목록에 같은 방이 여러 번 + * 뜨므로, 인터넷으로 나가는 경로의 인터페이스 하나만 고른다. + */ + static MulticastSocket openSender() throws IOException { + MulticastSocket socket = new MulticastSocket(); + socket.setTimeToLive(1); // 같은 서브넷 밖으로 나갈 필요가 없다. + NetworkInterface nic = preferredOutgoing(); + if (nic != null) { + try { + socket.setNetworkInterface(nic); + Log.debug("LAN 비콘 송신 인터페이스: " + nic.getName()); + } catch (SocketException e) { + Log.debug("송신 인터페이스 고정 실패 — 기본값 사용", e); + } + } + return socket; + } + + static InetSocketAddress groupV4() throws IOException { + return new InetSocketAddress(InetAddress.getByName(LanBeacon.GROUP_V4), LanBeacon.PORT); + } + + /** + * 인터넷으로 나갈 때 쓰이는 로컬 주소가 붙어 있는 인터페이스. UDP 소켓을 공개 주소로 + * connect 하면 커널이 라우팅 테이블을 보고 로컬 주소를 정해 주는데, 이때 실제 패킷은 + * 한 바이트도 나가지 않는다. 그 주소의 소유 인터페이스를 고른다. + */ + private static NetworkInterface preferredOutgoing() { + try (java.net.DatagramSocket probe = new java.net.DatagramSocket()) { + probe.connect(InetAddress.getByName("1.1.1.1"), 53); + InetAddress local = probe.getLocalAddress(); + if (local != null && !local.isAnyLocalAddress()) { + NetworkInterface nic = NetworkInterface.getByInetAddress(local); + if (nic != null && nic.supportsMulticast()) return nic; + } + } catch (Exception e) { + Log.debug("기본 경로 탐지 실패 — 인터페이스 목록으로 대체한다", e); + } + List candidates = multicastCapable(false); + return candidates.isEmpty() ? null : candidates.get(0); + } + + /** + * @param includeLoopback 수신에서는 루프백도 포함해야 한다. 같은 기기에서 도는 다른 + * 프로세스가 루프백으로 쏠 수 있기 때문이다. 송신에서는 제외한다. + */ + private static List multicastCapable(boolean includeLoopback) { + List out = new ArrayList<>(); + try { + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nic.isUp() || !nic.supportsMulticast()) continue; + if (nic.isLoopback() && !includeLoopback) continue; + boolean hasV4 = nic.getInterfaceAddresses().stream() + .anyMatch(a -> a.getAddress() instanceof Inet4Address); + if (hasV4) out.add(nic); + } + } catch (SocketException e) { + Log.debug("인터페이스 목록을 읽지 못했다", e); + } + return out; + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/Candidate.java b/src/main/java/kr/tkrmagid/mcp2p/net/Candidate.java new file mode 100644 index 0000000..f9404ce --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/Candidate.java @@ -0,0 +1,104 @@ +package kr.tkrmagid.mcp2p.net; + +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; + +/** + * 연결 후보 하나. ICE 의 candidate 와 같은 개념이지만 우선순위 계산이나 nomination 같은 + * RFC 8445 의 정교한 절차는 넣지 않았다. 후보를 전부 동시에 찔러 보고 먼저 응답한 경로를 + * 쓰는 방식이면 이 용도에는 충분하고, 검증할 코드도 훨씬 적다. + */ +public final class Candidate { + + public enum Type { + /** 같은 LAN 안에서만 통하는 사설 주소. */ + HOST, + /** STUN 으로 알아낸 공인 주소. 홀펀칭의 주 표적. */ + SRFLX, + /** 중계 서버를 경유하는 주소. 홀펀칭이 실패할 때의 보험. */ + RELAY, + } + + private final Type type; + private final InetSocketAddress address; + + public Candidate(Type type, InetSocketAddress address) { + this.type = type; + this.address = address; + } + + public Type type() { + return type; + } + + public InetSocketAddress address() { + return address; + } + + public boolean isRelay() { + return type == Type.RELAY; + } + + /** 시그널링으로 실어 보낼 표기. 공백이 없어야 한다(줄 기반 프로토콜이라). */ + public String encode() { + return type.name().toLowerCase(java.util.Locale.ROOT) + ":" + + address.getAddress().getHostAddress() + ":" + address.getPort(); + } + + public static Candidate decode(String text) { + String[] parts = text.split(":"); + if (parts.length != 3) return null; + Type type; + try { + type = Type.valueOf(parts[0].toUpperCase(java.util.Locale.ROOT)); + } catch (IllegalArgumentException e) { + return null; + } + int port; + try { + port = Integer.parseInt(parts[2]); + } catch (NumberFormatException e) { + return null; + } + if (port < 1 || port > 65535) return null; + InetSocketAddress address = new InetSocketAddress(parts[1], port); + if (address.isUnresolved()) return null; + return new Candidate(type, address); + } + + public static String encodeList(List candidates) { + if (candidates.isEmpty()) return "-"; + StringBuilder sb = new StringBuilder(); + for (Candidate c : candidates) { + if (sb.length() > 0) sb.append(','); + sb.append(c.encode()); + } + return sb.toString(); + } + + public static List decodeList(String text) { + List out = new ArrayList<>(); + if (text == null || text.isBlank() || "-".equals(text)) return out; + for (String part : text.split(",")) { + Candidate c = decode(part.trim()); + if (c != null) out.add(c); + } + return out; + } + + @Override + public String toString() { + return encode(); + } + + @Override + public boolean equals(Object o) { + return o instanceof Candidate other && other.type == type && other.address.equals(address); + } + + @Override + public int hashCode() { + return type.hashCode() * 31 + address.hashCode(); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/P2PSocket.java b/src/main/java/kr/tkrmagid/mcp2p/net/P2PSocket.java new file mode 100644 index 0000000..f709466 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/P2PSocket.java @@ -0,0 +1,373 @@ +package kr.tkrmagid.mcp2p.net; + +import kr.tkrmagid.mcp2p.Log; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.net.SocketTimeoutException; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * QUIC 과 홀펀칭이 같은 UDP 소켓을 공유하게 만드는 장치. + * + *

왜 같은 소켓이어야 하는가. NAT 매핑은 (내부IP:포트 → 공인IP:포트) 단위로 만들어진다. + * 홀펀칭으로 구멍을 뚫어 놓고 QUIC 을 다른 소켓으로 열면 그 구멍은 아무 쓸모가 없다. + * 그래서 STUN 질의, 펀칭 프로브, QUIC 데이터가 전부 이 소켓 하나를 지나야 한다. + * + *

구현은 {@link DatagramSocket} 을 상속해서 수신을 가로채는 방식이다. 전용 읽기 스레드가 + * 하나 돌면서 들어온 데이터그램을 두 갈래로 나눈다. 프로브면 여기서 처리하고, 나머지는 + * 큐에 넣어 {@link #receive(DatagramPacket)} 를 호출한 쪽(Kwik)에게 넘긴다. + * 읽기 주체를 하나로 고정했기 때문에 "펀칭 루프와 Kwik 수신 스레드가 서로 패킷을 + * 훔쳐가는" 경쟁 상태가 원천적으로 생기지 않는다. + * + *

프로브 첫 바이트는 {@code 0x11} 이다. QUIC 패킷은 헤더 고정 비트(0x40)가 반드시 1 이라 + * 0x40 비트가 꺼진 첫 바이트는 어떤 유효한 QUIC 패킷과도 겹치지 않는다. 즉 우리 프로브를 + * QUIC 패킷으로 오해할 여지가 없다. + */ +public final class P2PSocket extends DatagramSocket { + + private static final byte PROBE_MAGIC = 0x11; + private static final byte PROBE_PING = 'P'; + private static final byte PROBE_PONG = 'O'; + private static final int PROBE_LENGTH = 10; // magic + kind + nonce(8) + + private static final int MAX_DATAGRAM = 2048; + private static final int QUEUE_CAPACITY = 512; + private static final int READ_TIMEOUT_MS = 200; + private static final long PROBE_INTERVAL_MS = 150L; + + /** 받은 데이터그램 한 개. Kwik 에게 넘길 때까지 들고 있는 값. */ + private record Inbound(byte[] data, InetAddress address, int port) {} + + private final BlockingQueue inbox = new ArrayBlockingQueue<>(QUEUE_CAPACITY); + private final AtomicBoolean running = new AtomicBoolean(true); + private final SecureRandom random = new SecureRandom(); + + /** 우리가 보낸 ping 의 nonce → (상대주소, 보낸 시각). pong 을 짝지으려고 둔다. */ + private final Map pending = new ConcurrentHashMap<>(); + + /** 왕복이 확인된 주소들. 먼저 확인된 것이 앞에 온다. */ + private final List validated = Collections.synchronizedList(new ArrayList<>()); + + private final Thread reader; + private volatile int virtualTimeout; + private volatile long droppedPackets; + + private record PendingProbe(InetSocketAddress target, long sentAtNanos, Candidate.Type type) {} + + /** 왕복이 확인된 경로. */ + public record Validated(Candidate candidate, long rttMillis) {} + + public P2PSocket() throws SocketException { + super(0); + super.setSoTimeout(READ_TIMEOUT_MS); + // 마인크래프트 트래픽은 순간적으로 몰린다(청크 전송). 커널 버퍼를 넉넉히 잡아 + // 수신 드롭을 줄인다. 실패해도 치명적이지 않으므로 조용히 넘어간다. + try { + super.setReceiveBufferSize(4 * 1024 * 1024); + super.setSendBufferSize(1024 * 1024); + } catch (SocketException e) { + Log.debug("소켓 버퍼 확대 실패 — 기본값으로 진행한다", e); + } + reader = new Thread(this::readLoop, "mcp2p-udp-reader"); + reader.setDaemon(true); + reader.start(); + } + + // ── 후보 수집 ──────────────────────────────────────────────────────────── + + /** + * 내 쪽 후보 목록을 만든다. + * + * @param stunServer null 이면 STUN 을 건너뛴다(같은 LAN 전용이 된다). + */ + public List gather(InetSocketAddress stunServer) { + Set out = new LinkedHashSet<>(); + int port = getLocalPort(); + for (InetAddress address : localAddresses()) { + out.add(new Candidate(Candidate.Type.HOST, new InetSocketAddress(address, port))); + } + if (stunServer != null) { + InetSocketAddress reflexive = Stun.discover(this, stunServer, 1500); + if (reflexive != null) { + out.add(new Candidate(Candidate.Type.SRFLX, reflexive)); + Log.debug("공인 주소 확인: " + reflexive); + } else { + Log.warn("STUN 응답이 없다 — 같은 LAN 안에서만 직결이 가능하다"); + } + } + return new ArrayList<>(out); + } + + // ── 홀펀칭 ────────────────────────────────────────────────────────────── + + /** + * 주어진 후보들에게 프로브를 계속 보낸다. 호스트 쪽은 경로를 고르지 않고 이것만 돌린다 — + * 상대가 들어올 수 있도록 NAT 에 구멍을 열어 두는 것이 목적이기 때문이다. + * + * @return 중지시킬 수 있는 핸들 + */ + public AutoCloseable startProbing(List targets) { + return startProbing(targets, Long.MAX_VALUE); + } + + /** + * @param durationMs 이만큼 지나면 스스로 멈춘다. 호스트가 참가자마다 프로브 스레드를 + * 띄우고 영구히 방치하면 스레드와 트래픽이 계속 쌓이기 때문이다. + * 경로가 한 번 열리면 그 뒤로는 QUIC 트래픽이 NAT 매핑을 유지한다. + */ + public AutoCloseable startProbing(List targets, long durationMs) { + AtomicBoolean active = new AtomicBoolean(true); + long deadline = durationMs == Long.MAX_VALUE ? Long.MAX_VALUE : System.currentTimeMillis() + durationMs; + Thread t = new Thread(() -> { + while (active.get() && running.get() && System.currentTimeMillis() < deadline) { + for (Candidate c : targets) { + sendPing(c); + } + sleep(PROBE_INTERVAL_MS); + } + }, "mcp2p-punch"); + t.setDaemon(true); + t.start(); + return () -> active.set(false); + } + + /** + * 후보들을 동시에 찔러 보고 쓸 경로를 하나 고른다. + * + *

중계 후보는 거의 항상 즉시 성공한다(중계 서버는 NAT 뒤에 없으니까). 그래서 아무 + * 장치 없이 "먼저 된 것" 을 고르면 직결이 가능한 상황에서도 중계로 굳어버린다. 그래서 + * 직결 후보에게 {@code directPreferenceMs} 만큼의 유예를 준다. 홀펀칭이 그 안에 되면 + * 직결을, 안 되면 중계를 쓴다. + * + *

Tailscale·iroh 처럼 "중계로 먼저 붙이고 나중에 직결로 갈아타기" 를 하면 대기 시간이 + * 0 이 되지만, 스트림을 끊김 없이 이전하는 장치가 추가로 필요하다. 유예 방식은 최악의 + * 경우 {@code directPreferenceMs} 만 기다리는 대신 구현이 훨씬 단순하다. 갈아타기는 + * docs/DESIGN.md 의 후속 과제로 남겼다. + * + * @return 고른 경로, 아무것도 확인되지 않으면 {@code null} + */ + public Validated awaitPath(List targets, long totalTimeoutMs, long directPreferenceMs) { + long start = System.currentTimeMillis(); + AutoCloseable probe = startProbing(targets, totalTimeoutMs); + try { + while (System.currentTimeMillis() - start < totalTimeoutMs) { + Validated direct = firstMatching(false); + if (direct != null) return direct; + if (System.currentTimeMillis() - start >= directPreferenceMs) { + Validated relay = firstMatching(true); + if (relay != null) return relay; + } + sleep(20); + } + Validated direct = firstMatching(false); + return direct != null ? direct : firstMatching(true); + } finally { + try { + probe.close(); + } catch (Exception e) { + Log.debug("프로브 정지 실패", e); + } + } + } + + private Validated firstMatching(boolean relay) { + synchronized (validated) { + for (Validated v : validated) { + if (v.candidate().isRelay() == relay) return v; + } + } + return null; + } + + public List validatedPaths() { + synchronized (validated) { + return new ArrayList<>(validated); + } + } + + private void sendPing(Candidate target) { + long nonce = random.nextLong(); + byte[] payload = new byte[PROBE_LENGTH]; + payload[0] = PROBE_MAGIC; + payload[1] = PROBE_PING; + writeLong(payload, 2, nonce); + pending.put(nonce, new PendingProbe(target.address(), System.nanoTime(), target.type())); + try { + super.send(new DatagramPacket(payload, payload.length, target.address())); + } catch (IOException e) { + Log.debug("프로브 송신 실패 " + target, e); + } + // 오래된 항목을 정리한다. 상대가 응답하지 않으면 계속 쌓이기만 한다. + if (pending.size() > 256) { + long cutoff = System.nanoTime() - TimeUnit.SECONDS.toNanos(10); + pending.entrySet().removeIf(e -> e.getValue().sentAtNanos() < cutoff); + } + } + + // ── 수신 ──────────────────────────────────────────────────────────────── + + private void readLoop() { + byte[] buffer = new byte[MAX_DATAGRAM]; + while (running.get()) { + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + try { + super.receive(packet); + } catch (SocketTimeoutException e) { + continue; + } catch (IOException e) { + if (running.get()) Log.debug("UDP 수신 오류", e); + continue; + } + if (handleProbe(packet)) continue; + + byte[] copy = new byte[packet.getLength()]; + System.arraycopy(packet.getData(), packet.getOffset(), copy, 0, packet.getLength()); + if (!inbox.offer(new Inbound(copy, packet.getAddress(), packet.getPort()))) { + // UDP 는 손실이 허용되는 전송이고 QUIC 이 재전송한다. 큐를 늘려 지연을 + // 키우는 것보다 버리는 쪽이 낫다. + droppedPackets++; + if (droppedPackets % 100 == 1) { + Log.debug("수신 큐가 가득 차 데이터그램을 버렸다 (누적 " + droppedPackets + ")"); + } + } + } + } + + /** @return 프로브라서 여기서 처리했으면 true */ + private boolean handleProbe(DatagramPacket packet) { + byte[] data = packet.getData(); + int offset = packet.getOffset(); + if (packet.getLength() != PROBE_LENGTH || data[offset] != PROBE_MAGIC) return false; + byte kind = data[offset + 1]; + if (kind != PROBE_PING && kind != PROBE_PONG) return false; + + long nonce = readLong(data, offset + 2); + if (kind == PROBE_PING) { + byte[] reply = new byte[PROBE_LENGTH]; + reply[0] = PROBE_MAGIC; + reply[1] = PROBE_PONG; + writeLong(reply, 2, nonce); + try { + super.send(new DatagramPacket(reply, reply.length, + new InetSocketAddress(packet.getAddress(), packet.getPort()))); + } catch (IOException e) { + Log.debug("프로브 응답 실패", e); + } + return true; + } + + PendingProbe probe = pending.remove(nonce); + if (probe == null) return true; // 우리가 보낸 적 없는 nonce — 버린다 + long rtt = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - probe.sentAtNanos()); + Candidate reached = new Candidate(probe.type(), probe.target()); + synchronized (validated) { + boolean known = validated.stream().anyMatch(v -> v.candidate().equals(reached)); + if (!known) { + validated.add(new Validated(reached, rtt)); + Log.info("경로 확인 " + reached + " (왕복 " + rtt + "ms)"); + } + } + return true; + } + + @Override + public void receive(DatagramPacket p) throws IOException { + int timeout = virtualTimeout; + long deadline = timeout <= 0 ? Long.MAX_VALUE : System.currentTimeMillis() + timeout; + Inbound in = null; + // 무기한 대기(take)를 쓰지 않는다. Kwik 수신 스레드가 여기서 영구히 잠들면 + // close() 로 깨울 방법이 없어 연결 종료가 매달린다. 짧게 끊어 기다리면서 + // 소켓이 닫혔는지 같이 확인한다. + while (in == null) { + if (!running.get()) throw new SocketException("소켓이 닫혔다"); + if (System.currentTimeMillis() >= deadline) throw new SocketTimeoutException("수신 대기 시간 초과"); + try { + in = inbox.poll(100, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new SocketException("수신 대기가 중단됐다"); + } + } + int length = Math.min(in.data().length, p.getData().length - p.getOffset()); + System.arraycopy(in.data(), 0, p.getData(), p.getOffset(), length); + p.setLength(length); + p.setAddress(in.address()); + p.setPort(in.port()); + } + + /** + * 실제 소켓 타임아웃은 읽기 스레드가 쓰고 있으므로 건드리지 않는다. Kwik 이 지정하는 + * 값은 {@link #receive(DatagramPacket)} 의 큐 대기 시간으로만 반영한다. + */ + @Override + public void setSoTimeout(int timeout) { + virtualTimeout = timeout; + } + + @Override + public int getSoTimeout() { + return virtualTimeout; + } + + @Override + public void close() { + running.set(false); + reader.interrupt(); + super.close(); + } + + // ── 유틸 ──────────────────────────────────────────────────────────────── + + static List localAddresses() { + List out = new ArrayList<>(); + try { + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nic.isUp() || nic.isLoopback()) continue; + for (InetAddress address : Collections.list(nic.getInetAddresses())) { + if (address instanceof Inet4Address && !address.isLinkLocalAddress()) { + out.add(address); + } + } + } + } catch (SocketException e) { + Log.debug("로컬 주소를 읽지 못했다", e); + } + return out; + } + + private static void writeLong(byte[] b, int off, long v) { + for (int i = 0; i < 8; i++) b[off + i] = (byte) (v >>> (56 - i * 8)); + } + + private static long readLong(byte[] b, int off) { + long v = 0; + for (int i = 0; i < 8; i++) v = (v << 8) | (b[off + i] & 0xFF); + return v; + } + + private static void sleep(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/Pipe.java b/src/main/java/kr/tkrmagid/mcp2p/net/Pipe.java new file mode 100644 index 0000000..d2a52a3 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/Pipe.java @@ -0,0 +1,58 @@ +package kr.tkrmagid.mcp2p.net; + +import kr.tkrmagid.mcp2p.Log; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; + +/** 두 바이트 스트림을 서로 이어 준다. 터널의 실제 데이터 운반은 전부 여기를 지난다. */ +final class Pipe { + + /** + * 마인크래프트 패킷은 작은 것(플레이어 이동)과 큰 것(청크)이 섞인다. 16KB 는 청크 + * 전송 때 시스템 콜 횟수를 줄이면서도 작은 패킷의 지연을 늘리지 않는 크기다. + */ + private static final int BUFFER = 16 * 1024; + + private Pipe() {} + + /** + * 한 방향으로 끝까지 복사한다. 한쪽이 닫히면 반대쪽 출력도 닫아서 상대가 EOF 를 + * 보게 한다 — 이걸 빼먹으면 마인크래프트 클라이언트가 접속 종료를 알아채지 못하고 + * 멈춘 것처럼 보인다. + */ + static void copy(InputStream in, OutputStream out, String label, Runnable onFinish) { + byte[] buffer = new byte[BUFFER]; + try { + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + // 마인크래프트는 작은 패킷의 지연에 민감하다. 버퍼가 찰 때까지 기다리지 않는다. + out.flush(); + } + } catch (IOException e) { + Log.debug("스트림 복사 종료 (" + label + ")", e); + } finally { + closeQuietly(out); + if (onFinish != null) onFinish.run(); + } + } + + /** 이름 붙은 데몬 스레드로 한 방향 복사를 띄운다. */ + static Thread pump(InputStream in, OutputStream out, String label, Runnable onFinish) { + Thread t = new Thread(() -> copy(in, out, label, onFinish), "mcp2p-pipe-" + label); + t.setDaemon(true); + t.start(); + return t; + } + + static void closeQuietly(AutoCloseable closeable) { + if (closeable == null) return; + try { + closeable.close(); + } catch (Exception e) { + Log.debug("닫기 실패", e); + } + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/SelfSignedCert.java b/src/main/java/kr/tkrmagid/mcp2p/net/SelfSignedCert.java new file mode 100644 index 0000000..32012a5 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/SelfSignedCert.java @@ -0,0 +1,176 @@ +package kr.tkrmagid.mcp2p.net; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.security.Signature; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; + +/** + * 자체 서명 인증서를 JDK 기능만으로 만든다. BouncyCastle 을 넣지 않기 위해 X.509 DER 을 + * 직접 조립한다(약 100줄). 라이브러리 하나를 아끼는 것보다 중요한 이유가 있다 — 이 모드는 + * 모든 마인크래프트 버전에서 같은 jar 로 돌아야 하고, 마인크래프트가 어떤 암호 라이브러리를 + * 클래스패스에 올려 두는지는 버전마다 다르다. JDK 안에서 끝내면 그 변수가 없어진다. + * + *

인증서의 신뢰는 CA 가 아니라 지문 고정으로 얻는다. 참가자는 시그널링으로 받은 + * SHA-256 지문과 실제 핸드셰이크에서 받은 인증서의 지문을 대조한다. WebRTC 가 DTLS 지문을 + * SDP 로 실어 보내고 대조하는 것과 같은 모델이다. + */ +public final class SelfSignedCert { + + /** 키스토어 안의 별칭. Kwik 에 그대로 넘긴다. */ + public static final String ALIAS = "mcp2p"; + + private static final char[] PASSWORD = "mcp2p".toCharArray(); + + // ecdsa-with-SHA256 (1.2.840.10045.4.3.2) + private static final byte[] OID_ECDSA_SHA256 = + {0x06, 0x08, 0x2A, (byte) 0x86, 0x48, (byte) 0xCE, 0x3D, 0x04, 0x03, 0x02}; + // commonName (2.5.4.3) + private static final byte[] OID_COMMON_NAME = {0x06, 0x03, 0x55, 0x04, 0x03}; + + private final KeyStore keyStore; + private final String fingerprint; + + private SelfSignedCert(KeyStore keyStore, String fingerprint) { + this.keyStore = keyStore; + this.fingerprint = fingerprint; + } + + public KeyStore keyStore() { + return keyStore; + } + + public char[] password() { + return PASSWORD.clone(); + } + + /** 대문자 16진수 SHA-256 지문. 시그널링으로 이 값을 보낸다. */ + public String fingerprint() { + return fingerprint; + } + + public static SelfSignedCert generate() throws Exception { + KeyPairGenerator generator = KeyPairGenerator.getInstance("EC"); + generator.initialize(new ECGenParameterSpec("secp256r1")); + KeyPair pair = generator.generateKeyPair(); + + X509Certificate certificate = build(pair); + KeyStore store = KeyStore.getInstance("PKCS12"); + store.load(null, null); + store.setKeyEntry(ALIAS, pair.getPrivate(), PASSWORD, new Certificate[]{certificate}); + return new SelfSignedCert(store, fingerprintOf(certificate)); + } + + public static String fingerprintOf(X509Certificate certificate) throws Exception { + byte[] digest = MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded()); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(Character.forDigit((b >> 4) & 0xF, 16)); + sb.append(Character.forDigit(b & 0xF, 16)); + } + return sb.toString().toUpperCase(java.util.Locale.ROOT); + } + + // ── X.509 조립 ────────────────────────────────────────────────────────── + + private static X509Certificate build(KeyPair pair) throws Exception { + byte[] algorithmId = seq(OID_ECDSA_SHA256); + byte[] name = seq(set(seq(OID_COMMON_NAME, utf8String("mcp2p")))); + + // 시계 오차에 관대하게. 어차피 신뢰는 지문 고정에서 나오므로 유효기간은 형식 요건이다. + ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC); + byte[] validity = seq(utcTime(now.minusDays(1)), utcTime(now.plusDays(30))); + + // getEncoded() 가 이미 SubjectPublicKeyInfo DER 이라 그대로 넣으면 된다. + byte[] publicKeyInfo = pair.getPublic().getEncoded(); + + byte[] tbs = seq( + tagged(0, integer(BigInteger.valueOf(2))), // v3 + integer(new BigInteger(64, new SecureRandom()).add(BigInteger.ONE)), + algorithmId, + name, + validity, + name, + publicKeyInfo + ); + + Signature signer = Signature.getInstance("SHA256withECDSA"); + signer.initSign(pair.getPrivate()); + signer.update(tbs); + byte[] signature = signer.sign(); + + byte[] certificate = seq(tbs, algorithmId, bitString(signature)); + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certificate)); + } + + // ── 최소 DER 인코더 ───────────────────────────────────────────────────── + + private static byte[] tlv(int tag, byte[] content) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + out.write(tag); + int length = content.length; + if (length < 0x80) { + out.write(length); + } else { + // 긴 형식: 길이를 담는 바이트 수를 먼저 쓴다. + byte[] lengthBytes = BigInteger.valueOf(length).toByteArray(); + int start = lengthBytes[0] == 0 ? 1 : 0; + int count = lengthBytes.length - start; + out.write(0x80 | count); + out.write(lengthBytes, start, count); + } + out.write(content, 0, content.length); + return out.toByteArray(); + } + + private static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) out.write(part, 0, part.length); + return out.toByteArray(); + } + + private static byte[] seq(byte[]... parts) { + return tlv(0x30, concat(parts)); + } + + private static byte[] set(byte[]... parts) { + return tlv(0x31, concat(parts)); + } + + /** context-specific, constructed 태그 — TBSCertificate 의 version 필드용. */ + private static byte[] tagged(int number, byte[] content) { + return tlv(0xA0 | number, content); + } + + private static byte[] integer(BigInteger value) { + return tlv(0x02, value.toByteArray()); + } + + private static byte[] utf8String(String value) { + return tlv(0x0C, value.getBytes(StandardCharsets.UTF_8)); + } + + private static byte[] utcTime(ZonedDateTime time) { + String text = time.format(DateTimeFormatter.ofPattern("yyMMddHHmmss")) + "Z"; + return tlv(0x17, text.getBytes(StandardCharsets.US_ASCII)); + } + + private static byte[] bitString(byte[] value) { + // 앞의 0x00 은 "쓰지 않는 비트 0개" 를 뜻한다. + return tlv(0x03, concat(new byte[]{0x00}, value)); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/Stun.java b/src/main/java/kr/tkrmagid/mcp2p/net/Stun.java new file mode 100644 index 0000000..2713ec3 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/Stun.java @@ -0,0 +1,146 @@ +package kr.tkrmagid.mcp2p.net; + +import kr.tkrmagid.mcp2p.Log; + +import java.io.IOException; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.security.SecureRandom; +import java.util.Arrays; + +/** + * STUN Binding 요청(RFC 5389)만 구현한다. 목적은 하나, 우리 UDP 소켓이 공인망에서 + * 어떤 주소·포트로 보이는지(server-reflexive 주소) 알아내는 것이다. + * + *

STUN 라이브러리를 쓰지 않은 이유는 필요한 게 요청 한 종류와 속성 한 개뿐이라서다. + * 인증도, 재전송 정책도, ICE 우선순위 계산도 여기서는 필요하지 않다. + */ +public final class Stun { + + private static final int BINDING_REQUEST = 0x0001; + private static final int BINDING_SUCCESS = 0x0101; + private static final int MAGIC_COOKIE = 0x2112A442; + private static final int ATTR_MAPPED_ADDRESS = 0x0001; + private static final int ATTR_XOR_MAPPED_ADDRESS = 0x0020; + private static final int HEADER_LENGTH = 20; + + private Stun() {} + + /** + * 주어진 소켓으로 STUN 서버에 물어본다. 이 소켓은 나중에 QUIC 이 그대로 쓰는 소켓이어야 + * 한다 — 다른 소켓으로 물어보면 NAT 매핑이 달라서 알아낸 주소가 쓸모없어진다. + * + * @return 공인망에서 보이는 주소, 실패하면 {@code null} + */ + public static InetSocketAddress discover(DatagramSocket socket, InetSocketAddress server, int timeoutMs) { + byte[] transactionId = new byte[12]; + new SecureRandom().nextBytes(transactionId); + + byte[] request = new byte[HEADER_LENGTH]; + writeShort(request, 0, BINDING_REQUEST); + writeShort(request, 2, 0); // 속성 없음 + writeInt(request, 4, MAGIC_COOKIE); + System.arraycopy(transactionId, 0, request, 8, 12); + + int originalTimeout; + try { + originalTimeout = socket.getSoTimeout(); + } catch (IOException e) { + return null; + } + + try { + socket.setSoTimeout(timeoutMs); + socket.send(new DatagramPacket(request, request.length, server)); + + long deadline = System.currentTimeMillis() + timeoutMs; + byte[] buffer = new byte[512]; + while (System.currentTimeMillis() < deadline) { + DatagramPacket response = new DatagramPacket(buffer, buffer.length); + socket.receive(response); + InetSocketAddress mapped = parse(response.getData(), response.getLength(), transactionId); + if (mapped != null) return mapped; + // STUN 응답이 아니면(예: 상대가 벌써 보낸 펀칭 프로브) 무시하고 계속 기다린다. + } + return null; + } catch (IOException e) { + Log.debug("STUN 질의 실패 " + server, e); + return null; + } finally { + try { + socket.setSoTimeout(originalTimeout); + } catch (IOException ignored) { + // 복원 실패는 치명적이지 않다. + } + } + } + + /** 패키지 밖에서도 테스트할 수 있게 열어 둔다. */ + static InetSocketAddress parse(byte[] data, int length, byte[] expectedTransactionId) { + if (length < HEADER_LENGTH) return null; + if (readShort(data, 0) != BINDING_SUCCESS) return null; + if (readInt(data, 4) != MAGIC_COOKIE) return null; + if (!Arrays.equals(Arrays.copyOfRange(data, 8, 20), expectedTransactionId)) return null; + + int bodyLength = readShort(data, 2); + int end = Math.min(length, HEADER_LENGTH + bodyLength); + int pos = HEADER_LENGTH; + while (pos + 4 <= end) { + int type = readShort(data, pos); + int len = readShort(data, pos + 2); + int valueStart = pos + 4; + if (valueStart + len > end) break; + + if (type == ATTR_XOR_MAPPED_ADDRESS || type == ATTR_MAPPED_ADDRESS) { + InetSocketAddress parsed = parseAddress(data, valueStart, len, type == ATTR_XOR_MAPPED_ADDRESS); + if (parsed != null) return parsed; + } + pos = valueStart + len + ((4 - (len % 4)) % 4); // 4바이트 정렬 패딩 + } + return null; + } + + private static InetSocketAddress parseAddress(byte[] data, int offset, int length, boolean xored) { + if (length < 8) return null; + int family = data[offset + 1] & 0xFF; + if (family != 0x01) return null; // IPv4 만 쓴다 + + int port = readShort(data, offset + 2); + byte[] addr = Arrays.copyOfRange(data, offset + 4, offset + 8); + if (xored) { + port ^= (MAGIC_COOKIE >>> 16) & 0xFFFF; + for (int i = 0; i < 4; i++) { + addr[i] ^= (byte) ((MAGIC_COOKIE >>> (24 - i * 8)) & 0xFF); + } + } + try { + InetAddress ip = Inet4Address.getByAddress(addr); + return new InetSocketAddress(ip, port); + } catch (IOException e) { + return null; + } + } + + private static void writeShort(byte[] b, int off, int v) { + b[off] = (byte) (v >>> 8); + b[off + 1] = (byte) v; + } + + private static void writeInt(byte[] b, int off, int v) { + b[off] = (byte) (v >>> 24); + b[off + 1] = (byte) (v >>> 16); + b[off + 2] = (byte) (v >>> 8); + b[off + 3] = (byte) v; + } + + private static int readShort(byte[] b, int off) { + return ((b[off] & 0xFF) << 8) | (b[off + 1] & 0xFF); + } + + private static int readInt(byte[] b, int off) { + return ((b[off] & 0xFF) << 24) | ((b[off + 1] & 0xFF) << 16) | ((b[off + 2] & 0xFF) << 8) | (b[off + 3] & 0xFF); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/Tunnel.java b/src/main/java/kr/tkrmagid/mcp2p/net/Tunnel.java new file mode 100644 index 0000000..1d5fc82 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/Tunnel.java @@ -0,0 +1,18 @@ +package kr.tkrmagid.mcp2p.net; + +import java.nio.charset.StandardCharsets; + +/** 터널 양쪽이 공유하는 상수. */ +public final class Tunnel { + + /** QUIC ALPN. 이 값이 다르면 핸드셰이크 단계에서 거절된다. */ + public static final String ALPN = "mcp2p/1"; + + /** 스트림 첫머리에 오는 표식. */ + public static final byte[] MAGIC = "MCP2P1".getBytes(StandardCharsets.US_ASCII); + + /** 표식 + 방 비밀. 호스트는 이걸 받아 대조한 뒤에야 TCP 를 연다. */ + public static final int HANDSHAKE_LENGTH = 6 + kr.tkrmagid.mcp2p.InviteCode.SECRET_BYTES; + + private Tunnel() {} +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/TunnelClient.java b/src/main/java/kr/tkrmagid/mcp2p/net/TunnelClient.java new file mode 100644 index 0000000..95e84de --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/TunnelClient.java @@ -0,0 +1,170 @@ +package kr.tkrmagid.mcp2p.net; + +import kr.tkrmagid.mcp2p.Log; +import tech.kwik.core.QuicClientConnection; +import tech.kwik.core.QuicConnection; +import tech.kwik.core.QuicStream; +import tech.kwik.core.log.NullLogger; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.security.cert.X509Certificate; +import java.time.Duration; +import java.util.List; + +/** + * 참가자 쪽 터널. 로컬 TCP 리스너를 열고, 들어오는 연결마다 QUIC 스트림을 만들어 호스트로 + * 넘긴다. 바닐라 클라이언트는 이 리스너를 평범한 마인크래프트 서버로 인식한다. + */ +public final class TunnelClient implements AutoCloseable { + + private final P2PSocket socket; + private final byte[] roomSecret; + private final String expectedFingerprint; + + private volatile QuicClientConnection connection; + private volatile ServerSocket listener; + private volatile Thread acceptThread; + private volatile boolean usesRelay; + private volatile long pathRttMillis = -1; + private volatile boolean running; + + public TunnelClient(P2PSocket socket, byte[] roomSecret, String expectedFingerprint) { + this.socket = socket; + this.roomSecret = roomSecret.clone(); + this.expectedFingerprint = expectedFingerprint; + } + + /** + * 고른 경로로 QUIC 핸드셰이크를 하고 로컬 리스너를 연다. + * + * @return 바닐라 클라이언트가 접속할 로컬 TCP 포트 + */ + public int connect(P2PSocket.Validated path) throws Exception { + InetSocketAddress peer = path.candidate().address(); + usesRelay = path.candidate().isRelay(); + pathRttMillis = path.rttMillis(); + + QuicClientConnection quic = QuicClientConnection.newBuilder() + .host(peer.getAddress().getHostAddress()) + .port(peer.getPort()) + .applicationProtocol(Tunnel.ALPN) + // 홀펀칭으로 구멍을 뚫어 둔 그 소켓을 그대로 써야 한다. 다른 소켓을 쓰면 + // NAT 매핑이 달라져서 펀칭이 무의미해진다(P2PSocket 주석 참고). + .socketFactory(destination -> socket) + // 자체 서명 인증서라 체인 검증은 통과할 수 없다. 대신 아래에서 지문을 + // 직접 대조한다. 검증을 "생략" 하는 게 아니라 "다른 근거로 대체" 하는 것이다. + .noServerCertificateCheck() + .connectTimeout(Duration.ofSeconds(10)) + .maxIdleTimeout(Duration.ofSeconds(60)) + .defaultStreamReceiveBufferSize(1_000_000L) + .initialRtt((int) Math.max(50, path.rttMillis())) + .logger(new NullLogger()) + .build(); + + quic.connect(); + + List chain = quic.getServerCertificateChain(); + String actual = chain.isEmpty() ? "" : SelfSignedCert.fingerprintOf(chain.get(0)); + if (!expectedFingerprint.equalsIgnoreCase(actual)) { + quic.close(); + throw new IOException("인증서 지문이 다르다 — 중간자 공격이 의심된다"); + } + connection = quic; + // 홀펀칭으로 열린 구멍은 트래픽이 없으면 NAT 가 수십 초 안에 닫는다. + quic.keepAlive(15); + + ServerSocket server = new ServerSocket(); + server.setReuseAddress(true); + // 127.0.0.1 에만 묶는다. 0.0.0.0 으로 열면 같은 LAN 의 다른 기기가 우리 초대코드 없이 + // 방에 들어올 수 있다. + server.bind(new InetSocketAddress("127.0.0.1", 0)); + listener = server; + running = true; + + Thread t = new Thread(this::acceptLoop, "mcp2p-join-accept"); + t.setDaemon(true); + acceptThread = t; + t.start(); + + Log.info("참가 터널 준비 — 로컬 포트 " + server.getLocalPort() + + ", 경로 " + path.candidate().type() + " (왕복 " + path.rttMillis() + "ms)"); + return server.getLocalPort(); + } + + public boolean usesRelay() { + return usesRelay; + } + + public long pathRttMillis() { + return pathRttMillis; + } + + public boolean isConnected() { + QuicClientConnection quic = connection; + return quic != null && quic.isConnected(); + } + + public int localPort() { + ServerSocket server = listener; + return server == null ? -1 : server.getLocalPort(); + } + + private void acceptLoop() { + ServerSocket server = listener; + while (running && server != null && !server.isClosed()) { + Socket accepted; + try { + accepted = server.accept(); + } catch (IOException e) { + if (running) Log.debug("로컬 연결 수락 실패", e); + return; + } + openStream(accepted); + } + } + + private void openStream(Socket local) { + Thread worker = new Thread(() -> { + try { + QuicConnection quic = connection; + if (quic == null) { + Pipe.closeQuietly(local); + return; + } + local.setTcpNoDelay(true); + QuicStream stream = quic.createStream(true); + OutputStream quicOut = stream.getOutputStream(); + quicOut.write(Tunnel.MAGIC); + quicOut.write(roomSecret); + quicOut.flush(); + + Pipe.pump(stream.getInputStream(), local.getOutputStream(), "quic→mc", () -> Pipe.closeQuietly(local)); + Pipe.copy(local.getInputStream(), quicOut, "mc→quic", () -> Pipe.closeQuietly(local)); + } catch (IOException e) { + Log.debug("참가 스트림 생성 실패", e); + Pipe.closeQuietly(local); + } + }, "mcp2p-join-stream"); + worker.setDaemon(true); + worker.start(); + } + + @Override + public void close() { + running = false; + Pipe.closeQuietly(listener); + listener = null; + Thread t = acceptThread; + if (t != null) t.interrupt(); + QuicClientConnection quic = connection; + connection = null; + if (quic != null) { + quic.close(); + Log.info("참가 터널 종료"); + } + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/net/TunnelHost.java b/src/main/java/kr/tkrmagid/mcp2p/net/TunnelHost.java new file mode 100644 index 0000000..16fb3ce --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/net/TunnelHost.java @@ -0,0 +1,159 @@ +package kr.tkrmagid.mcp2p.net; + +import kr.tkrmagid.mcp2p.Log; +import tech.kwik.core.QuicConnection; +import tech.kwik.core.QuicStream; +import tech.kwik.core.log.NullLogger; +import tech.kwik.core.server.ApplicationProtocolConnection; +import tech.kwik.core.server.ApplicationProtocolConnectionFactory; +import tech.kwik.core.server.ServerConnector; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 호스트 쪽 터널. 통합 서버(127.0.0.1:lanPort)를 QUIC 으로 내보낸다. + * + *

스트림 하나가 TCP 연결 하나에 대응한다. 마인크래프트 클라이언트는 서버 목록 핑과 + * 실제 접속에 각각 별도의 TCP 연결을 쓰므로 한 참가자가 여러 스트림을 열 수 있다. + */ +public final class TunnelHost implements AutoCloseable { + + private final P2PSocket socket; + private final int lanPort; + private final byte[] roomSecret; + private final SelfSignedCert certificate; + private final Set live = ConcurrentHashMap.newKeySet(); + private final AtomicInteger acceptedStreams = new AtomicInteger(); + private final AtomicInteger rejectedStreams = new AtomicInteger(); + private volatile ServerConnector server; + + public TunnelHost(P2PSocket socket, int lanPort, byte[] roomSecret, SelfSignedCert certificate) { + this.socket = socket; + this.lanPort = lanPort; + this.roomSecret = roomSecret.clone(); + this.certificate = certificate; + } + + public void start() throws Exception { + ServerConnector connector = ServerConnector.builder() + .withSocket(socket) + .withKeyStore(certificate.keyStore(), SelfSignedCert.ALIAS, certificate.password()) + .withSupportedVersion(QuicConnection.QuicVersion.V1) + .withLogger(new NullLogger()) + .build(); + connector.registerApplicationProtocol(Tunnel.ALPN, new Factory()); + connector.start(); + server = connector; + Log.info("호스트 터널 시작 — 통합 서버 127.0.0.1:" + lanPort + ", UDP 포트 " + socket.getLocalPort()); + } + + public int acceptedStreams() { + return acceptedStreams.get(); + } + + public int rejectedStreams() { + return rejectedStreams.get(); + } + + public int connectionCount() { + return live.size(); + } + + @Override + public void close() { + ServerConnector connector = server; + server = null; + if (connector != null) { + for (QuicConnection connection : live) { + try { + connection.close(); + } catch (RuntimeException e) { + Log.debug("연결 종료 실패", e); + } + } + live.clear(); + connector.close(); + Log.info("호스트 터널 종료"); + } + } + + private final class Factory implements ApplicationProtocolConnectionFactory { + + @Override + public ApplicationProtocolConnection createConnection(String protocol, QuicConnection connection) { + live.add(connection); + Log.info("참가자 연결됨 (현재 " + live.size() + "명)"); + // ApplicationProtocolConnection 의 유일한 메서드가 default 라 함수형 인터페이스가 + // 아니다 — 람다로 줄일 수 없고 익명 클래스로 재정의해야 한다. + return new ApplicationProtocolConnection() { + @Override + public void acceptPeerInitiatedStream(QuicStream stream) { + handleStream(connection, stream); + } + }; + } + + @Override + public int maxConcurrentPeerInitiatedBidirectionalStreams() { + // 핑 + 접속 + 여유. 무제한으로 두면 악의적인 상대가 스트림을 계속 열어 메모리를 먹일 수 있다. + return 16; + } + + @Override + public int maxConcurrentPeerInitiatedUnidirectionalStreams() { + return 0; + } + } + + private void handleStream(QuicConnection connection, QuicStream stream) { + Thread worker = new Thread(() -> { + Socket local = null; + try { + InputStream quicIn = stream.getInputStream(); + OutputStream quicOut = stream.getOutputStream(); + + byte[] handshake = new byte[Tunnel.HANDSHAKE_LENGTH]; + new DataInputStream(quicIn).readFully(handshake); + if (!verify(handshake)) { + rejectedStreams.incrementAndGet(); + Log.warn("스트림 거절 — 초대코드가 맞지 않는다"); + stream.resetStream(0x100); + connection.close(); + return; + } + + local = new Socket(); + // 통합 서버는 같은 기기에 있으므로 연결은 즉시 되거나 즉시 실패한다. + local.connect(new InetSocketAddress("127.0.0.1", lanPort), 3000); + local.setTcpNoDelay(true); + acceptedStreams.incrementAndGet(); + + Socket finalLocal = local; + Pipe.pump(quicIn, local.getOutputStream(), "quic→mc", () -> Pipe.closeQuietly(finalLocal)); + Pipe.copy(local.getInputStream(), quicOut, "mc→quic", () -> Pipe.closeQuietly(finalLocal)); + } catch (IOException e) { + Log.debug("호스트 스트림 처리 실패", e); + Pipe.closeQuietly(local); + } + }, "mcp2p-host-stream-" + stream.getStreamId()); + worker.setDaemon(true); + worker.start(); + } + + private boolean verify(byte[] handshake) { + byte[] magic = Arrays.copyOfRange(handshake, 0, Tunnel.MAGIC.length); + if (!Arrays.equals(magic, Tunnel.MAGIC)) return false; + byte[] secret = Arrays.copyOfRange(handshake, Tunnel.MAGIC.length, handshake.length); + // 타이밍 공격 방어. 비밀 자체가 120비트 난수라 실질적인 위험은 낮지만 비용이 0 이다. + return java.security.MessageDigest.isEqual(secret, roomSecret); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/rendezvous/Protocol.java b/src/main/java/kr/tkrmagid/mcp2p/rendezvous/Protocol.java new file mode 100644 index 0000000..5d3a0b3 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/rendezvous/Protocol.java @@ -0,0 +1,42 @@ +package kr.tkrmagid.mcp2p.rendezvous; + +/** + * 시그널링 프로토콜. 줄 단위 텍스트다. + * + *

WebSocket 도 JSON 도 쓰지 않는다. 주고받는 메시지가 네 종류뿐이고 토큰에 공백이 + * 들어갈 일이 없어서, 평범한 TCP 위의 줄 기반 텍스트로 충분하다. 의존성이 0 이 되고 + * 서버 구현도 200줄 아래로 떨어진다. + * + *

+ * 호스트: HOST <방ID> <인증서지문> <후보목록>
+ * 서버  : OK
+ * 서버  : PEER <참가자후보목록> <중계포트>      (참가자가 올 때마다 밀어 준다)
+ *
+ * 참가자: JOIN <방ID> <후보목록>
+ * 서버  : HOST <인증서지문> <후보목록> <중계포트>
+ * 서버  : ERR <사유>
+ *
+ * 양쪽  : PING / PONG
+ * 
+ * + *

방 ID 는 초대코드에서 단방향으로 파생된 값이다. 서버는 초대코드를 알 수 없다 + * ({@link kr.tkrmagid.mcp2p.InviteCode} 주석 참고). + */ +public final class Protocol { + + public static final String HOST = "HOST"; + public static final String JOIN = "JOIN"; + public static final String PEER = "PEER"; + public static final String OK = "OK"; + public static final String ERR = "ERR"; + public static final String PING = "PING"; + public static final String PONG = "PONG"; + + /** 한 줄 최대 길이. 서버가 악의적인 무한 줄에 메모리를 쓰지 않게 하는 상한이다. */ + public static final int MAX_LINE = 4096; + + /** 중계 포트가 배정되지 않았음을 뜻하는 값. */ + public static final int NO_RELAY = 0; + + private Protocol() {} +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousClient.java b/src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousClient.java new file mode 100644 index 0000000..c7ee9fa --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousClient.java @@ -0,0 +1,184 @@ +package kr.tkrmagid.mcp2p.rendezvous; + +import kr.tkrmagid.mcp2p.Log; +import kr.tkrmagid.mcp2p.net.Candidate; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +/** 시그널링 서버와의 연결. 호스트와 참가자가 같은 클래스를 쓴다. */ +public final class RendezvousClient implements AutoCloseable { + + /** 참가자가 서버에게서 받는 호스트 정보. */ + public record HostInfo(String fingerprint, List candidates, int relayPort) {} + + /** 호스트가 받는 참가자 정보. */ + public record PeerInfo(List candidates, int relayPort) {} + + private final String host; + private final int port; + private final Socket socket; + private final BufferedReader in; + private final Writer out; + private final BlockingQueue replies = new ArrayBlockingQueue<>(16); + private volatile Consumer peerListener; + private volatile boolean running = true; + + public RendezvousClient(String host, int port, int connectTimeoutMs) throws IOException { + this.host = host; + this.port = port; + this.socket = new Socket(); + socket.connect(new InetSocketAddress(host, port), connectTimeoutMs); + socket.setTcpNoDelay(true); + this.in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + this.out = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8); + + Thread reader = new Thread(this::readLoop, "mcp2p-signal-read"); + reader.setDaemon(true); + reader.start(); + + Thread keepalive = new Thread(this::keepaliveLoop, "mcp2p-signal-keepalive"); + keepalive.setDaemon(true); + keepalive.start(); + } + + /** 중계 포트를 실제 주소로 바꿔 준다. 서버는 포트만 알려 주고 IP 는 우리가 이미 안다. */ + public InetSocketAddress relayAddress(int relayPort) { + return new InetSocketAddress(host, relayPort); + } + + /** + * 방을 등록한다. 참가자가 올 때마다 {@code onPeer} 가 호출된다 — 호스트는 그 후보들을 + * 향해 프로브를 쏘아 NAT 에 구멍을 열어야 한다. + */ + public void registerHost(String roomId, String fingerprint, List candidates, + Consumer onPeer) throws IOException { + this.peerListener = onPeer; + send(Protocol.HOST + " " + roomId + " " + fingerprint + " " + Candidate.encodeList(candidates)); + String reply = awaitReply(10); + if (reply == null) throw new IOException("시그널링 서버가 응답하지 않는다"); + String[] parts = reply.split(" ", 2); + if (!Protocol.OK.equals(parts[0])) { + throw new IOException("방 등록 거절: " + (parts.length > 1 ? parts[1] : reply)); + } + Log.info("방 등록 완료 — 방 ID " + roomId); + } + + /** 방에 참가한다. 호스트의 지문·후보·우리 쪽 중계 포트를 받아 온다. */ + public HostInfo join(String roomId, List candidates) throws IOException { + send(Protocol.JOIN + " " + roomId + " " + Candidate.encodeList(candidates)); + String reply = awaitReply(10); + if (reply == null) throw new IOException("시그널링 서버가 응답하지 않는다"); + String[] parts = reply.split(" "); + if (Protocol.ERR.equals(parts[0])) { + String reason = parts.length > 1 ? parts[1] : "unknown"; + throw new IOException("no-room".equals(reason) + ? "그런 방이 없다 — 초대코드를 다시 확인하거나 호스트가 방을 열었는지 확인해라" + : "참가 거절: " + reason); + } + if (!Protocol.HOST.equals(parts[0]) || parts.length < 4) { + throw new IOException("시그널링 응답 형식 오류: " + reply); + } + return new HostInfo(parts[1], Candidate.decodeList(parts[2]), parseInt(parts[3])); + } + + private void readLoop() { + try { + String line; + while (running && (line = in.readLine()) != null) { + if (line.length() > Protocol.MAX_LINE) continue; + if (Protocol.PONG.equals(line)) continue; + if (line.startsWith(Protocol.PEER + " ")) { + String[] parts = line.split(" "); + Consumer listener = peerListener; + if (listener != null && parts.length >= 3) { + PeerInfo info = new PeerInfo(Candidate.decodeList(parts[1]), parseInt(parts[2])); + Log.info("참가자 접속 시도 감지 — 후보 " + info.candidates().size() + + "개, 중계 포트 " + info.relayPort()); + try { + listener.accept(info); + } catch (RuntimeException e) { + Log.warn("참가자 처리 중 오류", e); + } + } + continue; + } + // 요청에 대한 응답은 큐로. 가득 차면 오래된 것을 버린다. + if (!replies.offer(line)) { + replies.poll(); + replies.offer(line); + } + } + } catch (IOException e) { + if (running) Log.debug("시그널링 연결이 끊어졌다", e); + } finally { + running = false; + } + } + + private void keepaliveLoop() { + while (running) { + try { + Thread.sleep(30_000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (!running) return; + try { + send(Protocol.PING); + } catch (IOException e) { + Log.debug("시그널링 keepalive 실패", e); + return; + } + } + } + + private synchronized void send(String line) throws IOException { + out.write(line); + out.write('\n'); + out.flush(); + } + + private String awaitReply(int timeoutSeconds) { + try { + return replies.poll(timeoutSeconds, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return null; + } + } + + public boolean isOpen() { + return running && !socket.isClosed(); + } + + private static int parseInt(String text) { + try { + return Integer.parseInt(text); + } catch (NumberFormatException e) { + return Protocol.NO_RELAY; + } + } + + @Override + public void close() { + running = false; + try { + socket.close(); + } catch (IOException e) { + Log.debug("시그널링 소켓 닫기 실패", e); + } + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousServer.java b/src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousServer.java new file mode 100644 index 0000000..46cea92 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousServer.java @@ -0,0 +1,390 @@ +package kr.tkrmagid.mcp2p.rendezvous; + +import kr.tkrmagid.mcp2p.Log; + +import java.io.BufferedReader; +import java.io.Closeable; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 자체 호스팅용 시그널링 + 중계 서버. + * + *

하는 일이 두 가지뿐이다. + *

    + *
  1. 시그널링 — 방 ID 로 호스트와 참가자가 서로의 후보 주소와 인증서 지문을 교환한다.
  2. + *
  3. UDP 중계 — 홀펀칭이 실패했을 때를 위한 보험. 방마다가 아니라 + * 참가 세션마다 포트 한 쌍을 배정해서 여러 참가자가 섞이지 않게 한다.
  4. + *
+ * + *

중계는 TURN 처럼 UDP 데이터그램을 그대로 넘긴다. 그래서 직결 경로와 중계 경로에서 + * 터널 코드가 완전히 동일하다 — 목적지 주소만 다르다. 그리고 QUIC 의 TLS 1.3 이 종단간 + * 이므로 중계 서버는 지나가는 마인크래프트 트래픽을 읽을 수 없다. + * + *

실행: + *

java -jar mc_p2p_mod-<버전>-rendezvous.jar [신호포트] [중계포트시작-중계포트끝]
+ */ +public final class RendezvousServer implements Closeable { + + public static final int DEFAULT_PORT = 25580; + + private static final int MAX_ROOMS = 1000; + private static final int MAX_RELAYS = 200; + private static final int CLIENT_IDLE_TIMEOUT_MS = 120_000; + private static final long RELAY_IDLE_TIMEOUT_MS = 120_000L; + private static final int RELAY_BUFFER = 2048; + + private record Room(String fingerprint, String candidates, ClientHandler host) {} + + private final Map rooms = new ConcurrentHashMap<>(); + private final AtomicInteger relayCount = new AtomicInteger(); + private final ScheduledExecutorService janitor = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "mcp2p-relay-janitor"); + t.setDaemon(true); + return t; + }); + private final ServerSocket listener; + private final int relayPortFrom; + private final int relayPortTo; + private volatile boolean running = true; + + public RendezvousServer(int port, int relayPortFrom, int relayPortTo) throws IOException { + this.relayPortFrom = relayPortFrom; + this.relayPortTo = relayPortTo; + this.listener = new ServerSocket(); + this.listener.setReuseAddress(true); + this.listener.bind(new InetSocketAddress(port)); + } + + public int port() { + return listener.getLocalPort(); + } + + public int roomCount() { + return rooms.size(); + } + + public void serveForever() { + Log.info("시그널링 서버 시작 — TCP " + port() + + (relayPortFrom > 0 ? ", 중계 UDP " + relayPortFrom + "-" + relayPortTo : ", 중계 UDP 임의 포트")); + while (running) { + Socket client; + try { + client = listener.accept(); + } catch (IOException e) { + if (running) Log.warn("연결 수락 실패", e); + return; + } + try { + ClientHandler handler = new ClientHandler(client); + Thread t = new Thread(handler, "mcp2p-signal-client"); + t.setDaemon(true); + t.start(); + } catch (IOException e) { + // 연결이 붙자마자 끊긴 경우다. 서버 전체를 멈출 이유는 없다. + Log.debug("클라이언트 초기화 실패", e); + try { + client.close(); + } catch (IOException ignored) { + // 이미 닫혔다. + } + } + } + } + + @Override + public void close() { + running = false; + janitor.shutdownNow(); + try { + listener.close(); + } catch (IOException e) { + Log.debug("리스너 닫기 실패", e); + } + } + + // ── 클라이언트 한 명 ──────────────────────────────────────────────────── + + private final class ClientHandler implements Runnable { + + private final Socket socket; + private final Writer out; + private volatile String ownedRoom; + + ClientHandler(Socket socket) throws IOException { + this.socket = socket; + this.socket.setTcpNoDelay(true); + this.socket.setSoTimeout(CLIENT_IDLE_TIMEOUT_MS); + this.out = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8); + } + + synchronized void send(String line) { + try { + out.write(line); + out.write('\n'); + out.flush(); + } catch (IOException e) { + Log.debug("응답 전송 실패", e); + } + } + + @Override + public void run() { + try (BufferedReader in = new BufferedReader( + new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while (running && (line = in.readLine()) != null) { + if (line.length() > Protocol.MAX_LINE) { + send(Protocol.ERR + " line-too-long"); + return; + } + if (!handle(line.trim())) return; + } + } catch (SocketTimeoutException e) { + Log.debug("클라이언트 유휴 시간 초과"); + } catch (IOException e) { + Log.debug("클라이언트 연결 오류", e); + } finally { + String room = ownedRoom; + if (room != null) { + rooms.remove(room); + Log.info("방 해제 — " + room + " (남은 방 " + rooms.size() + "개)"); + } + try { + socket.close(); + } catch (IOException ignored) { + // 이미 닫혔을 수 있다. + } + } + } + + /** @return 연결을 계속 유지할지 여부 */ + private boolean handle(String line) { + if (line.isEmpty()) return true; + String[] parts = line.split(" "); + String command = parts[0].toUpperCase(Locale.ROOT); + + switch (command) { + case Protocol.PING -> send(Protocol.PONG); + + case Protocol.HOST -> { + if (parts.length < 4) { + send(Protocol.ERR + " bad-request"); + return false; + } + if (rooms.size() >= MAX_ROOMS) { + send(Protocol.ERR + " server-full"); + return false; + } + String roomId = parts[1]; + if (!isValidRoomId(roomId)) { + send(Protocol.ERR + " bad-room-id"); + return false; + } + Room room = new Room(parts[2], parts[3], this); + if (rooms.putIfAbsent(roomId, room) != null) { + send(Protocol.ERR + " room-taken"); + return false; + } + ownedRoom = roomId; + send(Protocol.OK); + Log.info("방 등록 — " + roomId + " (총 " + rooms.size() + "개)"); + } + + case Protocol.JOIN -> { + if (parts.length < 3) { + send(Protocol.ERR + " bad-request"); + return false; + } + Room room = rooms.get(parts[1]); + if (room == null) { + send(Protocol.ERR + " no-room"); + return true; // 코드를 고쳐 다시 시도할 수 있게 연결은 유지한다. + } + RelayPair relay = allocateRelay(); + int hostSide = relay == null ? Protocol.NO_RELAY : relay.hostPort(); + int joinSide = relay == null ? Protocol.NO_RELAY : relay.joinPort(); + send(Protocol.HOST + " " + room.fingerprint() + " " + room.candidates() + " " + joinSide); + room.host().send(Protocol.PEER + " " + parts[2] + " " + hostSide); + } + + default -> { + send(Protocol.ERR + " unknown-command"); + return false; + } + } + return true; + } + } + + private static boolean isValidRoomId(String roomId) { + if (roomId.length() < 4 || roomId.length() > 32) return false; + for (char c : roomId.toCharArray()) { + if (!Character.isLetterOrDigit(c)) return false; + } + return true; + } + + // ── UDP 중계 ──────────────────────────────────────────────────────────── + + private RelayPair allocateRelay() { + if (relayCount.get() >= MAX_RELAYS) { + Log.warn("중계 슬롯이 가득 찼다 — 이 참가자는 직결만 시도한다"); + return null; + } + try { + DatagramSocket hostSide = bindRelaySocket(); + DatagramSocket joinSide = bindRelaySocket(); + RelayPair pair = new RelayPair(hostSide, joinSide); + relayCount.incrementAndGet(); + pair.start(); + // 유휴 중계를 걷어낸다. 자기 자신의 스케줄을 취소해야 하므로 배열로 참조를 넘긴다. + final java.util.concurrent.ScheduledFuture[] handle = new java.util.concurrent.ScheduledFuture[1]; + handle[0] = janitor.scheduleAtFixedRate(() -> { + if (!pair.isIdle()) return; + pair.close(); + relayCount.decrementAndGet(); + Log.debug("유휴 중계 회수 — 포트 " + pair.hostPort() + "/" + pair.joinPort()); + if (handle[0] != null) handle[0].cancel(false); + }, RELAY_IDLE_TIMEOUT_MS, RELAY_IDLE_TIMEOUT_MS / 2, TimeUnit.MILLISECONDS); + return pair; + } catch (IOException e) { + Log.warn("중계 포트 배정 실패", e); + return null; + } + } + + /** + * 중계 포트를 범위 안에서 잡는다. 범위를 지정할 수 있게 한 이유는 방화벽이다 — + * 임의 포트를 쓰면 UDP 를 전 범위 개방해야 하지만, 범위를 고정하면 그만큼만 열면 된다. + */ + private DatagramSocket bindRelaySocket() throws IOException { + if (relayPortFrom <= 0) return new DatagramSocket(); + for (int port = relayPortFrom; port <= relayPortTo; port++) { + try { + return new DatagramSocket(port); + } catch (IOException e) { + // 이미 쓰이는 포트다. 다음 것을 본다. + } + } + throw new IOException("지정 범위(" + relayPortFrom + "-" + relayPortTo + ")에 빈 UDP 포트가 없다"); + } + + /** + * 데이터그램을 양방향으로 넘기는 포트 한 쌍. + * + *

각 방향의 상대 주소는 "그 포트로 마지막에 들어온 패킷의 발신지" 로 학습한다. + * 호스트가 keepalive 프로브를 계속 보내므로 참가자의 첫 패킷이 도착하기 전에 + * 호스트 주소가 먼저 학습된다. + */ + private static final class RelayPair implements Closeable { + + private final DatagramSocket hostSide; + private final DatagramSocket joinSide; + private volatile InetSocketAddress hostPeer; + private volatile InetSocketAddress joinPeer; + private volatile long lastActivity = System.currentTimeMillis(); + private volatile boolean open = true; + + RelayPair(DatagramSocket hostSide, DatagramSocket joinSide) throws IOException { + this.hostSide = hostSide; + this.joinSide = joinSide; + hostSide.setSoTimeout(1000); + joinSide.setSoTimeout(1000); + } + + int hostPort() { + return hostSide.getLocalPort(); + } + + int joinPort() { + return joinSide.getLocalPort(); + } + + void start() { + spawn(hostSide, true); + spawn(joinSide, false); + } + + private void spawn(DatagramSocket from, boolean fromHost) { + Thread t = new Thread(() -> { + byte[] buffer = new byte[RELAY_BUFFER]; + while (open) { + DatagramPacket packet = new DatagramPacket(buffer, buffer.length); + try { + from.receive(packet); + } catch (SocketTimeoutException e) { + continue; + } catch (IOException e) { + return; + } + lastActivity = System.currentTimeMillis(); + InetSocketAddress source = new InetSocketAddress(packet.getAddress(), packet.getPort()); + DatagramSocket to; + InetSocketAddress target; + if (fromHost) { + hostPeer = source; + to = joinSide; + target = joinPeer; + } else { + joinPeer = source; + to = hostSide; + target = hostPeer; + } + if (target == null) continue; // 반대쪽을 아직 모른다 — 버린다 + try { + to.send(new DatagramPacket(packet.getData(), packet.getOffset(), + packet.getLength(), target)); + } catch (IOException e) { + Log.debug("중계 전달 실패", e); + } + } + }, "mcp2p-relay-" + (fromHost ? "host" : "join") + "-" + from.getLocalPort()); + t.setDaemon(true); + t.start(); + } + + boolean isIdle() { + return System.currentTimeMillis() - lastActivity > RELAY_IDLE_TIMEOUT_MS; + } + + @Override + public void close() { + open = false; + hostSide.close(); + joinSide.close(); + } + } + + // ── 실행 진입점 ───────────────────────────────────────────────────────── + + public static void main(String[] args) throws IOException { + int port = args.length > 0 ? Integer.parseInt(args[0]) : DEFAULT_PORT; + int from = 0; + int to = 0; + if (args.length > 1 && args[1].contains("-")) { + String[] range = args[1].split("-", 2); + from = Integer.parseInt(range[0]); + to = Integer.parseInt(range[1]); + } + RendezvousServer server = new RendezvousServer(port, from, to); + Runtime.getRuntime().addShutdownHook(new Thread(server::close)); + server.serveForever(); + } +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/ui/Page.java b/src/main/java/kr/tkrmagid/mcp2p/ui/Page.java new file mode 100644 index 0000000..354c4ba --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/ui/Page.java @@ -0,0 +1,155 @@ +package kr.tkrmagid.mcp2p.ui; + +/** 웹 콘솔 페이지. 외부 리소스를 하나도 불러오지 않는 단일 HTML 이다(오프라인에서도 열린다). */ +final class Page { + + private Page() {} + + static final String HTML = """ + + + + + + mc_p2p 콘솔 + + +

+

mc_p2p 콘솔

+

게임을 재시작하지 않아도 이 페이지에서 바로 바뀝니다.

+ +
+

시그널링 서버

+ + +
직접 띄운 서버 주소입니다. 배포 방법은 저장소 README 를 보세요.
+
+ +
+

방 만들기 (호스트)

+
+ + +
+
준비를 누른 뒤 게임에서 일시정지 → LAN 에 공개를 누르면 방이 열립니다.
+ +
+ +
+

방 들어가기 (참가자)

+ +
+ + +
+
참가되면 게임의 멀티플레이 화면 LAN 목록에 방이 나타납니다. 그걸 누르면 접속됩니다.
+
+ +
+

상태

+
+
시그널링
-
+
STUN
-
+
호스트
-
+
참가자 수
-
+
참가 상태
-
+
+
+
+ + + """; +} diff --git a/src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java b/src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java new file mode 100644 index 0000000..f4626f3 --- /dev/null +++ b/src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java @@ -0,0 +1,262 @@ +package kr.tkrmagid.mcp2p.ui; + +import kr.tkrmagid.mcp2p.Log; +import kr.tkrmagid.mcp2p.Session; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; + +/** + * 조작용 웹 콘솔. {@code http://127.0.0.1:<포트>} 로 접속해서 초대코드를 넣고 방을 만든다. + * + *

왜 마인크래프트 GUI 를 만들지 않았는가. 화면 클래스는 마인크래프트 버전마다 + * 이름과 시그니처가 바뀌는 대표적인 부분이다. 참고로 살펴본 기존 모드는 GUI 한 파일에 + * 버전 분기 주석이 96개 들어가 있었다. 조작 UI 를 게임 밖으로 빼면 그 유지보수 비용이 + * 통째로 사라지고, jar 하나로 모든 버전을 지원한다는 목표와도 맞는다. + * + *

HTTP 서버를 직접 구현한 이유는 {@code com.sun.net.httpserver} 가 {@code jdk.httpserver} + * 모듈에 있어서 마인크래프트가 들고 다니는 런타임에 항상 포함된다고 보장할 수 없기 때문이다. + * 필요한 것이 GET 몇 개와 POST 몇 개뿐이라 직접 쓰는 편이 안전하다. + * + *

127.0.0.1 에만 바인딩한다. 이 콘솔은 방을 열고 닫을 수 있으므로 외부에 노출되면 안 된다. + */ +public final class WebConsole implements AutoCloseable { + + private static final int MAX_REQUEST_LINE = 8192; + private static final int MAX_BODY = 8192; + + private final Session session; + private final ServerSocket listener; + private volatile boolean running = true; + + public WebConsole(Session session, int port) throws IOException { + this.session = session; + this.listener = new ServerSocket(); + listener.setReuseAddress(true); + listener.bind(new InetSocketAddress("127.0.0.1", port)); + + Thread t = new Thread(this::acceptLoop, "mcp2p-web"); + t.setDaemon(true); + t.start(); + } + + public int port() { + return listener.getLocalPort(); + } + + public String url() { + return "http://127.0.0.1:" + port(); + } + + private void acceptLoop() { + while (running) { + try { + Socket client = listener.accept(); + Thread t = new Thread(() -> handle(client), "mcp2p-web-req"); + t.setDaemon(true); + t.start(); + } catch (IOException e) { + if (running) Log.debug("웹 콘솔 수락 실패", e); + return; + } + } + } + + private void handle(Socket client) { + try (Socket socket = client) { + socket.setSoTimeout(10_000); + InputStream in = new BufferedInputStream(socket.getInputStream()); + OutputStream out = socket.getOutputStream(); + + String requestLine = readLine(in); + if (requestLine == null || requestLine.isEmpty()) return; + String[] parts = requestLine.split(" "); + if (parts.length < 2) { + respond(out, 400, "text/plain; charset=utf-8", "bad request".getBytes(StandardCharsets.UTF_8)); + return; + } + String method = parts[0]; + String path = parts[1]; + + int contentLength = 0; + String line; + while ((line = readLine(in)) != null && !line.isEmpty()) { + int colon = line.indexOf(':'); + if (colon > 0 && line.substring(0, colon).trim().equalsIgnoreCase("Content-Length")) { + try { + contentLength = Math.min(MAX_BODY, Integer.parseInt(line.substring(colon + 1).trim())); + } catch (NumberFormatException ignored) { + contentLength = 0; + } + } + } + byte[] body = new byte[contentLength]; + int read = 0; + while (read < contentLength) { + int n = in.read(body, read, contentLength - read); + if (n < 0) break; + read += n; + } + + route(out, method, path, new String(body, 0, read, StandardCharsets.UTF_8)); + } catch (IOException e) { + Log.debug("웹 요청 처리 실패", e); + } + } + + private void route(OutputStream out, String method, String path, String body) throws IOException { + if ("GET".equals(method) && ("/".equals(path) || "/index.html".equals(path))) { + respond(out, 200, "text/html; charset=utf-8", Page.HTML.getBytes(StandardCharsets.UTF_8)); + return; + } + if ("GET".equals(method) && "/api/status".equals(path)) { + json(out, 200, status()); + return; + } + if (!"POST".equals(method)) { + json(out, 404, "{\"ok\":false,\"error\":\"not found\"}"); + return; + } + + Map form = parseForm(body); + try { + switch (path) { + case "/api/config" -> { + String rendezvous = form.getOrDefault("rendezvous", "").trim(); + if (rendezvous.isEmpty()) throw new IllegalArgumentException("시그널링 주소가 비어 있다"); + session.config().setRendezvous(rendezvous); + json(out, 200, "{\"ok\":true}"); + } + case "/api/host/arm" -> { + session.armHost(); + json(out, 200, "{\"ok\":true}"); + } + case "/api/host/disarm" -> { + session.disarmHost(); + json(out, 200, "{\"ok\":true}"); + } + case "/api/join" -> { + int port = session.join(form.getOrDefault("code", "")); + json(out, 200, "{\"ok\":true,\"localPort\":" + port + "}"); + } + case "/api/leave" -> { + session.leave(); + json(out, 200, "{\"ok\":true}"); + } + default -> json(out, 404, "{\"ok\":false,\"error\":\"not found\"}"); + } + } catch (Exception e) { + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + Log.warn("웹 콘솔 요청 실패: " + path, e); + json(out, 200, "{\"ok\":false,\"error\":\"" + escape(message) + "\"}"); + } + } + + private String status() { + StringBuilder sb = new StringBuilder(256); + sb.append('{'); + sb.append("\"configured\":").append(session.config().hasRendezvous()); + sb.append(",\"rendezvous\":\"").append(escape(session.config().rendezvous())).append('"'); + sb.append(",\"stun\":\"").append(escape(String.valueOf(session.stunServer()))).append('"'); + sb.append(",\"hostArmed\":").append(session.isHostArmed()); + sb.append(",\"inviteCode\":\"").append(escape(String.valueOf(session.hostInviteCode()))).append('"'); + sb.append(",\"players\":").append(session.hostPlayerCount()); + sb.append(",\"joined\":").append(session.isJoined()); + sb.append(",\"joinRoom\":\"").append(escape(String.valueOf(session.joinRoomId()))).append('"'); + sb.append(",\"joinRelay\":\"").append(escape(String.valueOf(session.joinUsesRelay()))).append('"'); + sb.append(",\"joinRtt\":").append(session.joinRttMillis()); + sb.append(",\"joinError\":\"").append(escape(String.valueOf(session.joinError()))).append('"'); + sb.append('}'); + return sb.toString(); + } + + // ── HTTP 최소 구현 ────────────────────────────────────────────────────── + + private static String readLine(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(128); + int c; + while ((c = in.read()) != -1) { + if (c == '\n') break; + if (c != '\r') buffer.write(c); + if (buffer.size() > MAX_REQUEST_LINE) throw new IOException("요청 줄이 너무 길다"); + } + if (c == -1 && buffer.size() == 0) return null; + return buffer.toString(StandardCharsets.UTF_8); + } + + private static void json(OutputStream out, int code, String payload) throws IOException { + respond(out, code, "application/json; charset=utf-8", payload.getBytes(StandardCharsets.UTF_8)); + } + + private static void respond(OutputStream out, int code, String contentType, byte[] payload) throws IOException { + String head = "HTTP/1.1 " + code + " " + reason(code) + "\r\n" + + "Content-Type: " + contentType + "\r\n" + + "Content-Length: " + payload.length + "\r\n" + + "Cache-Control: no-store\r\n" + + "Connection: close\r\n\r\n"; + out.write(head.getBytes(StandardCharsets.US_ASCII)); + out.write(payload); + out.flush(); + } + + private static String reason(int code) { + return switch (code) { + case 200 -> "OK"; + case 400 -> "Bad Request"; + case 404 -> "Not Found"; + default -> "Error"; + }; + } + + static Map parseForm(String body) { + Map out = new HashMap<>(); + if (body == null || body.isBlank()) return out; + for (String pair : body.split("&")) { + int eq = pair.indexOf('='); + if (eq < 0) continue; + String key = URLDecoder.decode(pair.substring(0, eq), StandardCharsets.UTF_8); + String value = URLDecoder.decode(pair.substring(eq + 1), StandardCharsets.UTF_8); + out.put(key, value); + } + return out; + } + + static String escape(String text) { + if (text == null) return ""; + StringBuilder sb = new StringBuilder(text.length() + 8); + for (char c : text.toCharArray()) { + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) sb.append(String.format("\\u%04x", (int) c)); + else sb.append(c); + } + } + } + return sb.toString(); + } + + @Override + public void close() { + running = false; + try { + listener.close(); + } catch (IOException e) { + Log.debug("웹 콘솔 닫기 실패", e); + } + } +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..2673bcf --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "id": "mc_p2p", + "version": "${version}", + "name": "mc_p2p_mod", + "description": "초대코드 하나로 싱글 월드를 친구와 함께 플레이하는 P2P 모드. 마인크래프트 클래스를 참조하지 않아 모든 버전에서 같은 jar 하나로 동작한다.", + "authors": ["tkrmagid"], + "contact": { + "sources": "https://git.tkrmagid.kr/tkrmagid/mc_p2p_mod" + }, + "license": "MIT", + "environment": "client", + "entrypoints": { + "client": ["kr.tkrmagid.mcp2p.P2PMod"] + }, + "depends": { + "fabricloader": ">=0.14.0" + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java b/src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java new file mode 100644 index 0000000..b85f192 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java @@ -0,0 +1,124 @@ +package kr.tkrmagid.mcp2p; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * "jar 하나로 모든 마인크래프트 버전" 이라는 이 프로젝트의 유일한 구조적 약속을 강제하는 테스트. + * + *

약속이 깨지는 방식은 단 하나다 — 누군가 마인크래프트 클래스를 참조하는 것. 그 순간 + * 1.21.x(난독화 + intermediary 리매핑)와 26.x(비난독화, intermediary 폐지) 양쪽에서 동시에 + * 동작하는 것이 불가능해지고, 버전별 빌드로 되돌아가야 한다. + * + *

컴파일된 클래스 파일의 상수 풀을 직접 뒤진다. 상수 풀에는 참조하는 모든 클래스 이름이 + * UTF-8 문자열로 들어 있으므로, {@code net/minecraft} 가 한 번도 나오지 않는다면 마인크래프트 + * 타입을 참조하지 않는다는 뜻이다. import 문을 grep 하는 것과 달리 리플렉션이 아닌 실제 + * 바이트코드 수준의 보증이다. + */ +class ArchitectureTest { + + /** 로더 API 를 참조해도 되는 유일한 클래스들. */ + private static final List LOADER_ALLOWED = List.of("P2PMod.class", "Config.class"); + + @Test + void 마인크래프트_클래스를_단_하나도_참조하지_않는다() throws IOException { + List offenders = new ArrayList<>(); + for (Path classFile : compiledClasses()) { + if (contains(classFile, "net/minecraft")) { + offenders.add(classFile.getFileName().toString()); + } + } + if (!offenders.isEmpty()) { + fail("마인크래프트 클래스를 참조하는 파일이 있다 — 단일 jar 다중 버전 지원이 깨진다: " + offenders); + } + } + + @Test + void 모드로더_API는_진입점에서만_쓴다() throws IOException { + List offenders = new ArrayList<>(); + for (Path classFile : compiledClasses()) { + String name = classFile.getFileName().toString(); + if (LOADER_ALLOWED.contains(name)) continue; + if (contains(classFile, "net/fabricmc")) offenders.add(name); + } + assertTrue(offenders.isEmpty(), + "로더 API 참조가 진입점 밖으로 번졌다(다른 로더로 옮기기 어려워진다): " + offenders); + } + + @Test + void 중계_서버는_로더나_게임에_의존하지_않는다() throws IOException { + // 시그널링 서버는 게임 없이 VPS 에서 단독 실행돼야 한다. + for (Path classFile : compiledClasses()) { + if (!classFile.toString().contains("rendezvous")) continue; + assertTrue(!contains(classFile, "net/fabricmc") && !contains(classFile, "net/minecraft"), + classFile.getFileName() + " 가 게임/로더에 의존한다"); + } + } + + @Test + void 클래스_파일은_자바_17_대상이다() throws IOException { + // major 61 = Java 17. 더 높으면 구버전 마인크래프트의 JVM 에서 로드되지 않는다. + for (Path classFile : compiledClasses()) { + byte[] bytes = Files.readAllBytes(classFile); + int major = ((bytes[6] & 0xFF) << 8) | (bytes[7] & 0xFF); + assertEquals(61, major, classFile.getFileName() + " 의 클래스 파일 버전이 17 이 아니다"); + } + } + + @Test + void 컴파일된_클래스를_찾을_수_있다() throws IOException { + // 위 테스트들이 "파일이 0개라 통과" 하는 위약을 막는 안전장치. + assertTrue(compiledClasses().size() >= 15, "검사한 클래스 수가 너무 적다 — 경로 탐색이 잘못됐다"); + } + + private static boolean contains(Path classFile, String needle) throws IOException { + byte[] haystack = Files.readAllBytes(classFile); + byte[] pattern = needle.getBytes(StandardCharsets.US_ASCII); + outer: + for (int i = 0; i <= haystack.length - pattern.length; i++) { + for (int j = 0; j < pattern.length; j++) { + if (haystack[i + j] != pattern[j]) continue outer; + } + return true; + } + return false; + } + + private static List compiledClasses() throws IOException { + Path root = classesRoot(); + try (Stream walk = Files.walk(root)) { + return walk.filter(p -> p.toString().endsWith(".class")).toList(); + } + } + + /** + * 우리 클래스가 실제로 로드된 디렉터리를 쓴다. 빌드 도구 배치에 덜 의존한다. + * + *

기준 클래스로 {@link InviteCode} 를 쓴다. {@code P2PMod} 를 쓰면 안 된다 — + * 그 클래스는 {@code ClientModInitializer} 를 구현하므로 로드 시점에 Fabric Loader 가 + * 필요하고, 로더는 {@code compileOnly} 라 테스트 런타임 클래스패스에 없다. 참고로 + * 다른 클래스가 로더 없이 전부 로드된다는 사실 자체가 이 프로젝트의 설계 의도가 + * 지켜지고 있다는 또 하나의 증거다. + */ + private static Path classesRoot() { + try { + Path location = Paths.get(InviteCode.class.getProtectionDomain().getCodeSource().getLocation().toURI()); + if (Files.isDirectory(location)) return location; + } catch (Exception e) { + // 아래 경로로 물러난다. + } + return Paths.get("build", "classes", "java", "main"); + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/InviteCodeTest.java b/src/test/java/kr/tkrmagid/mcp2p/InviteCodeTest.java new file mode 100644 index 0000000..bed87c7 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/InviteCodeTest.java @@ -0,0 +1,86 @@ +package kr.tkrmagid.mcp2p; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InviteCodeTest { + + @Test + void 표기와_파싱이_왕복한다() { + InviteCode original = InviteCode.generate(); + InviteCode parsed = InviteCode.parse(original.display()); + assertArrayEquals(original.secret(), parsed.secret()); + assertEquals(original.roomId(), parsed.roomId()); + } + + @Test + void 표기는_6자씩_4묶음이다() { + String display = InviteCode.generate().display(); + assertEquals(27, display.length(), display); // 24자 + 하이픈 3개 + String[] groups = display.split("-"); + assertEquals(4, groups.length); + for (String group : groups) assertEquals(6, group.length()); + } + + @Test + void 방ID는_비밀에서_결정적으로_파생된다() { + InviteCode code = InviteCode.generate(); + assertEquals(code.roomId(), InviteCode.parse(code.display()).roomId()); + assertEquals(8, code.roomId().length()); + } + + @Test + void 서로_다른_코드는_서로_다른_방이_된다() { + Set rooms = new HashSet<>(); + for (int i = 0; i < 500; i++) rooms.add(InviteCode.generate().roomId()); + assertEquals(500, rooms.size(), "방 ID 충돌이 발생했다"); + } + + @Test + void 대소문자와_하이픈과_공백을_무시한다() { + InviteCode code = InviteCode.generate(); + String messy = " " + code.display().toLowerCase().replace("-", " ") + " "; + assertArrayEquals(code.secret(), InviteCode.parse(messy).secret()); + } + + @Test + void 손글씨_혼동_문자를_되돌린다() { + // Crockford 알파벳에는 I·L·O 가 없다. 사용자가 1·0 대신 잘못 적어도 살려 준다. + InviteCode viaDigits = InviteCode.parse("100000-000000-000000-000000"); + InviteCode viaLetters = InviteCode.parse("IOOOOO-OOOOOO-OOOOOO-OOOOOO"); + assertArrayEquals(viaDigits.secret(), viaLetters.secret()); + assertArrayEquals(viaDigits.secret(), InviteCode.parse("L00000-000000-000000-000000").secret()); + } + + @Test + void 잘못된_입력을_거절한다() { + assertThrows(IllegalArgumentException.class, () -> InviteCode.parse(null)); + assertThrows(IllegalArgumentException.class, () -> InviteCode.parse("")); + assertThrows(IllegalArgumentException.class, () -> InviteCode.parse("ABC")); + assertThrows(IllegalArgumentException.class, () -> InviteCode.parse("ABCDEF-GHJKMN-PQRSTV-WXYZ0")); // 23자 + assertThrows(IllegalArgumentException.class, () -> InviteCode.parse("ABCDEF-GHJKMN-PQRSTV-WXYZ0!")); + } + + @Test + void toString은_비밀을_노출하지_않는다() { + InviteCode code = InviteCode.generate(); + String text = code.toString(); + assertTrue(text.contains(code.roomId())); + assertNotEquals(true, text.contains(code.display().replace("-", ""))); + } + + @Test + void base32가_왕복한다() { + byte[] input = new byte[InviteCode.SECRET_BYTES]; + for (int i = 0; i < input.length; i++) input[i] = (byte) (i * 17 + 3); + assertArrayEquals(input, InviteCode.decode(InviteCode.encode(input))); + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/lan/LanBeaconTest.java b/src/test/java/kr/tkrmagid/mcp2p/lan/LanBeaconTest.java new file mode 100644 index 0000000..8169500 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/lan/LanBeaconTest.java @@ -0,0 +1,93 @@ +package kr.tkrmagid.mcp2p.lan; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +/** + * 이 테스트가 곧 "jar 하나로 모든 버전" 주장의 근거다. 여기서 검증하는 것은 자바 API 가 + * 아니라 와이어 포맷이고, 그 포맷은 마인크래프트 1.3 이후로 바뀐 적이 없다. + */ +class LanBeaconTest { + + private static LanBeacon decode(String payload) { + byte[] bytes = payload.getBytes(StandardCharsets.UTF_8); + return LanBeacon.decode(bytes, bytes.length); + } + + @Test + void 바닐라가_보내는_형태를_읽는다() { + LanBeacon beacon = decode("[MOTD]허승한의 세계[/MOTD][AD]25565[/AD]"); + assertNotNull(beacon); + assertEquals("허승한의 세계", beacon.motd()); + assertEquals(25565, beacon.port()); + } + + @Test + void 태그_순서가_바뀌어도_읽는다() { + // 문서화된 예시 중에는 AD 가 앞에 오는 것도 있다. 순서에 의존하지 않아야 한다. + LanBeacon beacon = decode("[AD]54345[/AD][MOTD]Tomsik68's world[/MOTD]"); + assertNotNull(beacon); + assertEquals("Tomsik68's world", beacon.motd()); + assertEquals(54345, beacon.port()); + } + + @Test + void 인코딩과_디코딩이_왕복한다() { + LanBeacon original = new LanBeacon("P2P 방 · 직결 42ms", 49123); + byte[] wire = original.encode(); + LanBeacon parsed = LanBeacon.decode(wire, wire.length); + assertNotNull(parsed); + assertEquals(original.motd(), parsed.motd()); + assertEquals(original.port(), parsed.port()); + } + + @Test + void 우리가_만든_페이로드는_바닐라_문법을_그대로_따른다() { + String wire = new String(new LanBeacon("방", 25565).encode(), StandardCharsets.UTF_8); + assertEquals("[MOTD]방[/MOTD][AD]25565[/AD]", wire); + } + + @Test + void MOTD가_비어_있어도_동작한다() { + LanBeacon beacon = decode("[MOTD][/MOTD][AD]25565[/AD]"); + assertNotNull(beacon); + assertEquals("", beacon.motd()); + assertEquals(25565, beacon.port()); + } + + @Test + void 관계없는_멀티캐스트_트래픽은_조용히_버린다() { + // 4445 그룹에는 다른 프로그램의 패킷도 들어온다. 예외를 던지면 수신 루프가 시끄러워진다. + assertNull(decode("")); + assertNull(decode("random garbage")); + assertNull(decode("[MOTD]포트 없음[/MOTD]")); + assertNull(decode("[AD]not-a-number[/AD]")); + assertNull(decode("[AD]25565")); + assertNull(LanBeacon.decode(null, 0)); + assertNull(LanBeacon.decode(new byte[10], 0)); + } + + @Test + void 포트_범위를_벗어나면_버린다() { + assertNull(decode("[MOTD]x[/MOTD][AD]0[/AD]")); + assertNull(decode("[MOTD]x[/MOTD][AD]65536[/AD]")); + assertNull(decode("[MOTD]x[/MOTD][AD]-1[/AD]")); + assertNotNull(decode("[MOTD]x[/MOTD][AD]65535[/AD]")); + assertNotNull(decode("[MOTD]x[/MOTD][AD]1[/AD]")); + } + + @Test + void 우리_비콘에는_자기_무시용_표식이_붙는다() { + // 이게 없으면 참가자가 쏜 비콘을 자기 자신이 "호스트가 열렸다" 로 오인한다. + LanBeacon beacon = new LanBeacon(LanAdvertiser.MARKER + "P2P 방", 40000); + byte[] wire = beacon.encode(); + LanBeacon parsed = LanBeacon.decode(wire, wire.length); + assertNotNull(parsed); + org.junit.jupiter.api.Assertions.assertTrue(parsed.motd().contains(LanAdvertiser.MARKER)); + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/lan/LanMulticastIntegrationTest.java b/src/test/java/kr/tkrmagid/mcp2p/lan/LanMulticastIntegrationTest.java new file mode 100644 index 0000000..d3c1eb7 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/lan/LanMulticastIntegrationTest.java @@ -0,0 +1,89 @@ +package kr.tkrmagid.mcp2p.lan; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.net.Inet4Address; +import java.net.NetworkInterface; +import java.util.Collections; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * 실제 멀티캐스트 소켓으로 비콘을 주고받는다. 단위 테스트가 아니라 이 설계의 핵심 가정을 + * 기계로 확인하는 절차다 — "마인크래프트 클래스를 건드리지 않고도 LAN 목록에 끼어들 수 있다" + * 는 주장이 이 테스트에 달려 있다. + * + *

컨테이너나 CI 환경에는 멀티캐스트가 없을 수 있다. 그럴 때는 실패가 아니라 건너뛴다 — + * 환경 문제를 코드 결함으로 보고하면 신호가 흐려진다. + */ +class LanMulticastIntegrationTest { + + private static boolean multicastAvailable() { + try { + for (NetworkInterface nic : Collections.list(NetworkInterface.getNetworkInterfaces())) { + if (!nic.isUp() || nic.isLoopback() || !nic.supportsMulticast()) continue; + boolean hasV4 = nic.getInterfaceAddresses().stream() + .anyMatch(a -> a.getAddress() instanceof Inet4Address); + if (hasV4) return true; + } + } catch (IOException e) { + return false; + } + return false; + } + + @Test + @Timeout(60) + void 비콘을_쏘면_같은_기기의_감지기가_포트를_읽는다() throws Exception { + assumeTrue(multicastAvailable(), "멀티캐스트 가능한 인터페이스가 없는 환경이다"); + + CountDownLatch detected = new CountDownLatch(1); + AtomicInteger seenPort = new AtomicInteger(-1); + + // 표식을 일부러 안 맞춰서, 우리가 쏜 비콘을 "바닐라 호스트의 비콘" 으로 취급하게 한다. + try (HostPortWatcher watcher = new HostPortWatcher("표식-일치하지-않음", port -> { + seenPort.set(port); + detected.countDown(); + })) { + watcher.start(); + Thread.sleep(500); // 그룹 가입이 끝날 시간을 준다. + + try (LanAdvertiser advertiser = new LanAdvertiser()) { + advertiser.put(54321, "테스트 월드"); + assertTrue(detected.await(20, TimeUnit.SECONDS), + "비콘을 쏘았는데 감지되지 않았다 — LAN 검색 경로가 깨졌다"); + } + assertEquals(54321, seenPort.get()); + assertTrue(watcher.lastBeaconAt() > 0, "비콘 수신 시각이 기록되지 않았다"); + assertEquals(54321, watcher.lastPort()); + } + } + + @Test + @Timeout(60) + void 자기_표식이_붙은_비콘은_무시한다() throws Exception { + assumeTrue(multicastAvailable(), "멀티캐스트 가능한 인터페이스가 없는 환경이다"); + + // 참가자로서 쏜 비콘을 자기 자신이 "호스트가 열렸다" 로 오인하면 방이 무한히 재생성된다. + CountDownLatch detected = new CountDownLatch(1); + try (HostPortWatcher watcher = new HostPortWatcher(LanAdvertiser.MARKER, port -> detected.countDown())) { + watcher.start(); + Thread.sleep(500); + try (LanAdvertiser advertiser = new LanAdvertiser()) { + advertiser.put(54322, "참가한 방"); + assertTrue(!detected.await(6, TimeUnit.SECONDS), + "자기 자신이 쏜 비콘을 호스트 비콘으로 오인했다"); + } + assertEquals(-1, watcher.lastPort()); + // 표식을 걸러냈더라도 "패킷은 도착했다" 는 사실은 기록돼야 한다. + assertEquals(0, watcher.lastBeaconAt(), "무시한 비콘의 시각까지 갱신하면 안 된다"); + } + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/net/EchoServer.java b/src/test/java/kr/tkrmagid/mcp2p/net/EchoServer.java new file mode 100644 index 0000000..1837336 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/net/EchoServer.java @@ -0,0 +1,75 @@ +package kr.tkrmagid.mcp2p.net; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * 테스트에서 마인크래프트 통합 서버 역할을 하는 대역. 받은 바이트를 대문자로 바꿔 돌려준다 — + * 그냥 되돌리는 것보다, 실제로 서버 쪽까지 갔다 왔다는 것을 확실히 보여 준다. + */ +public final class EchoServer implements AutoCloseable { + + private final ServerSocket listener; + private final AtomicInteger connections = new AtomicInteger(); + private volatile boolean running = true; + + public EchoServer() throws IOException { + listener = new ServerSocket(); + listener.setReuseAddress(true); + listener.bind(new InetSocketAddress("127.0.0.1", 0)); + Thread t = new Thread(this::acceptLoop, "test-echo-accept"); + t.setDaemon(true); + t.start(); + } + + public int port() { + return listener.getLocalPort(); + } + + public int connectionCount() { + return connections.get(); + } + + private void acceptLoop() { + while (running) { + try { + Socket client = listener.accept(); + connections.incrementAndGet(); + Thread t = new Thread(() -> serve(client), "test-echo-conn"); + t.setDaemon(true); + t.start(); + } catch (IOException e) { + return; + } + } + } + + private void serve(Socket client) { + try (Socket socket = client; + InputStream in = socket.getInputStream(); + OutputStream out = socket.getOutputStream()) { + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + String text = new String(buffer, 0, read, StandardCharsets.UTF_8); + out.write(text.toUpperCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8)); + out.flush(); + } + } catch (IOException e) { + // 테스트가 끝나면서 끊긴 것이다. + } + } + + @Override + public void close() throws IOException { + running = false; + listener.close(); + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/net/NetPrimitivesTest.java b/src/test/java/kr/tkrmagid/mcp2p/net/NetPrimitivesTest.java new file mode 100644 index 0000000..e1cd25d --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/net/NetPrimitivesTest.java @@ -0,0 +1,170 @@ +package kr.tkrmagid.mcp2p.net; + +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class NetPrimitivesTest { + + // ── 후보 직렬화 ───────────────────────────────────────────────────────── + + @Test + void 후보가_왕복한다() { + Candidate original = new Candidate(Candidate.Type.SRFLX, new InetSocketAddress("203.0.113.7", 40000)); + Candidate parsed = Candidate.decode(original.encode()); + assertNotNull(parsed); + assertEquals(original, parsed); + } + + @Test + void 후보_목록이_왕복한다() { + List original = List.of( + new Candidate(Candidate.Type.HOST, new InetSocketAddress("192.168.0.5", 25566)), + new Candidate(Candidate.Type.SRFLX, new InetSocketAddress("203.0.113.7", 40000)), + new Candidate(Candidate.Type.RELAY, new InetSocketAddress("198.51.100.9", 41000))); + List parsed = Candidate.decodeList(Candidate.encodeList(original)); + assertEquals(original, parsed); + } + + @Test + void 빈_목록은_하이픈_하나로_표현된다() { + // 줄 기반 프로토콜이라 빈 토큰을 보낼 수 없다. 자리를 지키는 표식이 필요하다. + assertEquals("-", Candidate.encodeList(List.of())); + assertTrue(Candidate.decodeList("-").isEmpty()); + assertTrue(Candidate.decodeList("").isEmpty()); + assertTrue(Candidate.decodeList(null).isEmpty()); + } + + @Test + void 깨진_후보는_버리고_나머지는_살린다() { + List parsed = Candidate.decodeList("host:10.0.0.1:1234,쓰레기,srflx:10.0.0.2:99999,relay:10.0.0.3:2345"); + assertEquals(2, parsed.size()); + assertEquals(Candidate.Type.HOST, parsed.get(0).type()); + assertEquals(Candidate.Type.RELAY, parsed.get(1).type()); + } + + @Test + void 후보_표기에는_공백이_없다() { + String encoded = Candidate.encodeList(List.of( + new Candidate(Candidate.Type.HOST, new InetSocketAddress("10.0.0.1", 1)), + new Candidate(Candidate.Type.RELAY, new InetSocketAddress("10.0.0.2", 2)))); + assertEquals(-1, encoded.indexOf(' '), "공백이 들어가면 줄 기반 프로토콜이 깨진다: " + encoded); + } + + // ── 자체 서명 인증서 ──────────────────────────────────────────────────── + + @Test + void 인증서를_JDK_기능만으로_만들_수_있다() throws Exception { + SelfSignedCert cert = SelfSignedCert.generate(); + KeyStore store = cert.keyStore(); + assertTrue(store.containsAlias(SelfSignedCert.ALIAS)); + + X509Certificate x509 = (X509Certificate) store.getCertificate(SelfSignedCert.ALIAS); + assertNotNull(x509); + // 직접 조립한 DER 이 실제로 파싱되고 서명까지 맞는지 확인한다. + x509.verify(x509.getPublicKey()); + x509.checkValidity(); + assertEquals("EC", x509.getPublicKey().getAlgorithm()); + assertNotNull(store.getKey(SelfSignedCert.ALIAS, cert.password())); + } + + @Test + void 지문은_64자_16진수이고_인증서와_일치한다() throws Exception { + SelfSignedCert cert = SelfSignedCert.generate(); + String fingerprint = cert.fingerprint(); + assertEquals(64, fingerprint.length()); + assertTrue(fingerprint.matches("[0-9A-F]{64}"), fingerprint); + + X509Certificate x509 = (X509Certificate) cert.keyStore().getCertificate(SelfSignedCert.ALIAS); + assertEquals(fingerprint, SelfSignedCert.fingerprintOf(x509)); + } + + @Test + void 매번_다른_인증서가_나온다() throws Exception { + assertNotEquals(SelfSignedCert.generate().fingerprint(), SelfSignedCert.generate().fingerprint()); + } + + // ── STUN 파싱 ─────────────────────────────────────────────────────────── + + @Test + void XOR_MAPPED_ADDRESS를_해독한다() throws Exception { + byte[] transactionId = new byte[12]; + for (int i = 0; i < 12; i++) transactionId[i] = (byte) i; + byte[] response = stunSuccess(transactionId, 0x0020, "203.0.113.7", 45678, true); + + InetSocketAddress parsed = Stun.parse(response, response.length, transactionId); + assertNotNull(parsed); + assertEquals("203.0.113.7", parsed.getAddress().getHostAddress()); + assertEquals(45678, parsed.getPort()); + } + + @Test + void XOR가_아닌_MAPPED_ADDRESS도_읽는다() throws Exception { + byte[] transactionId = new byte[12]; + byte[] response = stunSuccess(transactionId, 0x0001, "198.51.100.2", 3478, false); + InetSocketAddress parsed = Stun.parse(response, response.length, transactionId); + assertNotNull(parsed); + assertEquals("198.51.100.2", parsed.getAddress().getHostAddress()); + assertEquals(3478, parsed.getPort()); + } + + @Test + void 트랜잭션ID가_다르면_버린다() throws Exception { + byte[] sent = new byte[12]; + byte[] other = new byte[12]; + other[0] = 9; + byte[] response = stunSuccess(other, 0x0020, "203.0.113.7", 1234, true); + assertNull(Stun.parse(response, response.length, sent)); + } + + @Test + void STUN이_아닌_데이터그램은_버린다() { + byte[] transactionId = new byte[12]; + assertNull(Stun.parse(new byte[5], 5, transactionId)); + byte[] notStun = new byte[40]; + notStun[0] = 0x11; // 우리 펀칭 프로브의 첫 바이트 + assertNull(Stun.parse(notStun, notStun.length, transactionId)); + } + + /** 테스트용 STUN Binding Success 응답을 만든다. */ + private static byte[] stunSuccess(byte[] transactionId, int attributeType, + String ip, int port, boolean xored) throws Exception { + byte[] address = java.net.InetAddress.getByName(ip).getAddress(); + int magic = 0x2112A442; + int encodedPort = xored ? (port ^ ((magic >>> 16) & 0xFFFF)) : port; + byte[] encodedAddress = address.clone(); + if (xored) { + for (int i = 0; i < 4; i++) encodedAddress[i] ^= (byte) ((magic >>> (24 - i * 8)) & 0xFF); + } + + byte[] out = new byte[20 + 12]; + out[0] = 0x01; + out[1] = 0x01; // Binding Success + out[2] = 0x00; + out[3] = 0x0C; // 본문 12바이트 + out[4] = 0x21; + out[5] = 0x12; + out[6] = (byte) 0xA4; + out[7] = 0x42; + System.arraycopy(transactionId, 0, out, 8, 12); + out[20] = (byte) (attributeType >>> 8); + out[21] = (byte) attributeType; + out[22] = 0x00; + out[23] = 0x08; // 속성 길이 8 + out[24] = 0x00; + out[25] = 0x01; // IPv4 + out[26] = (byte) (encodedPort >>> 8); + out[27] = (byte) encodedPort; + System.arraycopy(encodedAddress, 0, out, 28, 4); + return out; + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/net/TunnelDirectTest.java b/src/test/java/kr/tkrmagid/mcp2p/net/TunnelDirectTest.java new file mode 100644 index 0000000..aea944e --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/net/TunnelDirectTest.java @@ -0,0 +1,250 @@ +package kr.tkrmagid.mcp2p.net; + +import kr.tkrmagid.mcp2p.InviteCode; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 터널 전 구간을 실제로 돌려 본다. 루프백이라 NAT 는 없지만, 그 외의 모든 것 — 펀칭 프로브, + * 경로 선택, QUIC 핸드셰이크, 인증서 지문 고정, 방 비밀 대조, TCP↔QUIC 양방향 운반 — + * 이 전부 실제 코드로 동작한다. + */ +class TunnelDirectTest { + + private static Candidate loopback(P2PSocket socket) { + return new Candidate(Candidate.Type.HOST, + new InetSocketAddress("127.0.0.1", socket.getLocalPort())); + } + + @Test + @Timeout(60) + void 직결_경로로_바이트가_끝까지_오간다() throws Exception { + InviteCode code = InviteCode.generate(); + SelfSignedCert cert = SelfSignedCert.generate(); + + try (EchoServer minecraft = new EchoServer(); + P2PSocket hostSocket = new P2PSocket(); + P2PSocket joinSocket = new P2PSocket(); + TunnelHost host = new TunnelHost(hostSocket, minecraft.port(), code.secret(), cert)) { + + host.start(); + + try (TunnelClient client = new TunnelClient(joinSocket, code.secret(), cert.fingerprint())) { + P2PSocket.Validated path = + joinSocket.awaitPath(List.of(loopback(hostSocket)), 15_000, 0); + assertNotNull(path, "루프백 경로가 확인되지 않았다"); + assertEquals(Candidate.Type.HOST, path.candidate().type()); + assertFalse(client.usesRelay()); + + int localPort = client.connect(path); + assertTrue(localPort > 0); + assertTrue(client.isConnected()); + + assertEquals("HELLO 마인크래프트", roundTrip(localPort, "hello 마인크래프트")); + assertEquals(1, minecraft.connectionCount()); + assertEquals(1, host.acceptedStreams()); + assertEquals(0, host.rejectedStreams()); + } + } + } + + @Test + @Timeout(60) + void 여러_TCP_연결이_각각_스트림을_얻는다() throws Exception { + // 마인크래프트는 서버 목록 핑과 실제 접속에 별도의 TCP 연결을 쓴다. + InviteCode code = InviteCode.generate(); + SelfSignedCert cert = SelfSignedCert.generate(); + + try (EchoServer minecraft = new EchoServer(); + P2PSocket hostSocket = new P2PSocket(); + P2PSocket joinSocket = new P2PSocket(); + TunnelHost host = new TunnelHost(hostSocket, minecraft.port(), code.secret(), cert); + TunnelClient client = new TunnelClient(joinSocket, code.secret(), cert.fingerprint())) { + + host.start(); + P2PSocket.Validated path = joinSocket.awaitPath(List.of(loopback(hostSocket)), 15_000, 0); + assertNotNull(path); + int localPort = client.connect(path); + + assertEquals("FIRST", roundTrip(localPort, "first")); + assertEquals("SECOND", roundTrip(localPort, "second")); + assertEquals("THIRD", roundTrip(localPort, "third")); + assertEquals(3, host.acceptedStreams()); + assertEquals(3, minecraft.connectionCount()); + } + } + + @Test + @Timeout(60) + void 큰_전송도_손실_없이_넘어간다() throws Exception { + // 청크 전송처럼 한 번에 몰리는 경우. 수신 큐가 넘치면 QUIC 이 재전송해 복구해야 한다. + InviteCode code = InviteCode.generate(); + SelfSignedCert cert = SelfSignedCert.generate(); + + StringBuilder builder = new StringBuilder(); + while (builder.length() < 512 * 1024) builder.append("abcdefghij"); + String payload = builder.toString(); + + try (EchoServer minecraft = new EchoServer(); + P2PSocket hostSocket = new P2PSocket(); + P2PSocket joinSocket = new P2PSocket(); + TunnelHost host = new TunnelHost(hostSocket, minecraft.port(), code.secret(), cert); + TunnelClient client = new TunnelClient(joinSocket, code.secret(), cert.fingerprint())) { + + host.start(); + P2PSocket.Validated path = joinSocket.awaitPath(List.of(loopback(hostSocket)), 15_000, 0); + assertNotNull(path); + int localPort = client.connect(path); + + String echoed = roundTripExact(localPort, payload); + assertEquals(payload.length(), echoed.length(), "전송량이 다르다"); + assertEquals(payload.toUpperCase(java.util.Locale.ROOT), echoed); + } + } + + @Test + @Timeout(60) + void 초대코드가_틀리면_호스트가_스트림을_거절한다() throws Exception { + InviteCode hostCode = InviteCode.generate(); + InviteCode wrongCode = InviteCode.generate(); + SelfSignedCert cert = SelfSignedCert.generate(); + + try (EchoServer minecraft = new EchoServer(); + P2PSocket hostSocket = new P2PSocket(); + P2PSocket joinSocket = new P2PSocket(); + TunnelHost host = new TunnelHost(hostSocket, minecraft.port(), hostCode.secret(), cert); + TunnelClient client = new TunnelClient(joinSocket, wrongCode.secret(), cert.fingerprint())) { + + host.start(); + P2PSocket.Validated path = joinSocket.awaitPath(List.of(loopback(hostSocket)), 15_000, 0); + assertNotNull(path); + int localPort = client.connect(path); + + // QUIC 핸드셰이크 자체는 통과한다(ALPN·지문 모두 맞으니까). 비밀 대조는 스트림 + // 수준이라, 거절은 데이터를 읽을 수 없는 형태로 나타난다. + try (Socket probe = new Socket()) { + probe.connect(new InetSocketAddress("127.0.0.1", localPort), 5000); + probe.setSoTimeout(8000); + probe.getOutputStream().write("hello".getBytes(StandardCharsets.UTF_8)); + probe.getOutputStream().flush(); + try { + assertEquals(-1, probe.getInputStream().read(), "거절돼야 하는데 데이터가 왔다"); + } catch (IOException expected) { + // 연결이 끊기는 형태도 정상적인 거절이다. + } + } + assertEquals(0, host.acceptedStreams()); + assertEquals(1, host.rejectedStreams()); + assertEquals(0, minecraft.connectionCount(), "거절된 스트림이 통합 서버에 닿았다"); + } + } + + @Test + @Timeout(60) + void 인증서_지문이_다르면_참가자가_연결을_끊는다() throws Exception { + InviteCode code = InviteCode.generate(); + SelfSignedCert real = SelfSignedCert.generate(); + SelfSignedCert impostor = SelfSignedCert.generate(); + + try (EchoServer minecraft = new EchoServer(); + P2PSocket hostSocket = new P2PSocket(); + P2PSocket joinSocket = new P2PSocket(); + TunnelHost host = new TunnelHost(hostSocket, minecraft.port(), code.secret(), real); + TunnelClient client = new TunnelClient(joinSocket, code.secret(), impostor.fingerprint())) { + + host.start(); + P2PSocket.Validated path = joinSocket.awaitPath(List.of(loopback(hostSocket)), 15_000, 0); + assertNotNull(path); + + IOException failure = assertThrows(IOException.class, () -> client.connect(path)); + assertTrue(failure.getMessage().contains("지문"), failure.getMessage()); + } + } + + @Test + @Timeout(30) + void 응답하지_않는_후보만_있으면_경로가_없다고_보고한다() throws Exception { + try (P2PSocket socket = new P2PSocket()) { + // 아무도 듣지 않는 포트. 프로브가 영원히 돌아오지 않아야 한다. + Candidate dead = new Candidate(Candidate.Type.HOST, new InetSocketAddress("127.0.0.1", 1)); + assertEquals(null, socket.awaitPath(List.of(dead), 1500, 0)); + } + } + + @Test + @Timeout(30) + void 직결이_가능하면_중계보다_직결을_고른다() throws Exception { + // 중계와 직결 후보가 동시에 유효할 때 직결이 이겨야 한다. 여기서는 둘 다 같은 + // 루프백 소켓을 가리키게 해서, 선택 로직만 분리해 검증한다. + try (P2PSocket peer = new P2PSocket(); + P2PSocket socket = new P2PSocket()) { + InetSocketAddress address = new InetSocketAddress("127.0.0.1", peer.getLocalPort()); + List targets = List.of( + new Candidate(Candidate.Type.RELAY, address), + new Candidate(Candidate.Type.HOST, address)); + P2PSocket.Validated chosen = socket.awaitPath(targets, 10_000, 600); + assertNotNull(chosen); + assertFalse(chosen.candidate().isRelay(), "중계가 직결을 이겼다"); + } + } + + // ── 도우미 ────────────────────────────────────────────────────────────── + + /** 한 줄 보내고 한 번 읽는다. */ + private static String roundTrip(int localPort, String message) throws IOException { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", localPort), 5000); + socket.setSoTimeout(20_000); + socket.getOutputStream().write(message.getBytes(StandardCharsets.UTF_8)); + socket.getOutputStream().flush(); + byte[] buffer = new byte[8192]; + int read = socket.getInputStream().read(buffer); + return read <= 0 ? "" : new String(buffer, 0, read, StandardCharsets.UTF_8); + } + } + + /** 보낸 바이트 수만큼 정확히 읽어 온다. */ + private static String roundTripExact(int localPort, String message) throws IOException { + byte[] payload = message.getBytes(StandardCharsets.UTF_8); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", localPort), 5000); + socket.setSoTimeout(30_000); + OutputStream out = socket.getOutputStream(); + InputStream in = socket.getInputStream(); + + Thread writer = new Thread(() -> { + try { + out.write(payload); + out.flush(); + } catch (IOException e) { + throw new RuntimeException(e); + } + }, "test-writer"); + writer.setDaemon(true); + writer.start(); + + byte[] received = new byte[payload.length]; + int total = 0; + while (total < received.length) { + int read = in.read(received, total, received.length - total); + if (read < 0) break; + total += read; + } + return new String(received, 0, total, StandardCharsets.UTF_8); + } + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/rendezvous/RelayPathTest.java b/src/test/java/kr/tkrmagid/mcp2p/rendezvous/RelayPathTest.java new file mode 100644 index 0000000..1dfe2fe --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/rendezvous/RelayPathTest.java @@ -0,0 +1,96 @@ +package kr.tkrmagid.mcp2p.rendezvous; + +import kr.tkrmagid.mcp2p.InviteCode; +import kr.tkrmagid.mcp2p.net.Candidate; +import kr.tkrmagid.mcp2p.net.EchoServer; +import kr.tkrmagid.mcp2p.net.P2PSocket; +import kr.tkrmagid.mcp2p.net.SelfSignedCert; +import kr.tkrmagid.mcp2p.net.TunnelClient; +import kr.tkrmagid.mcp2p.net.TunnelHost; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 중계 경로 전 구간 검증. 홀펀칭이 실패하는 상황(대칭 NAT 등)을 흉내내기 위해 호스트가 + * 직결 후보를 하나도 알려 주지 않게 만든다. 그러면 참가자는 중계밖에 쓸 수 없다. + * + *

여기서 확인하는 핵심은 직결과 중계에서 터널 코드가 완전히 같다는 점이다. + * 중계가 UDP 데이터그램을 그대로 넘겨 주므로 QUIC 쪽에서는 목적지 주소만 다르다. + * 그리고 QUIC 의 TLS 1.3 이 종단간이라 중계 서버는 내용을 볼 수 없다. + */ +class RelayPathTest { + + @Test + @Timeout(90) + void 홀펀칭이_안_되면_중계로_끝까지_전달된다() throws Exception { + InviteCode code = InviteCode.generate(); + SelfSignedCert cert = SelfSignedCert.generate(); + + RendezvousServer server = new RendezvousServer(0, 0, 0); + Thread serverThread = new Thread(server::serveForever, "test-rendezvous"); + serverThread.setDaemon(true); + serverThread.start(); + + try (server; + EchoServer minecraft = new EchoServer(); + P2PSocket hostSocket = new P2PSocket(); + P2PSocket joinSocket = new P2PSocket(); + TunnelHost host = new TunnelHost(hostSocket, minecraft.port(), code.secret(), cert); + RendezvousClient hostSignal = new RendezvousClient("127.0.0.1", server.port(), 5000); + RendezvousClient joinSignal = new RendezvousClient("127.0.0.1", server.port(), 5000)) { + + host.start(); + + // 직결 후보를 비워 둔다 = 참가자가 쓸 수 있는 경로는 중계뿐이다. + hostSignal.registerHost(code.roomId(), cert.fingerprint(), List.of(), peer -> { + assertTrue(peer.relayPort() > 0, "호스트가 중계 포트를 못 받았다"); + // 호스트가 중계 쪽으로 계속 쏘아야 중계 서버가 호스트 주소를 학습한다. + hostSocket.startProbing( + List.of(new Candidate(Candidate.Type.RELAY, hostSignal.relayAddress(peer.relayPort()))), + 60_000); + }); + + RendezvousClient.HostInfo info = joinSignal.join(code.roomId(), List.of()); + assertTrue(info.candidates().isEmpty(), "직결 후보가 없어야 하는 시나리오다"); + assertTrue(info.relayPort() > 0); + + List targets = + List.of(new Candidate(Candidate.Type.RELAY, joinSignal.relayAddress(info.relayPort()))); + + try (TunnelClient client = new TunnelClient(joinSocket, code.secret(), info.fingerprint())) { + P2PSocket.Validated path = joinSocket.awaitPath(targets, 30_000, 0); + assertNotNull(path, "중계 경로가 확인되지 않았다"); + assertTrue(path.candidate().isRelay()); + + int localPort = client.connect(path); + assertTrue(client.usesRelay(), "중계로 붙었는데 직결이라고 보고한다"); + + assertEquals("RELAY 경로 확인", roundTrip(localPort, "relay 경로 확인")); + assertEquals(1, host.acceptedStreams()); + assertEquals(1, minecraft.connectionCount()); + } + } + } + + private static String roundTrip(int localPort, String message) throws IOException { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress("127.0.0.1", localPort), 5000); + socket.setSoTimeout(30_000); + socket.getOutputStream().write(message.getBytes(StandardCharsets.UTF_8)); + socket.getOutputStream().flush(); + byte[] buffer = new byte[8192]; + int read = socket.getInputStream().read(buffer); + return read <= 0 ? "" : new String(buffer, 0, read, StandardCharsets.UTF_8); + } + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousTest.java b/src/test/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousTest.java new file mode 100644 index 0000000..0ad90d8 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousTest.java @@ -0,0 +1,131 @@ +package kr.tkrmagid.mcp2p.rendezvous; + +import kr.tkrmagid.mcp2p.InviteCode; +import kr.tkrmagid.mcp2p.net.Candidate; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** 시그널링 서버와 클라이언트를 실제로 붙여 본다. */ +class RendezvousTest { + + private static RendezvousServer start() throws IOException { + RendezvousServer server = new RendezvousServer(0, 0, 0); + Thread t = new Thread(server::serveForever, "test-rendezvous"); + t.setDaemon(true); + t.start(); + return server; + } + + private static List candidates(String ip, int port) { + return List.of(new Candidate(Candidate.Type.SRFLX, new InetSocketAddress(ip, port))); + } + + @Test + @Timeout(30) + void 호스트와_참가자가_정보를_교환한다() throws Exception { + try (RendezvousServer server = start()) { + InviteCode code = InviteCode.generate(); + CompletableFuture peerSeen = new CompletableFuture<>(); + + try (RendezvousClient host = new RendezvousClient("127.0.0.1", server.port(), 5000); + RendezvousClient joiner = new RendezvousClient("127.0.0.1", server.port(), 5000)) { + + host.registerHost(code.roomId(), "AABBCC", candidates("203.0.113.1", 1111), peerSeen::complete); + assertEquals(1, server.roomCount()); + + RendezvousClient.HostInfo info = joiner.join(code.roomId(), candidates("203.0.113.2", 2222)); + assertEquals("AABBCC", info.fingerprint()); + assertEquals(1, info.candidates().size()); + assertEquals(1111, info.candidates().get(0).address().getPort()); + assertTrue(info.relayPort() > 0, "중계 포트가 배정되지 않았다"); + + // 호스트도 참가자 후보를 받아야 한다 — 양쪽이 동시에 쏘아야 구멍이 뚫린다. + RendezvousClient.PeerInfo peer = peerSeen.get(10, TimeUnit.SECONDS); + assertEquals(1, peer.candidates().size()); + assertEquals(2222, peer.candidates().get(0).address().getPort()); + assertTrue(peer.relayPort() > 0); + + // 양쪽 중계 포트는 서로 달라야 한다(포트 한 쌍을 쓰므로). + org.junit.jupiter.api.Assertions.assertNotEquals(info.relayPort(), peer.relayPort()); + } + } + } + + @Test + @Timeout(30) + void 없는_방에_참가하면_사람이_읽을_수_있는_오류가_난다() throws Exception { + try (RendezvousServer server = start(); + RendezvousClient joiner = new RendezvousClient("127.0.0.1", server.port(), 5000)) { + IOException failure = assertThrows(IOException.class, + () -> joiner.join(InviteCode.generate().roomId(), List.of())); + assertTrue(failure.getMessage().contains("초대코드"), failure.getMessage()); + } + } + + @Test + @Timeout(30) + void 같은_방ID를_두_번_등록할_수_없다() throws Exception { + try (RendezvousServer server = start()) { + InviteCode code = InviteCode.generate(); + try (RendezvousClient first = new RendezvousClient("127.0.0.1", server.port(), 5000); + RendezvousClient second = new RendezvousClient("127.0.0.1", server.port(), 5000)) { + first.registerHost(code.roomId(), "AA", List.of(), info -> {}); + IOException failure = assertThrows(IOException.class, + () -> second.registerHost(code.roomId(), "BB", List.of(), info -> {})); + assertTrue(failure.getMessage().contains("room-taken"), failure.getMessage()); + } + } + } + + @Test + @Timeout(30) + void 호스트가_끊기면_방이_사라진다() throws Exception { + try (RendezvousServer server = start()) { + InviteCode code = InviteCode.generate(); + RendezvousClient host = new RendezvousClient("127.0.0.1", server.port(), 5000); + host.registerHost(code.roomId(), "AA", List.of(), info -> {}); + assertEquals(1, server.roomCount()); + + host.close(); + long deadline = System.currentTimeMillis() + 10_000; + while (server.roomCount() != 0 && System.currentTimeMillis() < deadline) { + Thread.sleep(50); + } + assertEquals(0, server.roomCount(), "호스트가 나갔는데 방이 남아 있다"); + } + } + + @Test + @Timeout(30) + void 잘못된_방ID는_거절한다() throws Exception { + try (RendezvousServer server = start(); + RendezvousClient host = new RendezvousClient("127.0.0.1", server.port(), 5000)) { + assertThrows(IOException.class, () -> host.registerHost("a!b@c", "AA", List.of(), info -> {})); + } + } + + @Test + @Timeout(30) + void 중계_주소는_시그널링_호스트를_그대로_쓴다() throws Exception { + // 서버는 포트만 알려 준다. 자기 공인 IP 를 스스로 알아내는 것은 신뢰할 수 없어서 + // 클라이언트가 이미 아는 주소를 재사용한다. + try (RendezvousServer server = start(); + RendezvousClient client = new RendezvousClient("127.0.0.1", server.port(), 5000)) { + InetSocketAddress relay = client.relayAddress(45000); + assertNotNull(relay.getAddress()); + assertEquals("127.0.0.1", relay.getAddress().getHostAddress()); + assertEquals(45000, relay.getPort()); + } + } +} diff --git a/src/test/java/kr/tkrmagid/mcp2p/ui/WebConsoleTest.java b/src/test/java/kr/tkrmagid/mcp2p/ui/WebConsoleTest.java new file mode 100644 index 0000000..90fd986 --- /dev/null +++ b/src/test/java/kr/tkrmagid/mcp2p/ui/WebConsoleTest.java @@ -0,0 +1,158 @@ +package kr.tkrmagid.mcp2p.ui; + +import kr.tkrmagid.mcp2p.Config; +import kr.tkrmagid.mcp2p.Session; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 직접 구현한 HTTP 서버를 실제 HTTP 클라이언트로 두들겨 본다. 손으로 쓴 프로토콜 코드는 + * 검증 없이 두면 안 되는 종류라, 요청 파싱·응답 헤더·본문 처리를 전부 실제 통신으로 확인한다. + */ +class WebConsoleTest { + + private static final HttpClient CLIENT = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)).build(); + + private HttpResponse get(WebConsole console, String path) throws Exception { + return CLIENT.send(HttpRequest.newBuilder(URI.create(console.url() + path)) + .timeout(Duration.ofSeconds(10)).GET().build(), HttpResponse.BodyHandlers.ofString()); + } + + private HttpResponse post(WebConsole console, String path, String form) throws Exception { + return CLIENT.send(HttpRequest.newBuilder(URI.create(console.url() + path)) + .timeout(Duration.ofSeconds(20)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form)).build(), HttpResponse.BodyHandlers.ofString()); + } + + @Test + @Timeout(60) + void 페이지와_상태와_설정이_동작한다(@TempDir Path configDir) throws Exception { + Config config = new Config(configDir); + try (Session session = new Session(config); + WebConsole console = new WebConsole(session, 0)) { + + HttpResponse page = get(console, "/"); + assertEquals(200, page.statusCode()); + assertTrue(page.body().contains("mc_p2p 콘솔"), "페이지 본문이 이상하다"); + assertTrue(page.headers().firstValue("Content-Type").orElse("").contains("text/html")); + + String status = get(console, "/api/status").body(); + assertTrue(status.contains("\"configured\":false"), status); + + // 설정 저장 → 상태와 파일에 모두 반영돼야 한다. + assertTrue(post(console, "/api/config", "rendezvous=p2p.example.com%3A25580") + .body().contains("\"ok\":true")); + assertTrue(get(console, "/api/status").body().contains("p2p.example.com:25580")); + assertTrue(Files.readString(configDir.resolve("mc_p2p.properties")).contains("p2p.example.com")); + assertEquals("p2p.example.com", config.rendezvousHost()); + assertEquals(25580, config.rendezvousPort()); + } + } + + @Test + @Timeout(60) + void 설정_전에는_방을_열_수_없고_이유를_알려_준다(@TempDir Path configDir) throws Exception { + try (Session session = new Session(new Config(configDir)); + WebConsole console = new WebConsole(session, 0)) { + String body = post(console, "/api/host/arm", "").body(); + assertTrue(body.contains("\"ok\":false"), body); + assertTrue(body.contains("시그널링"), body); + assertFalse(session.isHostArmed()); + } + } + + @Test + @Timeout(60) + void 잘못된_초대코드는_사람이_읽을_수_있는_오류로_돌아온다(@TempDir Path configDir) throws Exception { + Config config = new Config(configDir); + config.setRendezvous("127.0.0.1:1"); + try (Session session = new Session(config); + WebConsole console = new WebConsole(session, 0)) { + String body = post(console, "/api/join", "code=NOPE").body(); + assertTrue(body.contains("\"ok\":false"), body); + // 24자가 아니라는 이유가 그대로 노출돼야 한다(연결 시도 전에 걸러진다). + assertTrue(body.contains("24"), body); + } + } + + @Test + @Timeout(60) + void 알_수_없는_경로는_404다(@TempDir Path configDir) throws Exception { + try (Session session = new Session(new Config(configDir)); + WebConsole console = new WebConsole(session, 0)) { + assertEquals(404, get(console, "/없는경로").statusCode()); + assertEquals(404, post(console, "/api/없는것", "").statusCode()); + } + } + + @Test + @Timeout(60) + void 콘솔은_루프백에만_열린다(@TempDir Path configDir) throws Exception { + // 이 콘솔은 방을 열고 닫을 수 있으므로 LAN 에 노출되면 안 된다. + java.net.InetAddress lan = firstNonLoopbackAddress(); + org.junit.jupiter.api.Assumptions.assumeTrue(lan != null, "비루프백 주소가 없는 환경이다"); + + try (Session session = new Session(new Config(configDir)); + WebConsole console = new WebConsole(session, 0)) { + // 루프백으로는 붙는다. + assertEquals(200, get(console, "/").statusCode()); + + // LAN 주소로는 붙지 않아야 한다. + try (java.net.Socket probe = new java.net.Socket()) { + probe.connect(new java.net.InetSocketAddress(lan, console.port()), 2000); + org.junit.jupiter.api.Assertions.fail( + "콘솔이 LAN 주소 " + lan.getHostAddress() + " 에서도 응답한다 — 외부에 노출됐다"); + } catch (java.io.IOException expected) { + // 연결 거부가 정답이다. + } + } + } + + private static java.net.InetAddress firstNonLoopbackAddress() throws Exception { + for (java.net.NetworkInterface nic + : java.util.Collections.list(java.net.NetworkInterface.getNetworkInterfaces())) { + if (!nic.isUp() || nic.isLoopback()) continue; + for (java.net.InetAddress address : java.util.Collections.list(nic.getInetAddresses())) { + if (address instanceof java.net.Inet4Address && !address.isLinkLocalAddress()) return address; + } + } + return null; + } + + @Test + void 폼_파싱이_URL_인코딩과_한글을_처리한다() { + Map form = WebConsole.parseForm("code=ABC%20DEF&motd=%ED%95%9C%EA%B8%80&empty="); + assertEquals("ABC DEF", form.get("code")); + assertEquals("한글", form.get("motd")); + assertEquals("", form.get("empty")); + assertTrue(WebConsole.parseForm("").isEmpty()); + assertTrue(WebConsole.parseForm(null).isEmpty()); + } + + @Test + void JSON_이스케이프가_따옴표와_제어문자를_막는다() { + // 오류 메시지가 그대로 JSON 에 들어가므로 이스케이프가 새면 응답이 깨진다. + assertEquals("a\\\"b", WebConsole.escape("a\"b")); + assertEquals("a\\\\b", WebConsole.escape("a\\b")); + assertEquals("a\\nb", WebConsole.escape("a\nb")); + assertEquals("\\u0000", WebConsole.escape(String.valueOf((char) 0))); + assertEquals("한글", WebConsole.escape("한글")); + assertEquals("", WebConsole.escape(null)); + } +}