feat: implement MC domain filter proxy, API, dashboard
- proxy: asyncio TCP proxy with handshake parser, domain whitelist, transparent backend tunneling, SQLite logging, mtime hot reload - api: FastAPI routes for config/domains/logs/status + restart trigger - frontend: React + Vite NPM-style dashboard (dashboard/domains/logs/settings) - nginx: reverse proxy for /api -> api:8000 and / -> frontend:3000 - docker-compose: full stack with shared data volume - replace spec mc-domain-filter.md with README.md
This commit is contained in:
13
proxy/Dockerfile
Normal file
13
proxy/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt ./
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY *.py ./
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
EXPOSE 25565
|
||||
|
||||
CMD ["python", "main.py"]
|
||||
58
proxy/config.py
Normal file
58
proxy/config.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""프록시용 config 로더.
|
||||
|
||||
API 서비스와 동일한 `data/config.json` 파일을 공유 볼륨으로 읽는다.
|
||||
atomic rename(tempfile + os.replace) 으로 갱신되기 때문에 mtime polling
|
||||
방식으로 안전하게 hot reload 가 가능하다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_PATH = Path(os.environ.get("MC_CONFIG_PATH", "/data/config.json"))
|
||||
|
||||
DEFAULT_CONFIG = {
|
||||
"proxy": {"listen_port": 25565, "enabled": True},
|
||||
"backend": {"host": "127.0.0.1", "port": 25565},
|
||||
"allowed_domains": [
|
||||
{"domain": "mc.tkrmagid.kr", "enabled": True, "note": "메인 도메인"}
|
||||
],
|
||||
}
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def load() -> dict:
|
||||
if not CONFIG_PATH.exists():
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
save(DEFAULT_CONFIG)
|
||||
return dict(DEFAULT_CONFIG)
|
||||
with _lock:
|
||||
with CONFIG_PATH.open("r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save(cfg: dict) -> None:
|
||||
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = CONFIG_PATH.with_suffix(".json.tmp")
|
||||
with _lock:
|
||||
with tmp.open("w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, ensure_ascii=False, indent=2)
|
||||
os.replace(tmp, CONFIG_PATH)
|
||||
|
||||
|
||||
def mtime() -> float:
|
||||
try:
|
||||
return CONFIG_PATH.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def allowed_domain_set(cfg: dict) -> set[str]:
|
||||
return {
|
||||
d["domain"].lower().strip()
|
||||
for d in cfg.get("allowed_domains", [])
|
||||
if d.get("enabled", True)
|
||||
}
|
||||
105
proxy/handshake.py
Normal file
105
proxy/handshake.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Minecraft 핸드셰이크 패킷 파서.
|
||||
|
||||
마인크래프트 클라이언트는 서버에 접속하면 첫 패킷으로 사용자가 입력한
|
||||
서버 주소(string)를 포함한 handshake 패킷을 보낸다. 이 모듈은 그 첫
|
||||
패킷을 잘라서 (protocol_version, server_address, server_port, next_state)
|
||||
를 반환한다.
|
||||
|
||||
패킷 구조:
|
||||
[varint] Packet Length
|
||||
[varint] Packet ID (0x00)
|
||||
[varint] Protocol Version
|
||||
[varint] Server Address Length
|
||||
[string] Server Address ← 클라이언트가 입력한 주소
|
||||
[ushort] Server Port
|
||||
[varint] Next State (1=status, 2=login)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class HandshakeError(Exception):
|
||||
"""핸드셰이크 패킷이 비정상일 때."""
|
||||
|
||||
|
||||
def read_varint(data: bytes, offset: int = 0) -> tuple[int, int]:
|
||||
"""(value, bytes_consumed) 반환. 5바이트를 넘으면 오류."""
|
||||
value = 0
|
||||
shift = 0
|
||||
pos = offset
|
||||
while True:
|
||||
if pos >= len(data):
|
||||
raise HandshakeError("varint truncated")
|
||||
byte = data[pos]
|
||||
pos += 1
|
||||
value |= (byte & 0x7F) << shift
|
||||
if not (byte & 0x80):
|
||||
break
|
||||
shift += 7
|
||||
if shift >= 35:
|
||||
raise HandshakeError("varint too long")
|
||||
return value, pos - offset
|
||||
|
||||
|
||||
@dataclass
|
||||
class Handshake:
|
||||
protocol_version: int
|
||||
server_address: str
|
||||
server_port: int
|
||||
next_state: int # 1 = status, 2 = login
|
||||
|
||||
|
||||
def parse_handshake(buf: bytes) -> Handshake:
|
||||
"""버퍼 시작 위치에서 핸드셰이크 패킷을 파싱."""
|
||||
pkt_len, n = read_varint(buf, 0)
|
||||
pos = n
|
||||
if len(buf) < pos + pkt_len:
|
||||
raise HandshakeError("packet truncated")
|
||||
pkt_id, n = read_varint(buf, pos)
|
||||
pos += n
|
||||
if pkt_id != 0x00:
|
||||
raise HandshakeError(f"unexpected packet id 0x{pkt_id:02x}")
|
||||
proto, n = read_varint(buf, pos)
|
||||
pos += n
|
||||
addr_len, n = read_varint(buf, pos)
|
||||
pos += n
|
||||
if addr_len < 0 or addr_len > 255 or pos + addr_len > len(buf):
|
||||
raise HandshakeError("invalid address length")
|
||||
address = buf[pos : pos + addr_len].decode("utf-8", errors="replace")
|
||||
pos += addr_len
|
||||
if pos + 2 > len(buf):
|
||||
raise HandshakeError("port truncated")
|
||||
port = int.from_bytes(buf[pos : pos + 2], "big")
|
||||
pos += 2
|
||||
next_state, _ = read_varint(buf, pos)
|
||||
# Forge / BungeeCord 등이 \x00 으로 메타데이터를 붙이는 경우 있음
|
||||
address = address.split("\x00", 1)[0].strip()
|
||||
return Handshake(proto, address, port, next_state)
|
||||
|
||||
|
||||
async def read_handshake_bytes(reader: asyncio.StreamReader, max_bytes: int = 2048) -> bytes:
|
||||
"""길이 varint 를 보고 정확히 첫 패킷 분량만 읽는다.
|
||||
|
||||
읽은 바이트는 그대로 보존해서 백엔드로 그대로 forward 할 수 있게 한다.
|
||||
"""
|
||||
buf = bytearray()
|
||||
# length varint: 최대 5바이트
|
||||
for _ in range(5):
|
||||
chunk = await reader.readexactly(1)
|
||||
buf.extend(chunk)
|
||||
if not (chunk[0] & 0x80):
|
||||
break
|
||||
else:
|
||||
raise HandshakeError("packet length varint too long")
|
||||
pkt_len, n = read_varint(bytes(buf), 0)
|
||||
if pkt_len <= 0 or pkt_len > max_bytes:
|
||||
raise HandshakeError(f"unreasonable packet length: {pkt_len}")
|
||||
remaining = pkt_len - (len(buf) - n)
|
||||
if remaining < 0:
|
||||
raise HandshakeError("inconsistent packet length")
|
||||
if remaining > 0:
|
||||
rest = await reader.readexactly(remaining)
|
||||
buf.extend(rest)
|
||||
return bytes(buf)
|
||||
278
proxy/main.py
Normal file
278
proxy/main.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""MC Domain Filter Proxy.
|
||||
|
||||
asyncio 기반 TCP 프록시. 동작 순서:
|
||||
1) 클라이언트가 연결되면 첫 핸드셰이크 패킷을 읽는다.
|
||||
2) 패킷에서 server_address 를 꺼내 허용 도메인 목록과 대조한다.
|
||||
3) 허용되면 백엔드 MC 서버에 연결하고, 받은 핸드셰이크 바이트를 그대로
|
||||
forward 한 뒤 양방향으로 TCP 를 중계한다.
|
||||
4) 허용되지 않으면 즉시 연결을 종료한다 (응답을 보내지 않음).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import config as cfg_mod
|
||||
from handshake import HandshakeError, parse_handshake, read_handshake_bytes
|
||||
|
||||
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("proxy")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Log DB
|
||||
# ---------------------------------------------------------------------------
|
||||
def init_db() -> None:
|
||||
LOG_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
con = sqlite3.connect(LOG_DB)
|
||||
con.execute("PRAGMA journal_mode=WAL;")
|
||||
con.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS connections (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts REAL NOT NULL,
|
||||
client_ip TEXT NOT NULL,
|
||||
domain TEXT,
|
||||
next_state INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
reason TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
con.execute("CREATE INDEX IF NOT EXISTS idx_connections_ts ON connections(ts);")
|
||||
con.commit()
|
||||
con.close()
|
||||
|
||||
|
||||
def log_event(
|
||||
client_ip: str,
|
||||
domain: str | None,
|
||||
next_state: int | None,
|
||||
action: str,
|
||||
reason: str = "",
|
||||
) -> None:
|
||||
try:
|
||||
con = sqlite3.connect(LOG_DB, timeout=2)
|
||||
con.execute(
|
||||
"INSERT INTO connections(ts, client_ip, domain, next_state, action, reason) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(time.time(), client_ip, domain, next_state, action, reason),
|
||||
)
|
||||
con.commit()
|
||||
con.close()
|
||||
except Exception as exc: # noqa: BLE001 - 로그 실패는 본 흐름을 막지 않는다
|
||||
log.warning("log write failed: %s", exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime state
|
||||
# ---------------------------------------------------------------------------
|
||||
class ProxyState:
|
||||
def __init__(self) -> None:
|
||||
self.cfg = cfg_mod.load()
|
||||
self.cfg_mtime = cfg_mod.mtime()
|
||||
self.listen_port: int = int(self.cfg["proxy"]["listen_port"])
|
||||
|
||||
def allowed(self) -> set[str]:
|
||||
return cfg_mod.allowed_domain_set(self.cfg)
|
||||
|
||||
def backend(self) -> tuple[str, int]:
|
||||
b = self.cfg["backend"]
|
||||
return b["host"], int(b["port"])
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.cfg.get("proxy", {}).get("enabled", True))
|
||||
|
||||
def reload_if_changed(self) -> bool:
|
||||
m = cfg_mod.mtime()
|
||||
if m == self.cfg_mtime:
|
||||
return False
|
||||
try:
|
||||
self.cfg = cfg_mod.load()
|
||||
self.cfg_mtime = m
|
||||
log.info(
|
||||
"config reloaded: enabled=%s backend=%s domains=%s",
|
||||
self.enabled(),
|
||||
self.backend(),
|
||||
sorted(self.allowed()),
|
||||
)
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("config reload failed: %s", exc)
|
||||
return False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TCP tunneling
|
||||
# ---------------------------------------------------------------------------
|
||||
async def _pipe(
|
||||
reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
||||
) -> None:
|
||||
try:
|
||||
while True:
|
||||
data = await reader.read(8192)
|
||||
if not data:
|
||||
break
|
||||
writer.write(data)
|
||||
await writer.drain()
|
||||
except (ConnectionResetError, BrokenPipeError, asyncio.IncompleteReadError):
|
||||
pass
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.debug("pipe error: %s", exc)
|
||||
finally:
|
||||
try:
|
||||
writer.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
async def handle_client(
|
||||
client_reader: asyncio.StreamReader,
|
||||
client_writer: asyncio.StreamWriter,
|
||||
state: ProxyState,
|
||||
) -> None:
|
||||
peer = client_writer.get_extra_info("peername") or ("?", 0)
|
||||
client_ip = peer[0]
|
||||
|
||||
if not state.enabled():
|
||||
log_event(client_ip, None, None, "blocked", "proxy disabled")
|
||||
client_writer.close()
|
||||
return
|
||||
|
||||
try:
|
||||
hs_bytes = await asyncio.wait_for(
|
||||
read_handshake_bytes(client_reader), timeout=5
|
||||
)
|
||||
hs = parse_handshake(hs_bytes)
|
||||
except (HandshakeError, asyncio.TimeoutError, asyncio.IncompleteReadError, OSError) as exc:
|
||||
log_event(client_ip, None, None, "blocked", f"handshake error: {exc}")
|
||||
log.info("BLOCK %s reason=handshake_error (%s)", client_ip, exc)
|
||||
client_writer.close()
|
||||
return
|
||||
|
||||
domain = hs.server_address.lower().strip()
|
||||
allowed = state.allowed()
|
||||
if domain not in allowed:
|
||||
log_event(client_ip, domain, hs.next_state, "blocked", "domain not allowed")
|
||||
log.info(
|
||||
"BLOCK %s domain=%r next_state=%d", client_ip, domain, hs.next_state
|
||||
)
|
||||
client_writer.close()
|
||||
return
|
||||
|
||||
backend_host, backend_port = state.backend()
|
||||
try:
|
||||
backend_reader, backend_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(backend_host, backend_port), timeout=5
|
||||
)
|
||||
except (OSError, asyncio.TimeoutError) as exc:
|
||||
log_event(
|
||||
client_ip, domain, hs.next_state, "error", f"backend connect failed: {exc}"
|
||||
)
|
||||
log.warning(
|
||||
"ERROR %s domain=%r backend=%s:%d msg=%s",
|
||||
client_ip,
|
||||
domain,
|
||||
backend_host,
|
||||
backend_port,
|
||||
exc,
|
||||
)
|
||||
client_writer.close()
|
||||
return
|
||||
|
||||
log_event(client_ip, domain, hs.next_state, "allowed")
|
||||
log.info(
|
||||
"PASS %s -> %s:%d domain=%r next_state=%d",
|
||||
client_ip,
|
||||
backend_host,
|
||||
backend_port,
|
||||
domain,
|
||||
hs.next_state,
|
||||
)
|
||||
|
||||
# 백엔드로 캡처해둔 첫 핸드셰이크 바이트를 그대로 forward
|
||||
backend_writer.write(hs_bytes)
|
||||
try:
|
||||
await backend_writer.drain()
|
||||
except Exception: # noqa: BLE001
|
||||
client_writer.close()
|
||||
return
|
||||
|
||||
await asyncio.gather(
|
||||
_pipe(client_reader, backend_writer),
|
||||
_pipe(backend_reader, client_writer),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Listener lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
class Listener:
|
||||
def __init__(self, state: ProxyState) -> None:
|
||||
self.state = state
|
||||
self.server: asyncio.base_events.Server | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
if not self.state.enabled():
|
||||
log.info("proxy disabled by config; not listening")
|
||||
return
|
||||
self.server = await asyncio.start_server(
|
||||
lambda r, w: handle_client(r, w, self.state),
|
||||
host="0.0.0.0",
|
||||
port=self.state.listen_port,
|
||||
)
|
||||
log.info("listening on 0.0.0.0:%d", self.state.listen_port)
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self.server is not None:
|
||||
self.server.close()
|
||||
await self.server.wait_closed()
|
||||
self.server = None
|
||||
log.info("listener stopped")
|
||||
|
||||
async def restart(self) -> None:
|
||||
await self.stop()
|
||||
await self.start()
|
||||
|
||||
|
||||
async def config_watcher(state: ProxyState, listener: Listener) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(2)
|
||||
old_port = state.listen_port
|
||||
old_enabled = state.enabled()
|
||||
if not state.reload_if_changed():
|
||||
continue
|
||||
new_port = int(state.cfg["proxy"]["listen_port"])
|
||||
new_enabled = state.enabled()
|
||||
if new_port != old_port or new_enabled != old_enabled:
|
||||
state.listen_port = new_port
|
||||
await listener.restart()
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
init_db()
|
||||
state = ProxyState()
|
||||
listener = Listener(state)
|
||||
await listener.start()
|
||||
watcher = asyncio.create_task(config_watcher(state, listener))
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
watcher.cancel()
|
||||
await listener.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
1
proxy/requirements.txt
Normal file
1
proxy/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
# 프록시는 stdlib (asyncio, sqlite3, json) 만 사용한다.
|
||||
Reference in New Issue
Block a user