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 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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)); + } +}