feat: send Login Disconnect with custom message on blocked join

Previously a blocked join just dropped the socket, so the MC client
showed 'Internal Exception: SocketException: Connection reset'.

Now when next_state=2 (login), the proxy sends a proper Login
Disconnect (0x00) packet containing a JSON chat component, and the
client displays the message on its disconnect screen.

- block_message added to config (default Korean message); editable
  in Settings UI as a textarea
- build_login_disconnect() encodes (varint length)+(0x00)+(JSON str)
- Status/ping (next_state=1) still silently dropped so the proxy
  presence is not announced to scanners
- Backward-compat: load_config() backfills block_message on old files
This commit is contained in:
2026-05-23 17:10:49 +09:00
parent 4b0d790748
commit 75c4242365
7 changed files with 79 additions and 4 deletions

View File

@@ -7,7 +7,7 @@
## 기능
- 마인크래프트 핸드셰이크 패킷에서 클라이언트가 입력한 server address 파싱
- 허용 도메인 화이트리스트 매칭, 불일치 시 즉시 연결 종료
- 허용 도메인 화이트리스트 매칭, 불일치 시 Login Disconnect 패킷으로 차단 사유(커스텀 가능) 표시 후 연결 종료
- 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계
(Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관)
- 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요)

View File

@@ -21,6 +21,7 @@ DEFAULT_CONFIG = {
"allowed_domains": [
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
],
"block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.",
}
_lock = threading.Lock()
@@ -33,7 +34,11 @@ def load_config() -> dict:
return json.loads(json.dumps(DEFAULT_CONFIG))
with _lock:
with CONFIG_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
cfg = json.load(f)
# 구버전 설정 파일 호환: 누락된 최상위 키는 기본값으로 채워준다
if "block_message" not in cfg:
cfg["block_message"] = DEFAULT_CONFIG["block_message"]
return cfg
def save_config(cfg: dict) -> None:

View File

@@ -29,6 +29,7 @@ class FullConfig(BaseModel):
proxy: ProxyConfig
backend: BackendConfig
allowed_domains: list[DomainEntry]
block_message: str = "이 서버는 허용된 도메인에서만 접속 가능합니다."
@router.get("/config")

View File

@@ -51,6 +51,23 @@ export default function Settings() {
프록시 활성화
</label>
</section>
<section className="card">
<h2>차단 메시지</h2>
<label>
허용되지 않은 도메인으로 접속 시도 마인크래프트 클라이언트에 보여줄 메시지
<textarea
rows={2}
value={cfg.block_message ?? ''}
onChange={(e) =>
setCfg({ ...cfg, block_message: e.target.value })
}
placeholder="이 서버는 허용된 도메인에서만 접속 가능합니다."
/>
<span className="muted">
로그인(join) 시도에만 적용됩니다. 서버 리스트 핑은 그냥 응답을 차단합니다.
</span>
</label>
</section>
<section className="card">
<h2>백엔드 (실제 MC 서버)</h2>
<label>

View File

@@ -159,7 +159,8 @@ button.mt { margin-top: 12px; }
input:disabled { opacity: 0.7; cursor: not-allowed; }
input,
select {
select,
textarea {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
@@ -168,8 +169,15 @@ select {
font-size: 14px;
font-family: inherit;
}
textarea {
width: 100%;
max-width: 520px;
resize: vertical;
margin-top: 4px;
}
input:focus,
select:focus {
select:focus,
textarea:focus {
outline: none;
border-color: var(--accent);
}

View File

@@ -19,6 +19,7 @@ DEFAULT_CONFIG = {
"allowed_domains": [
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
],
"block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.",
}
_lock = threading.Lock()

View File

@@ -10,6 +10,7 @@ asyncio 기반 TCP 프록시. 동작 순서:
from __future__ import annotations
import asyncio
import json
import logging
import os
import sqlite3
@@ -19,6 +20,39 @@ from pathlib import Path
import config as cfg_mod
from handshake import HandshakeError, parse_handshake, read_handshake_bytes
DEFAULT_BLOCK_MESSAGE = "이 서버는 허용된 도메인에서만 접속 가능합니다."
def _encode_varint(n: int) -> bytes:
out = bytearray()
while True:
b = n & 0x7F
n >>= 7
if n:
out.append(b | 0x80)
else:
out.append(b)
break
return bytes(out)
def build_login_disconnect(message: str) -> bytes:
"""Login Disconnect (clientbound, login state, packet id 0x00).
Body: JSON chat component as length-prefixed UTF-8 string.
클라이언트는 이 패킷을 받으면 "서버에서 연결을 거부했습니다" 화면 대신
여기 담긴 chat 컴포넌트를 그대로 보여준다.
"""
chat_json = json.dumps(
{"text": message, "color": "red"}, ensure_ascii=False
).encode("utf-8")
body = (
_encode_varint(0x00)
+ _encode_varint(len(chat_json))
+ chat_json
)
return _encode_varint(len(body)) + body
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
RESTART_SIGNAL = Path(os.environ.get("MC_RESTART_SIGNAL", "/data/restart.signal"))
@@ -184,6 +218,15 @@ async def handle_client(
log.info(
"BLOCK %s domain=%r next_state=%d", client_ip, domain, hs.next_state
)
# next_state=2 (login) 인 경우 Login Disconnect 패킷으로 메시지 전달,
# next_state=1 (status/ping) 은 그냥 끊는다 (프록시 존재 자체를 굳이 노출 X).
if hs.next_state == 2:
try:
msg = state.cfg.get("block_message") or DEFAULT_BLOCK_MESSAGE
client_writer.write(build_login_disconnect(msg))
await client_writer.drain()
except (OSError, ConnectionResetError, BrokenPipeError):
pass
client_writer.close()
return