Compare commits
3 Commits
f226201359
...
bacc2e91d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bacc2e91d3 | ||
|
|
ff5aa0f0dc | ||
|
|
a124b6478a |
36
.github/workflows/build.yml
vendored
Normal file
36
.github/workflows/build.yml
vendored
Normal file
@@ -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
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
build/
|
||||
.gradle/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
.vscode/
|
||||
run/
|
||||
config/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
177
README.md
177
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) 참고.
|
||||
|
||||
101
build.gradle.kts
Normal file
101
build.gradle.kts
Normal file
@@ -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<JavaCompile>().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<Jar>("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") }
|
||||
257
docs/DESIGN.md
Normal file
257
docs/DESIGN.md
Normal file
@@ -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 를 참고했다. 코드는 가져오지 않았다.
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -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
|
||||
248
gradlew
vendored
Executable file
248
gradlew
vendored
Executable file
@@ -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" "$@"
|
||||
93
gradlew.bat
vendored
Normal file
93
gradlew.bat
vendored
Normal file
@@ -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
|
||||
1
settings.gradle.kts
Normal file
1
settings.gradle.kts
Normal file
@@ -0,0 +1 @@
|
||||
rootProject.name = "mc_p2p_mod"
|
||||
153
src/main/java/kr/tkrmagid/mcp2p/Config.java
Normal file
153
src/main/java/kr/tkrmagid/mcp2p/Config.java
Normal file
@@ -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} 를 쓴다.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/main/java/kr/tkrmagid/mcp2p/InviteCode.java
Normal file
158
src/main/java/kr/tkrmagid/mcp2p/InviteCode.java
Normal file
@@ -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;
|
||||
|
||||
/**
|
||||
* 초대코드 = 방의 공유 비밀 그 자체.
|
||||
*
|
||||
* <p>설계 의도가 하나 있다. <b>시그널링 서버는 초대코드를 절대 알지 못한다.</b>
|
||||
* 코드는 120비트 난수(비밀)이고, 서버에 등록되는 방 ID 는 그 비밀의 SHA-256 앞부분에서
|
||||
* 파생된 값이다. 서버는 방 ID 만 보므로 코드를 복원할 수 없고, 따라서 방에 몰래
|
||||
* 참가하거나 호스트를 사칭할 수 없다. 반대로 코드를 가진 사람은 방 ID 를 직접 계산할 수
|
||||
* 있어서 별도의 조회 과정이 필요 없다.
|
||||
*
|
||||
* <p>표기는 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);
|
||||
}
|
||||
}
|
||||
53
src/main/java/kr/tkrmagid/mcp2p/Log.java
Normal file
53
src/main/java/kr/tkrmagid/mcp2p/Log.java
Normal file
@@ -0,0 +1,53 @@
|
||||
package kr.tkrmagid.mcp2p;
|
||||
|
||||
/**
|
||||
* 최소 로거.
|
||||
*
|
||||
* <p>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;
|
||||
}
|
||||
}
|
||||
61
src/main/java/kr/tkrmagid/mcp2p/P2PMod.java
Normal file
61
src/main/java/kr/tkrmagid/mcp2p/P2PMod.java
Normal file
@@ -0,0 +1,61 @@
|
||||
package kr.tkrmagid.mcp2p;
|
||||
|
||||
import kr.tkrmagid.mcp2p.ui.WebConsole;
|
||||
import net.fabricmc.api.ClientModInitializer;
|
||||
|
||||
/**
|
||||
* 모드 진입점. <b>이 프로젝트에서 마인크래프트·모드로더 쪽 타입을 언급하는 파일은 여기와
|
||||
* {@link Config#inGameDirectory()} 둘뿐이다.</b>
|
||||
*
|
||||
* <p>그리고 여기서 쓰는 {@link ClientModInitializer} 조차 마인크래프트 클래스가 아니다.
|
||||
* Fabric Loader 소유의 인터페이스라 리매핑 대상이 아니고, 로더가 존재하는 모든 마인크래프트
|
||||
* 버전에서 같은 이름·같은 시그니처다. 그래서 이 jar 하나가 1.21 에서도 26.3 에서도 그대로
|
||||
* 로드된다. 자세한 근거는 docs/DESIGN.md 에 적어 두었다.
|
||||
*
|
||||
* <p>초기화는 가볍게 끝난다. 네트워크 자원은 사용자가 웹 콘솔에서 방을 열거나 참가할 때
|
||||
* 비로소 만들어지므로, 모드를 깔아 두기만 한 상태의 비용은 거의 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();
|
||||
}
|
||||
}
|
||||
318
src/main/java/kr/tkrmagid/mcp2p/Session.java
Normal file
318
src/main/java/kr/tkrmagid/mcp2p/Session.java
Normal file
@@ -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;
|
||||
|
||||
/**
|
||||
* 모드 전체의 상태 기계. 호스트 한 개와 참가 한 개를 동시에 들 수 있다(호스트가 다른
|
||||
* 방에 놀러 갈 수도 있으니까).
|
||||
*
|
||||
* <p>여기에도 마인크래프트 클래스 참조가 없다. 호스트 시작 신호는 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<AutoCloseable> 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<Candidate> 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<Candidate> 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<Candidate> mine = socket.gather(config.stunServer());
|
||||
RendezvousClient.HostInfo info = signal.join(code.roomId(), mine);
|
||||
|
||||
List<Candidate> 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();
|
||||
}
|
||||
}
|
||||
127
src/main/java/kr/tkrmagid/mcp2p/lan/HostPortWatcher.java
Normal file
127
src/main/java/kr/tkrmagid/mcp2p/lan/HostPortWatcher.java
Normal file
@@ -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 에 공개" 를 누른 순간을 감지해서 통합 서버 포트를 알아낸다.
|
||||
*
|
||||
* <p>믹스인이나 마인크래프트 클래스 참조 없이 이 일을 할 수 있는 이유는
|
||||
* {@link LanBeacon} 의 주석에 적어 두었다. 요약하면 마인크래프트가 그 정보를
|
||||
* 멀티캐스트로 방송해 주기 때문이다.
|
||||
*
|
||||
* <p>같은 기기에서 올라온 비콘만 받아들인다. 그렇지 않으면 같은 공유기에 붙은 친구의
|
||||
* 월드를 내 월드로 착각해서 엉뚱한 방을 열게 된다.
|
||||
*/
|
||||
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 이면 아직 아무것도 못 들었다.
|
||||
*
|
||||
* <p>월드를 닫으면 마인크래프트가 비콘을 멈춘다. 즉 이 값이 오래됐다는 것은 방을
|
||||
* 닫아야 한다는 신호다. "월드 종료" 이벤트를 마인크래프트 API 없이 알아내는 방법이다.
|
||||
*/
|
||||
public long lastBeaconAt() {
|
||||
return lastBeaconAt;
|
||||
}
|
||||
|
||||
private void run() {
|
||||
try (MulticastSocket s = LanMulticast.openReceiver()) {
|
||||
socket = s;
|
||||
Set<InetAddress> 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<InetAddress> localAddresses() {
|
||||
Set<InetAddress> 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();
|
||||
}
|
||||
}
|
||||
88
src/main/java/kr/tkrmagid/mcp2p/lan/LanAdvertiser.java
Normal file
88
src/main/java/kr/tkrmagid/mcp2p/lan/LanAdvertiser.java
Normal file
@@ -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 목록에 올린다.
|
||||
*
|
||||
* <p>참가자 쪽 사용자 경험 전체가 여기에 달려 있다. 로컬 TCP 리스너를 열고 그 포트를
|
||||
* 비콘으로 방송하면, 사용자는 멀티플레이 화면에서 방이 저절로 나타나는 것을 보고 누르면
|
||||
* 된다. 주소를 입력하거나 서버를 추가할 필요가 없고, 우리가 만들어야 할 GUI 도 없다.
|
||||
*
|
||||
* <p>여러 방을 동시에 올릴 수 있게 되어 있다. 참가 중인 방 하나와 안내용 항목 하나를
|
||||
* 같이 띄우는 식으로 쓴다.
|
||||
*/
|
||||
public final class LanAdvertiser implements AutoCloseable {
|
||||
|
||||
/** 우리가 쏜 비콘임을 표시하는 글자. {@link HostPortWatcher} 가 이걸 보고 무시한다. */
|
||||
public static final String MARKER = "§r";
|
||||
|
||||
private final Map<Integer, String> 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<Integer, String> 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();
|
||||
}
|
||||
}
|
||||
106
src/main/java/kr/tkrmagid/mcp2p/lan/LanBeacon.java
Normal file
106
src/main/java/kr/tkrmagid/mcp2p/lan/LanBeacon.java
Normal file
@@ -0,0 +1,106 @@
|
||||
package kr.tkrmagid.mcp2p.lan;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 마인크래프트 LAN 검색 비콘의 인코딩/디코딩.
|
||||
*
|
||||
* <p>이 클래스가 이 모드 전체의 핵심이다. 마인크래프트의 "LAN 에 공개" 기능은 자바 API 가
|
||||
* 아니라 <b>네트워크 프로토콜</b>로 노출된다. 호스트는 1.5초마다 멀티캐스트로 아래 형태의
|
||||
* UDP 페이로드를 뿌리고, 클라이언트는 멀티플레이 화면이 열려 있는 동안 그것을 듣는다.
|
||||
*
|
||||
* <pre>[MOTD]월드 이름[/MOTD][AD]25565[/AD]</pre>
|
||||
*
|
||||
* <p>중요한 점: 페이로드에는 <b>IP 가 없다.</b> 포트만 들어 있다. 따라서 클라이언트는
|
||||
* UDP 패킷의 발신 주소를 서버 주소로 쓸 수밖에 없다. 이 사실이 우리에게 두 가지를 준다.
|
||||
*
|
||||
* <ol>
|
||||
* <li>호스트 쪽: 사용자가 바닐라 "LAN 에 공개" 를 누르면 우리는 믹스인 없이 그 포트를 알 수 있다.</li>
|
||||
* <li>참가자 쪽: 우리가 직접 비콘을 쏘면 바닐라 클라이언트의 LAN 목록에 우리 터널이
|
||||
* 그대로 나타난다. GUI 코드가 한 줄도 필요 없다.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>이 프로토콜은 마인크래프트 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 + '}';
|
||||
}
|
||||
}
|
||||
115
src/main/java/kr/tkrmagid/mcp2p/lan/LanMulticast.java
Normal file
115
src/main/java/kr/tkrmagid/mcp2p/lan/LanMulticast.java
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 비콘 송신용 소켓. 송신 인터페이스를 명시적으로 고정한다.
|
||||
*
|
||||
* <p>이게 중요한 이유: 마인크래프트 클라이언트는 비콘 페이로드가 아니라 <b>UDP 발신
|
||||
* 주소</b>를 서버 주소로 쓴다. 그러므로 우리가 어느 인터페이스로 쏘는지가 곧 클라이언트가
|
||||
* 접속을 시도할 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<NetworkInterface> candidates = multicastCapable(false);
|
||||
return candidates.isEmpty() ? null : candidates.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param includeLoopback 수신에서는 루프백도 포함해야 한다. 같은 기기에서 도는 다른
|
||||
* 프로세스가 루프백으로 쏠 수 있기 때문이다. 송신에서는 제외한다.
|
||||
*/
|
||||
private static List<NetworkInterface> multicastCapable(boolean includeLoopback) {
|
||||
List<NetworkInterface> 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;
|
||||
}
|
||||
}
|
||||
104
src/main/java/kr/tkrmagid/mcp2p/net/Candidate.java
Normal file
104
src/main/java/kr/tkrmagid/mcp2p/net/Candidate.java
Normal file
@@ -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<Candidate> 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<Candidate> decodeList(String text) {
|
||||
List<Candidate> 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();
|
||||
}
|
||||
}
|
||||
373
src/main/java/kr/tkrmagid/mcp2p/net/P2PSocket.java
Normal file
373
src/main/java/kr/tkrmagid/mcp2p/net/P2PSocket.java
Normal file
@@ -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 과 홀펀칭이 <b>같은 UDP 소켓</b>을 공유하게 만드는 장치.
|
||||
*
|
||||
* <p>왜 같은 소켓이어야 하는가. NAT 매핑은 (내부IP:포트 → 공인IP:포트) 단위로 만들어진다.
|
||||
* 홀펀칭으로 구멍을 뚫어 놓고 QUIC 을 다른 소켓으로 열면 그 구멍은 아무 쓸모가 없다.
|
||||
* 그래서 STUN 질의, 펀칭 프로브, QUIC 데이터가 전부 이 소켓 하나를 지나야 한다.
|
||||
*
|
||||
* <p>구현은 {@link DatagramSocket} 을 상속해서 수신을 가로채는 방식이다. 전용 읽기 스레드가
|
||||
* 하나 돌면서 들어온 데이터그램을 두 갈래로 나눈다. 프로브면 여기서 처리하고, 나머지는
|
||||
* 큐에 넣어 {@link #receive(DatagramPacket)} 를 호출한 쪽(Kwik)에게 넘긴다.
|
||||
* 읽기 주체를 하나로 고정했기 때문에 "펀칭 루프와 Kwik 수신 스레드가 서로 패킷을
|
||||
* 훔쳐가는" 경쟁 상태가 원천적으로 생기지 않는다.
|
||||
*
|
||||
* <p>프로브 첫 바이트는 {@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<Inbound> inbox = new ArrayBlockingQueue<>(QUEUE_CAPACITY);
|
||||
private final AtomicBoolean running = new AtomicBoolean(true);
|
||||
private final SecureRandom random = new SecureRandom();
|
||||
|
||||
/** 우리가 보낸 ping 의 nonce → (상대주소, 보낸 시각). pong 을 짝지으려고 둔다. */
|
||||
private final Map<Long, PendingProbe> pending = new ConcurrentHashMap<>();
|
||||
|
||||
/** 왕복이 확인된 주소들. 먼저 확인된 것이 앞에 온다. */
|
||||
private final List<Validated> 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<Candidate> gather(InetSocketAddress stunServer) {
|
||||
Set<Candidate> 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<Candidate> targets) {
|
||||
return startProbing(targets, Long.MAX_VALUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param durationMs 이만큼 지나면 스스로 멈춘다. 호스트가 참가자마다 프로브 스레드를
|
||||
* 띄우고 영구히 방치하면 스레드와 트래픽이 계속 쌓이기 때문이다.
|
||||
* 경로가 한 번 열리면 그 뒤로는 QUIC 트래픽이 NAT 매핑을 유지한다.
|
||||
*/
|
||||
public AutoCloseable startProbing(List<Candidate> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 후보들을 동시에 찔러 보고 쓸 경로를 하나 고른다.
|
||||
*
|
||||
* <p>중계 후보는 거의 항상 즉시 성공한다(중계 서버는 NAT 뒤에 없으니까). 그래서 아무
|
||||
* 장치 없이 "먼저 된 것" 을 고르면 직결이 가능한 상황에서도 중계로 굳어버린다. 그래서
|
||||
* 직결 후보에게 {@code directPreferenceMs} 만큼의 유예를 준다. 홀펀칭이 그 안에 되면
|
||||
* 직결을, 안 되면 중계를 쓴다.
|
||||
*
|
||||
* <p>Tailscale·iroh 처럼 "중계로 먼저 붙이고 나중에 직결로 갈아타기" 를 하면 대기 시간이
|
||||
* 0 이 되지만, 스트림을 끊김 없이 이전하는 장치가 추가로 필요하다. 유예 방식은 최악의
|
||||
* 경우 {@code directPreferenceMs} 만 기다리는 대신 구현이 훨씬 단순하다. 갈아타기는
|
||||
* docs/DESIGN.md 의 후속 과제로 남겼다.
|
||||
*
|
||||
* @return 고른 경로, 아무것도 확인되지 않으면 {@code null}
|
||||
*/
|
||||
public Validated awaitPath(List<Candidate> 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<Validated> 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<InetAddress> localAddresses() {
|
||||
List<InetAddress> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/main/java/kr/tkrmagid/mcp2p/net/Pipe.java
Normal file
58
src/main/java/kr/tkrmagid/mcp2p/net/Pipe.java
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/main/java/kr/tkrmagid/mcp2p/net/SelfSignedCert.java
Normal file
176
src/main/java/kr/tkrmagid/mcp2p/net/SelfSignedCert.java
Normal file
@@ -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 안에서 끝내면 그 변수가 없어진다.
|
||||
*
|
||||
* <p>인증서의 신뢰는 CA 가 아니라 <b>지문 고정</b>으로 얻는다. 참가자는 시그널링으로 받은
|
||||
* 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));
|
||||
}
|
||||
}
|
||||
146
src/main/java/kr/tkrmagid/mcp2p/net/Stun.java
Normal file
146
src/main/java/kr/tkrmagid/mcp2p/net/Stun.java
Normal file
@@ -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 주소) 알아내는 것이다.
|
||||
*
|
||||
* <p>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);
|
||||
}
|
||||
}
|
||||
18
src/main/java/kr/tkrmagid/mcp2p/net/Tunnel.java
Normal file
18
src/main/java/kr/tkrmagid/mcp2p/net/Tunnel.java
Normal file
@@ -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() {}
|
||||
}
|
||||
170
src/main/java/kr/tkrmagid/mcp2p/net/TunnelClient.java
Normal file
170
src/main/java/kr/tkrmagid/mcp2p/net/TunnelClient.java
Normal file
@@ -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<X509Certificate> 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("참가 터널 종료");
|
||||
}
|
||||
}
|
||||
}
|
||||
159
src/main/java/kr/tkrmagid/mcp2p/net/TunnelHost.java
Normal file
159
src/main/java/kr/tkrmagid/mcp2p/net/TunnelHost.java
Normal file
@@ -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 으로 내보낸다.
|
||||
*
|
||||
* <p>스트림 하나가 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<QuicConnection> 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);
|
||||
}
|
||||
}
|
||||
42
src/main/java/kr/tkrmagid/mcp2p/rendezvous/Protocol.java
Normal file
42
src/main/java/kr/tkrmagid/mcp2p/rendezvous/Protocol.java
Normal file
@@ -0,0 +1,42 @@
|
||||
package kr.tkrmagid.mcp2p.rendezvous;
|
||||
|
||||
/**
|
||||
* 시그널링 프로토콜. 줄 단위 텍스트다.
|
||||
*
|
||||
* <p>WebSocket 도 JSON 도 쓰지 않는다. 주고받는 메시지가 네 종류뿐이고 토큰에 공백이
|
||||
* 들어갈 일이 없어서, 평범한 TCP 위의 줄 기반 텍스트로 충분하다. 의존성이 0 이 되고
|
||||
* 서버 구현도 200줄 아래로 떨어진다.
|
||||
*
|
||||
* <pre>
|
||||
* 호스트: HOST <방ID> <인증서지문> <후보목록>
|
||||
* 서버 : OK
|
||||
* 서버 : PEER <참가자후보목록> <중계포트> (참가자가 올 때마다 밀어 준다)
|
||||
*
|
||||
* 참가자: JOIN <방ID> <후보목록>
|
||||
* 서버 : HOST <인증서지문> <후보목록> <중계포트>
|
||||
* 서버 : ERR <사유>
|
||||
*
|
||||
* 양쪽 : PING / PONG
|
||||
* </pre>
|
||||
*
|
||||
* <p>방 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() {}
|
||||
}
|
||||
184
src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousClient.java
Normal file
184
src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousClient.java
Normal file
@@ -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<Candidate> candidates, int relayPort) {}
|
||||
|
||||
/** 호스트가 받는 참가자 정보. */
|
||||
public record PeerInfo(List<Candidate> 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<String> replies = new ArrayBlockingQueue<>(16);
|
||||
private volatile Consumer<PeerInfo> 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<Candidate> candidates,
|
||||
Consumer<PeerInfo> 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<Candidate> 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<PeerInfo> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
390
src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousServer.java
Normal file
390
src/main/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousServer.java
Normal file
@@ -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;
|
||||
|
||||
/**
|
||||
* 자체 호스팅용 시그널링 + 중계 서버.
|
||||
*
|
||||
* <p>하는 일이 두 가지뿐이다.
|
||||
* <ol>
|
||||
* <li><b>시그널링</b> — 방 ID 로 호스트와 참가자가 서로의 후보 주소와 인증서 지문을 교환한다.</li>
|
||||
* <li><b>UDP 중계</b> — 홀펀칭이 실패했을 때를 위한 보험. 방마다가 아니라
|
||||
* <b>참가 세션마다</b> 포트 한 쌍을 배정해서 여러 참가자가 섞이지 않게 한다.</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>중계는 TURN 처럼 UDP 데이터그램을 그대로 넘긴다. 그래서 직결 경로와 중계 경로에서
|
||||
* 터널 코드가 완전히 동일하다 — 목적지 주소만 다르다. 그리고 QUIC 의 TLS 1.3 이 종단간
|
||||
* 이므로 <b>중계 서버는 지나가는 마인크래프트 트래픽을 읽을 수 없다.</b>
|
||||
*
|
||||
* <p>실행:
|
||||
* <pre>java -jar mc_p2p_mod-<버전>-rendezvous.jar [신호포트] [중계포트시작-중계포트끝]</pre>
|
||||
*/
|
||||
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<String, Room> 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 포트가 없다");
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터그램을 양방향으로 넘기는 포트 한 쌍.
|
||||
*
|
||||
* <p>각 방향의 상대 주소는 "그 포트로 마지막에 들어온 패킷의 발신지" 로 학습한다.
|
||||
* 호스트가 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();
|
||||
}
|
||||
}
|
||||
155
src/main/java/kr/tkrmagid/mcp2p/ui/Page.java
Normal file
155
src/main/java/kr/tkrmagid/mcp2p/ui/Page.java
Normal file
@@ -0,0 +1,155 @@
|
||||
package kr.tkrmagid.mcp2p.ui;
|
||||
|
||||
/** 웹 콘솔 페이지. 외부 리소스를 하나도 불러오지 않는 단일 HTML 이다(오프라인에서도 열린다). */
|
||||
final class Page {
|
||||
|
||||
private Page() {}
|
||||
|
||||
static final String HTML = """
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>mc_p2p 콘솔</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; padding:24px; background:#14161a; color:#e6e6e6;
|
||||
font:15px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif; }
|
||||
main { max-width:620px; margin:0 auto; }
|
||||
h1 { font-size:19px; margin:0 0 4px; }
|
||||
p.sub { margin:0 0 24px; color:#8a9199; font-size:13px; }
|
||||
section { background:#1c1f25; border:1px solid #2a2f37; border-radius:10px;
|
||||
padding:18px; margin-bottom:16px; }
|
||||
h2 { font-size:14px; margin:0 0 12px; color:#9fb4c7; letter-spacing:.02em; }
|
||||
input { width:100%; box-sizing:border-box; background:#0f1115; color:#e6e6e6;
|
||||
border:1px solid #333a44; border-radius:6px; padding:10px 12px;
|
||||
font:inherit; font-variant-numeric:tabular-nums; }
|
||||
button { background:#2f6feb; color:#fff; border:0; border-radius:6px;
|
||||
padding:10px 16px; font:inherit; cursor:pointer; margin-top:10px; }
|
||||
button.ghost { background:#2a2f37; }
|
||||
button:disabled { opacity:.45; cursor:default; }
|
||||
.code { font:600 22px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
letter-spacing:.06em; background:#0f1115; border:1px dashed #3a4250;
|
||||
border-radius:8px; padding:14px; text-align:center; user-select:all;
|
||||
word-break:break-all; }
|
||||
.row { display:flex; gap:10px; }
|
||||
.row button { flex:1; }
|
||||
dl { display:grid; grid-template-columns:auto 1fr; gap:6px 14px; margin:0; font-size:13px; }
|
||||
dt { color:#8a9199; }
|
||||
dd { margin:0; font-variant-numeric:tabular-nums; }
|
||||
.ok { color:#5ccf8f; } .bad { color:#f0736a; } .warn { color:#e0b44a; }
|
||||
.hint { color:#8a9199; font-size:12.5px; margin-top:10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body><main>
|
||||
<h1>mc_p2p 콘솔</h1>
|
||||
<p class="sub">게임을 재시작하지 않아도 이 페이지에서 바로 바뀝니다.</p>
|
||||
|
||||
<section id="cfg">
|
||||
<h2>시그널링 서버</h2>
|
||||
<input id="rv" placeholder="예: p2p.example.com:25580" autocomplete="off">
|
||||
<button id="save">저장</button>
|
||||
<div class="hint">직접 띄운 서버 주소입니다. 배포 방법은 저장소 README 를 보세요.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>방 만들기 (호스트)</h2>
|
||||
<div class="row">
|
||||
<button id="arm">호스트 준비</button>
|
||||
<button id="disarm" class="ghost">중지</button>
|
||||
</div>
|
||||
<div class="hint" id="armHint">준비를 누른 뒤 게임에서 <b>일시정지 → LAN 에 공개</b>를 누르면 방이 열립니다.</div>
|
||||
<div id="codeBox" style="display:none;margin-top:14px">
|
||||
<div class="code" id="code">-</div>
|
||||
<button id="copy" class="ghost">초대코드 복사</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>방 들어가기 (참가자)</h2>
|
||||
<input id="joinCode" placeholder="초대코드 붙여넣기" autocomplete="off">
|
||||
<div class="row">
|
||||
<button id="join">참가</button>
|
||||
<button id="leave" class="ghost">나가기</button>
|
||||
</div>
|
||||
<div class="hint">참가되면 게임의 <b>멀티플레이</b> 화면 LAN 목록에 방이 나타납니다. 그걸 누르면 접속됩니다.</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>상태</h2>
|
||||
<dl>
|
||||
<dt>시그널링</dt><dd id="s-rv">-</dd>
|
||||
<dt>STUN</dt><dd id="s-stun">-</dd>
|
||||
<dt>호스트</dt><dd id="s-host">-</dd>
|
||||
<dt>참가자 수</dt><dd id="s-players">-</dd>
|
||||
<dt>참가 상태</dt><dd id="s-join">-</dd>
|
||||
</dl>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
let codeShown = "";
|
||||
|
||||
async function post(path, data) {
|
||||
const body = new URLSearchParams(data || {}).toString();
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: {"Content-Type": "application/x-www-form-urlencoded"},
|
||||
body
|
||||
});
|
||||
const out = await res.json();
|
||||
if (!out.ok) alert(out.error || "실패했습니다");
|
||||
refresh();
|
||||
return out;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let s;
|
||||
try { s = await (await fetch("/api/status")).json(); } catch (e) { return; }
|
||||
|
||||
$("s-rv").textContent = s.configured ? s.rendezvous : "설정되지 않음";
|
||||
$("s-rv").className = s.configured ? "ok" : "bad";
|
||||
if (!document.activeElement || document.activeElement.id !== "rv") $("rv").value = s.rendezvous;
|
||||
$("s-stun").textContent = s.stun === "null" ? "꺼짐 (같은 LAN 전용)" : s.stun;
|
||||
|
||||
$("s-host").textContent = s.hostArmed
|
||||
? (s.inviteCode !== "null" ? "방 열림" : "LAN 공개 대기 중")
|
||||
: "중지";
|
||||
$("s-host").className = s.hostArmed ? (s.inviteCode !== "null" ? "ok" : "warn") : "";
|
||||
$("s-players").textContent = s.players;
|
||||
|
||||
const hasCode = s.inviteCode && s.inviteCode !== "null";
|
||||
$("codeBox").style.display = hasCode ? "block" : "none";
|
||||
if (hasCode) { $("code").textContent = s.inviteCode; codeShown = s.inviteCode; }
|
||||
|
||||
if (s.joined) {
|
||||
const how = s.joinRelay === "true" ? "중계" : "직결";
|
||||
$("s-join").textContent = "접속됨 · " + how + " " + s.joinRtt + "ms";
|
||||
$("s-join").className = "ok";
|
||||
} else if (s.joinError && s.joinError !== "null") {
|
||||
$("s-join").textContent = s.joinError;
|
||||
$("s-join").className = "bad";
|
||||
} else {
|
||||
$("s-join").textContent = "없음";
|
||||
$("s-join").className = "";
|
||||
}
|
||||
}
|
||||
|
||||
$("save").onclick = () => post("/api/config", {rendezvous: $("rv").value});
|
||||
$("arm").onclick = () => post("/api/host/arm");
|
||||
$("disarm").onclick = () => post("/api/host/disarm");
|
||||
$("join").onclick = () => post("/api/join", {code: $("joinCode").value});
|
||||
$("leave").onclick = () => post("/api/leave");
|
||||
$("copy").onclick = async () => {
|
||||
try { await navigator.clipboard.writeText(codeShown); $("copy").textContent = "복사됨"; }
|
||||
catch (e) { $("copy").textContent = "복사 실패 — 직접 선택하세요"; }
|
||||
setTimeout(() => $("copy").textContent = "초대코드 복사", 1500);
|
||||
};
|
||||
|
||||
refresh();
|
||||
setInterval(refresh, 1500);
|
||||
</script>
|
||||
</body></html>
|
||||
""";
|
||||
}
|
||||
262
src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java
Normal file
262
src/main/java/kr/tkrmagid/mcp2p/ui/WebConsole.java
Normal file
@@ -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:<포트>} 로 접속해서 초대코드를 넣고 방을 만든다.
|
||||
*
|
||||
* <p><b>왜 마인크래프트 GUI 를 만들지 않았는가.</b> 화면 클래스는 마인크래프트 버전마다
|
||||
* 이름과 시그니처가 바뀌는 대표적인 부분이다. 참고로 살펴본 기존 모드는 GUI 한 파일에
|
||||
* 버전 분기 주석이 96개 들어가 있었다. 조작 UI 를 게임 밖으로 빼면 그 유지보수 비용이
|
||||
* 통째로 사라지고, jar 하나로 모든 버전을 지원한다는 목표와도 맞는다.
|
||||
*
|
||||
* <p>HTTP 서버를 직접 구현한 이유는 {@code com.sun.net.httpserver} 가 {@code jdk.httpserver}
|
||||
* 모듈에 있어서 마인크래프트가 들고 다니는 런타임에 항상 포함된다고 보장할 수 없기 때문이다.
|
||||
* 필요한 것이 GET 몇 개와 POST 몇 개뿐이라 직접 쓰는 편이 안전하다.
|
||||
*
|
||||
* <p>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<String, String> 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<String, String> parseForm(String body) {
|
||||
Map<String, String> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/main/resources/fabric.mod.json
Normal file
19
src/main/resources/fabric.mod.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
124
src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java
Normal file
124
src/test/java/kr/tkrmagid/mcp2p/ArchitectureTest.java
Normal file
@@ -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 하나로 모든 마인크래프트 버전" 이라는 이 프로젝트의 유일한 구조적 약속을 강제하는 테스트.
|
||||
*
|
||||
* <p>약속이 깨지는 방식은 단 하나다 — 누군가 마인크래프트 클래스를 참조하는 것. 그 순간
|
||||
* 1.21.x(난독화 + intermediary 리매핑)와 26.x(비난독화, intermediary 폐지) 양쪽에서 동시에
|
||||
* 동작하는 것이 불가능해지고, 버전별 빌드로 되돌아가야 한다.
|
||||
*
|
||||
* <p>컴파일된 클래스 파일의 상수 풀을 직접 뒤진다. 상수 풀에는 참조하는 모든 클래스 이름이
|
||||
* UTF-8 문자열로 들어 있으므로, {@code net/minecraft} 가 한 번도 나오지 않는다면 마인크래프트
|
||||
* 타입을 참조하지 않는다는 뜻이다. import 문을 grep 하는 것과 달리 리플렉션이 아닌 실제
|
||||
* 바이트코드 수준의 보증이다.
|
||||
*/
|
||||
class ArchitectureTest {
|
||||
|
||||
/** 로더 API 를 참조해도 되는 유일한 클래스들. */
|
||||
private static final List<String> LOADER_ALLOWED = List.of("P2PMod.class", "Config.class");
|
||||
|
||||
@Test
|
||||
void 마인크래프트_클래스를_단_하나도_참조하지_않는다() throws IOException {
|
||||
List<String> 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<String> 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<Path> compiledClasses() throws IOException {
|
||||
Path root = classesRoot();
|
||||
try (Stream<Path> walk = Files.walk(root)) {
|
||||
return walk.filter(p -> p.toString().endsWith(".class")).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 우리 클래스가 실제로 로드된 디렉터리를 쓴다. 빌드 도구 배치에 덜 의존한다.
|
||||
*
|
||||
* <p>기준 클래스로 {@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");
|
||||
}
|
||||
}
|
||||
86
src/test/java/kr/tkrmagid/mcp2p/InviteCodeTest.java
Normal file
86
src/test/java/kr/tkrmagid/mcp2p/InviteCodeTest.java
Normal file
@@ -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<String> 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)));
|
||||
}
|
||||
}
|
||||
93
src/test/java/kr/tkrmagid/mcp2p/lan/LanBeaconTest.java
Normal file
93
src/test/java/kr/tkrmagid/mcp2p/lan/LanBeaconTest.java
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 목록에 끼어들 수 있다"
|
||||
* 는 주장이 이 테스트에 달려 있다.
|
||||
*
|
||||
* <p>컨테이너나 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(), "무시한 비콘의 시각까지 갱신하면 안 된다");
|
||||
}
|
||||
}
|
||||
}
|
||||
75
src/test/java/kr/tkrmagid/mcp2p/net/EchoServer.java
Normal file
75
src/test/java/kr/tkrmagid/mcp2p/net/EchoServer.java
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
170
src/test/java/kr/tkrmagid/mcp2p/net/NetPrimitivesTest.java
Normal file
170
src/test/java/kr/tkrmagid/mcp2p/net/NetPrimitivesTest.java
Normal file
@@ -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<Candidate> 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<Candidate> 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<Candidate> 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;
|
||||
}
|
||||
}
|
||||
250
src/test/java/kr/tkrmagid/mcp2p/net/TunnelDirectTest.java
Normal file
250
src/test/java/kr/tkrmagid/mcp2p/net/TunnelDirectTest.java
Normal file
@@ -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<Candidate> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 등)을 흉내내기 위해 호스트가
|
||||
* 직결 후보를 하나도 알려 주지 않게 만든다. 그러면 참가자는 중계밖에 쓸 수 없다.
|
||||
*
|
||||
* <p>여기서 확인하는 핵심은 <b>직결과 중계에서 터널 코드가 완전히 같다</b>는 점이다.
|
||||
* 중계가 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<Candidate> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
131
src/test/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousTest.java
Normal file
131
src/test/java/kr/tkrmagid/mcp2p/rendezvous/RendezvousTest.java
Normal file
@@ -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<Candidate> 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<RendezvousClient.PeerInfo> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/test/java/kr/tkrmagid/mcp2p/ui/WebConsoleTest.java
Normal file
158
src/test/java/kr/tkrmagid/mcp2p/ui/WebConsoleTest.java
Normal file
@@ -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<String> 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<String> 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<String> 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<String, String> 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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user