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:
@@ -7,7 +7,7 @@
|
||||
## 기능
|
||||
|
||||
- 마인크래프트 핸드셰이크 패킷에서 클라이언트가 입력한 server address 파싱
|
||||
- 허용 도메인 화이트리스트 매칭, 불일치 시 즉시 연결 종료
|
||||
- 허용 도메인 화이트리스트 매칭, 불일치 시 Login Disconnect 패킷으로 차단 사유(커스텀 가능) 표시 후 연결 종료
|
||||
- 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계
|
||||
(Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관)
|
||||
- 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -29,6 +29,7 @@ class FullConfig(BaseModel):
|
||||
proxy: ProxyConfig
|
||||
backend: BackendConfig
|
||||
allowed_domains: list[DomainEntry]
|
||||
block_message: str = "이 서버는 허용된 도메인에서만 접속 가능합니다."
|
||||
|
||||
|
||||
@router.get("/config")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ DEFAULT_CONFIG = {
|
||||
"allowed_domains": [
|
||||
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
|
||||
],
|
||||
"block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.",
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user