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 파싱
|
- 마인크래프트 핸드셰이크 패킷에서 클라이언트가 입력한 server address 파싱
|
||||||
- 허용 도메인 화이트리스트 매칭, 불일치 시 즉시 연결 종료
|
- 허용 도메인 화이트리스트 매칭, 불일치 시 Login Disconnect 패킷으로 차단 사유(커스텀 가능) 표시 후 연결 종료
|
||||||
- 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계
|
- 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계
|
||||||
(Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관)
|
(Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관)
|
||||||
- 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요)
|
- 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ DEFAULT_CONFIG = {
|
|||||||
"allowed_domains": [
|
"allowed_domains": [
|
||||||
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
|
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
|
||||||
],
|
],
|
||||||
|
"block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.",
|
||||||
}
|
}
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
@@ -33,7 +34,11 @@ def load_config() -> dict:
|
|||||||
return json.loads(json.dumps(DEFAULT_CONFIG))
|
return json.loads(json.dumps(DEFAULT_CONFIG))
|
||||||
with _lock:
|
with _lock:
|
||||||
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
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:
|
def save_config(cfg: dict) -> None:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class FullConfig(BaseModel):
|
|||||||
proxy: ProxyConfig
|
proxy: ProxyConfig
|
||||||
backend: BackendConfig
|
backend: BackendConfig
|
||||||
allowed_domains: list[DomainEntry]
|
allowed_domains: list[DomainEntry]
|
||||||
|
block_message: str = "이 서버는 허용된 도메인에서만 접속 가능합니다."
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config")
|
@router.get("/config")
|
||||||
|
|||||||
@@ -51,6 +51,23 @@ export default function Settings() {
|
|||||||
프록시 활성화
|
프록시 활성화
|
||||||
</label>
|
</label>
|
||||||
</section>
|
</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">
|
<section className="card">
|
||||||
<h2>백엔드 (실제 MC 서버)</h2>
|
<h2>백엔드 (실제 MC 서버)</h2>
|
||||||
<label>
|
<label>
|
||||||
|
|||||||
@@ -159,7 +159,8 @@ button.mt { margin-top: 12px; }
|
|||||||
input:disabled { opacity: 0.7; cursor: not-allowed; }
|
input:disabled { opacity: 0.7; cursor: not-allowed; }
|
||||||
|
|
||||||
input,
|
input,
|
||||||
select {
|
select,
|
||||||
|
textarea {
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
@@ -168,8 +169,15 @@ select {
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
resize: vertical;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
input:focus,
|
input:focus,
|
||||||
select:focus {
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ DEFAULT_CONFIG = {
|
|||||||
"allowed_domains": [
|
"allowed_domains": [
|
||||||
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
|
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
|
||||||
],
|
],
|
||||||
|
"block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.",
|
||||||
}
|
}
|
||||||
|
|
||||||
_lock = threading.Lock()
|
_lock = threading.Lock()
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ asyncio 기반 TCP 프록시. 동작 순서:
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -19,6 +20,39 @@ from pathlib import Path
|
|||||||
import config as cfg_mod
|
import config as cfg_mod
|
||||||
from handshake import HandshakeError, parse_handshake, read_handshake_bytes
|
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"))
|
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
|
||||||
RESTART_SIGNAL = Path(os.environ.get("MC_RESTART_SIGNAL", "/data/restart.signal"))
|
RESTART_SIGNAL = Path(os.environ.get("MC_RESTART_SIGNAL", "/data/restart.signal"))
|
||||||
|
|
||||||
@@ -184,6 +218,15 @@ async def handle_client(
|
|||||||
log.info(
|
log.info(
|
||||||
"BLOCK %s domain=%r next_state=%d", client_ip, domain, hs.next_state
|
"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()
|
client_writer.close()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user