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:
2026-05-20 16:39:18 +09:00
parent b45e884633
commit d10dae5cb9
33 changed files with 1872 additions and 223 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# 런타임 데이터 (컨테이너가 생성)
data/config.json
data/logs.db
data/logs.db-wal
data/logs.db-shm
# 노드/파이썬 빌드 산출물
node_modules/
dist/
.vite/
__pycache__/
*.pyc
*.pyo
# 에디터/OS
.vscode/
.idea/
.DS_Store
*.swp
# 환경변수
.env
.env.*

175
README.md Normal file
View File

@@ -0,0 +1,175 @@
# MC Domain Filter Proxy
마인크래프트 서버 앞단에 두는 **도메인 화이트리스트 프록시 + 웹 관리 대시보드**.
클라이언트가 마인크래프트 서버 주소창에 입력한 도메인이 허용 목록과 일치할 때만 백엔드 MC 서버로 연결을 통과시킵니다. 외부에서 공유기의 공인 IP를 직접 입력해서 접속하는 경로를 차단할 수 있습니다.
## 기능
- 마인크래프트 핸드셰이크 패킷에서 클라이언트가 입력한 server address 파싱
- 허용 도메인 화이트리스트 매칭, 불일치 시 즉시 연결 종료
- 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계
(Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관)
- 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요)
- 모든 연결 시도(허용 / 차단 / 에러)를 SQLite 에 기록
- 웹 대시보드 (NPM 스타일): 도메인 관리, 실시간 로그, 통계 카드, 백엔드/포트 설정
## 네트워크 구조
```
외부 인터넷
공유기 (포트포워딩 25565 → Proxmox)
mc-filter-proxy 컨테이너 (25565)
게임PC 내부IP:25565 (실제 마인크래프트 서버)
```
## 빠른 시작
1. 저장소 클론
```bash
git clone https://git.tkrmagid.kr/tkrmagid/mc_domain_proxy.git
cd mc_domain_proxy
```
2. (선택) 초기 설정 파일을 미리 생성. 생략하면 프록시 첫 기동 시 기본값으로 자동 생성됩니다.
```bash
mkdir -p data
cat > data/config.json <<'EOF'
{
"proxy": { "listen_port": 25565, "enabled": true },
"backend": { "host": "192.168.0.20", "port": 25565 },
"allowed_domains": [
{ "domain": "mc.tkrmagid.kr", "enabled": true, "note": "메인 도메인" }
]
}
EOF
```
3. 전체 스택 빌드 & 실행
```bash
docker compose up -d --build
```
4. 접속
- **마인크래프트 클라이언트**: `mc.tkrmagid.kr` (또는 등록한 도메인)
- **대시보드**: `http://<Proxmox-IP>:8080`
## 구성 요소
| 서비스 | 포트 | 설명 |
|-------------|-----------------|----------------------------------------|
| `proxy` | 25565 (외부) | asyncio TCP 프록시, 핸드셰이크 파싱 |
| `api` | 8000 (내부) | FastAPI, 설정/로그/통계 REST API |
| `frontend` | 3000 (내부) | React + Vite SPA |
| `nginx` | 8080 (외부) | 대시보드 리버스 프록시 |
`data/` 디렉터리는 모든 서비스가 공유 볼륨으로 마운트해 `config.json`, `logs.db` 를 공용합니다.
## API
| 메서드 | 경로 | 설명 |
|--------|---------------------------------|----------------------------------------|
| GET | `/api/config` | 전체 설정 조회 |
| PUT | `/api/config` | 전체 설정 저장 |
| GET | `/api/domains` | 허용 도메인 목록 |
| POST | `/api/domains` | 도메인 추가 |
| PATCH | `/api/domains/{domain}` | 활성/메모 변경 |
| DELETE | `/api/domains/{domain}` | 도메인 삭제 |
| GET | `/api/logs?limit&offset&action` | 접속 로그 (페이지네이션) |
| GET | `/api/status` | 프록시 상태 + 통계 |
| POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) |
## Hot reload 동작
- API 가 `data/config.json` 을 atomic rename (`tempfile + os.replace`) 으로 갱신
- 프록시가 2초 간격으로 mtime 폴링, 변경 감지 시 메모리 캐시 재로드
- `listen_port` 나 `proxy.enabled` 가 바뀌면 리스너 자체를 재시작, 그 외(도메인/백엔드)는 다음 연결부터 즉시 적용
## 보안 권장
- **대시보드 포트(8080)는 외부 포트포워딩 금지.** Proxmox 내부망 / VPN / SSH 터널을 통해서만 접근하세요.
- API 에는 인증이 없으므로 외부에 노출해야 하는 경우 nginx 앞단에 basic auth 또는 OAuth proxy 를 추가하세요.
- 프록시 컨테이너는 핸드셰이크 단계에서만 패킷을 검사하고, 그 외에는 단순 TCP 중계라서 MC 프로토콜 변경에 영향을 받지 않습니다.
## 디렉터리 구조
```
.
├── proxy/ # asyncio TCP 프록시
│ ├── main.py
│ ├── handshake.py
│ ├── config.py
│ ├── requirements.txt
│ └── Dockerfile
├── api/ # FastAPI 백엔드
│ ├── main.py
│ ├── config_io.py
│ ├── routes/
│ │ ├── config.py
│ │ ├── domains.py
│ │ ├── logs.py
│ │ └── status.py
│ ├── requirements.txt
│ └── Dockerfile
├── frontend/ # React + Vite 대시보드
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Dashboard.jsx
│ │ │ ├── Domains.jsx
│ │ │ ├── Logs.jsx
│ │ │ └── Settings.jsx
│ │ ├── App.jsx
│ │ ├── api.js
│ │ ├── main.jsx
│ │ └── styles.css
│ ├── index.html
│ ├── vite.config.js
│ ├── package.json
│ └── Dockerfile
├── nginx/
│ └── nginx.conf
├── data/ # 런타임 데이터 (git 무시; config.json, logs.db)
└── docker-compose.yml
```
## 로컬 개발 (Docker 없이)
각 서비스는 독립적으로 실행 가능합니다.
```bash
# proxy
cd proxy
MC_CONFIG_PATH=$(pwd)/../data/config.json \
MC_LOG_DB=$(pwd)/../data/logs.db \
python main.py
# api (다른 터미널)
cd api
pip install -r requirements.txt
MC_CONFIG_PATH=$(pwd)/../data/config.json \
MC_LOG_DB=$(pwd)/../data/logs.db \
uvicorn main:app --reload --port 8000
# frontend (또 다른 터미널)
cd frontend
npm install
npm run dev # http://localhost:3000, /api 는 8000 으로 자동 프록시
```
## 환경 변수
| 변수 | 기본값 | 적용 서비스 |
|-------------------|--------------------|-------------------|
| `MC_CONFIG_PATH` | `/data/config.json`| proxy, api |
| `MC_LOG_DB` | `/data/logs.db` | proxy, api |
## 라이선스
내부 사용 (TkrMagid).

13
api/Dockerfile Normal file
View 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 . ./
ENV PYTHONUNBUFFERED=1
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

43
api/config_io.py Normal file
View File

@@ -0,0 +1,43 @@
"""API 측 config IO 헬퍼.
프록시와 동일한 파일을 가리키고, atomic rename 으로 갱신해서 프록시가
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"))
LOG_DB = Path(os.environ.get("MC_LOG_DB", "/data/logs.db"))
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_config() -> dict:
if not CONFIG_PATH.exists():
CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
save_config(DEFAULT_CONFIG)
return json.loads(json.dumps(DEFAULT_CONFIG))
with _lock:
with CONFIG_PATH.open("r", encoding="utf-8") as f:
return json.load(f)
def save_config(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)

33
api/main.py Normal file
View File

@@ -0,0 +1,33 @@
"""MC Domain Filter 관리 API.
FastAPI 로 설정/도메인/로그/상태 4개 라우터를 노출한다. 인증은 없으니
공개 네트워크에 직접 노출하지 말 것 (nginx + basic auth 권장).
"""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from routes import config as config_routes
from routes import domains as domain_routes
from routes import logs as log_routes
from routes import status as status_routes
app = FastAPI(title="MC Domain Filter API", version="0.1.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(config_routes.router, prefix="/api", tags=["config"])
app.include_router(domain_routes.router, prefix="/api", tags=["domains"])
app.include_router(log_routes.router, prefix="/api", tags=["logs"])
app.include_router(status_routes.router, prefix="/api", tags=["status"])
@app.get("/health")
def health() -> dict:
return {"ok": True}

3
api/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
fastapi==0.115.0
uvicorn[standard]==0.30.6
pydantic==2.9.2

0
api/routes/__init__.py Normal file
View File

42
api/routes/config.py Normal file
View File

@@ -0,0 +1,42 @@
"""전체 설정 조회/저장."""
from __future__ import annotations
from fastapi import APIRouter
from pydantic import BaseModel, Field
from config_io import load_config, save_config
router = APIRouter()
class ProxyConfig(BaseModel):
listen_port: int = Field(ge=1, le=65535)
enabled: bool
class BackendConfig(BaseModel):
host: str
port: int = Field(ge=1, le=65535)
class DomainEntry(BaseModel):
domain: str
enabled: bool = True
note: str = ""
class FullConfig(BaseModel):
proxy: ProxyConfig
backend: BackendConfig
allowed_domains: list[DomainEntry]
@router.get("/config")
def get_config() -> dict:
return load_config()
@router.put("/config")
def put_config(body: FullConfig) -> dict:
save_config(body.model_dump())
return load_config()

69
api/routes/domains.py Normal file
View File

@@ -0,0 +1,69 @@
"""허용 도메인 CRUD."""
from __future__ import annotations
from fastapi import APIRouter, HTTPException, Response
from pydantic import BaseModel
from config_io import load_config, save_config
router = APIRouter()
class DomainCreate(BaseModel):
domain: str
enabled: bool = True
note: str = ""
class DomainPatch(BaseModel):
enabled: bool | None = None
note: str | None = None
@router.get("/domains")
def list_domains() -> list[dict]:
return load_config().get("allowed_domains", [])
@router.post("/domains", status_code=201)
def add_domain(body: DomainCreate) -> dict:
cfg = load_config()
domains = cfg.setdefault("allowed_domains", [])
name = body.domain.strip().lower()
if not name:
raise HTTPException(status_code=400, detail="domain required")
if any(d["domain"].lower() == name for d in domains):
raise HTTPException(status_code=409, detail="domain already exists")
entry = {"domain": name, "enabled": body.enabled, "note": body.note}
domains.append(entry)
save_config(cfg)
return entry
@router.delete("/domains/{domain}", status_code=204)
def delete_domain(domain: str) -> Response:
cfg = load_config()
name = domain.strip().lower()
before = len(cfg.get("allowed_domains", []))
cfg["allowed_domains"] = [
d for d in cfg.get("allowed_domains", []) if d["domain"].lower() != name
]
if len(cfg["allowed_domains"]) == before:
raise HTTPException(status_code=404, detail="domain not found")
save_config(cfg)
return Response(status_code=204)
@router.patch("/domains/{domain}")
def patch_domain(domain: str, body: DomainPatch) -> dict:
cfg = load_config()
name = domain.strip().lower()
for d in cfg.get("allowed_domains", []):
if d["domain"].lower() == name:
if body.enabled is not None:
d["enabled"] = body.enabled
if body.note is not None:
d["note"] = body.note
save_config(cfg)
return d
raise HTTPException(status_code=404, detail="domain not found")

38
api/routes/logs.py Normal file
View File

@@ -0,0 +1,38 @@
"""접속 로그 조회."""
from __future__ import annotations
import sqlite3
from fastapi import APIRouter, Query
from config_io import LOG_DB
router = APIRouter()
@router.get("/logs")
def list_logs(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
action: str | None = Query(None),
) -> dict:
if not LOG_DB.exists():
return {"total": 0, "items": []}
con = sqlite3.connect(LOG_DB)
try:
con.row_factory = sqlite3.Row
where, params = "", []
if action:
where = "WHERE action = ?"
params.append(action)
total = con.execute(
f"SELECT COUNT(*) FROM connections {where}", params
).fetchone()[0]
rows = con.execute(
f"SELECT id, ts, client_ip, domain, next_state, action, reason "
f"FROM connections {where} ORDER BY id DESC LIMIT ? OFFSET ?",
[*params, limit, offset],
).fetchall()
finally:
con.close()
return {"total": total, "items": [dict(r) for r in rows]}

59
api/routes/status.py Normal file
View File

@@ -0,0 +1,59 @@
"""프록시 상태 및 통계."""
from __future__ import annotations
import sqlite3
import time
from fastapi import APIRouter
from config_io import LOG_DB, load_config, save_config
router = APIRouter()
@router.get("/status")
def status() -> dict:
cfg = load_config()
total = allowed = blocked = errored = 0
last_ts: float | None = None
if LOG_DB.exists():
con = sqlite3.connect(LOG_DB)
try:
total = con.execute("SELECT COUNT(*) FROM connections").fetchone()[0]
allowed = con.execute(
"SELECT COUNT(*) FROM connections WHERE action='allowed'"
).fetchone()[0]
blocked = con.execute(
"SELECT COUNT(*) FROM connections WHERE action='blocked'"
).fetchone()[0]
errored = con.execute(
"SELECT COUNT(*) FROM connections WHERE action='error'"
).fetchone()[0]
row = con.execute(
"SELECT ts FROM connections ORDER BY id DESC LIMIT 1"
).fetchone()
last_ts = row[0] if row else None
finally:
con.close()
return {
"proxy_enabled": cfg.get("proxy", {}).get("enabled", True),
"listen_port": cfg.get("proxy", {}).get("listen_port"),
"backend": cfg.get("backend"),
"domain_count": len(cfg.get("allowed_domains", [])),
"stats": {
"total": total,
"allowed": allowed,
"blocked": blocked,
"error": errored,
"last_event_ts": last_ts,
},
"server_time": time.time(),
}
@router.post("/proxy/restart")
def restart_proxy() -> dict:
"""config 파일 mtime 을 갱신해서 프록시 watcher 가 재로드하게 한다."""
cfg = load_config()
save_config(cfg)
return {"ok": True, "note": "config touched; proxy will reload"}

0
data/.gitkeep Normal file
View File

49
docker-compose.yml Normal file
View File

@@ -0,0 +1,49 @@
services:
proxy:
build: ./proxy
container_name: mc-filter-proxy
ports:
- "25565:25565"
volumes:
- ./data:/data
restart: unless-stopped
networks:
- mc-filter
api:
build: ./api
container_name: mc-filter-api
expose:
- "8000"
volumes:
- ./data:/data
restart: unless-stopped
networks:
- mc-filter
frontend:
build: ./frontend
container_name: mc-filter-frontend
expose:
- "3000"
restart: unless-stopped
networks:
- mc-filter
nginx:
image: nginx:alpine
container_name: mc-filter-nginx
ports:
- "8080:80" # 대시보드 접근 포트 (외부 포트포워딩 금지 권장)
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- api
- frontend
restart: unless-stopped
networks:
- mc-filter
networks:
mc-filter:
driver: bridge

3
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.vite

17
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# 빌드 단계
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json ./
RUN npm install --no-audit --no-fund
COPY . ./
RUN npm run build
# 서빙 단계: vite preview 로 dist/ 정적 서빙
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/package.json ./
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/vite.config.js ./
EXPOSE 3000
CMD ["npx", "vite", "preview", "--host", "0.0.0.0", "--port", "3000"]

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MC Domain Filter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

20
frontend/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "mc-domain-filter-frontend",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview --host 0.0.0.0 --port 3000"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.2"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.6"
}
}

32
frontend/src/App.jsx Normal file
View File

@@ -0,0 +1,32 @@
import { NavLink, Route, Routes } from 'react-router-dom'
import Dashboard from './pages/Dashboard.jsx'
import Domains from './pages/Domains.jsx'
import Logs from './pages/Logs.jsx'
import Settings from './pages/Settings.jsx'
export default function App() {
return (
<div className="layout">
<aside className="sidebar">
<div className="logo">
<span className="dot" /> MC Filter
</div>
<nav>
<NavLink to="/" end>대시보드</NavLink>
<NavLink to="/domains">허용 도메인</NavLink>
<NavLink to="/logs">접속 로그</NavLink>
<NavLink to="/settings">설정</NavLink>
</nav>
<div className="sidebar-footer">v0.1.0</div>
</aside>
<main className="content">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/domains" element={<Domains />} />
<Route path="/logs" element={<Logs />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</main>
</div>
)
}

36
frontend/src/api.js Normal file
View File

@@ -0,0 +1,36 @@
const base = '/api'
async function req(path, opts = {}) {
const res = await fetch(base + path, {
headers: { 'Content-Type': 'application/json' },
...opts,
})
if (!res.ok) {
const text = await res.text().catch(() => '')
throw new Error(`${res.status}: ${text || res.statusText}`)
}
if (res.status === 204) return null
return res.json()
}
export const api = {
status: () => req('/status'),
config: () => req('/config'),
putConfig: (cfg) => req('/config', { method: 'PUT', body: JSON.stringify(cfg) }),
domains: () => req('/domains'),
addDomain: (d) => req('/domains', { method: 'POST', body: JSON.stringify(d) }),
deleteDomain: (name) =>
req(`/domains/${encodeURIComponent(name)}`, { method: 'DELETE' }),
patchDomain: (name, body) =>
req(`/domains/${encodeURIComponent(name)}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
logs: (params = {}) => {
const q = new URLSearchParams(
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v != null))
).toString()
return req('/logs' + (q ? `?${q}` : ''))
},
restart: () => req('/proxy/restart', { method: 'POST' }),
}

13
frontend/src/main.jsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import App from './App.jsx'
import './styles.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
)

View File

@@ -0,0 +1,83 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
export default function Dashboard() {
const [status, setStatus] = useState(null)
const [error, setError] = useState(null)
async function load() {
try {
setStatus(await api.status())
setError(null)
} catch (e) {
setError(e.message)
}
}
useEffect(() => {
load()
const id = setInterval(load, 5000)
return () => clearInterval(id)
}, [])
async function toggle() {
const cfg = await api.config()
cfg.proxy.enabled = !cfg.proxy.enabled
await api.putConfig(cfg)
await load()
}
if (error) return <div className="error">에러: {error}</div>
if (!status) return <div className="muted">로딩 </div>
return (
<div>
<h1>대시보드</h1>
<div className="cards">
<div className="card">
<div className="card-title">프록시 상태</div>
<div className={`card-value ${status.proxy_enabled ? 'ok' : 'warn'}`}>
{status.proxy_enabled ? '동작 중' : '중지됨'}
</div>
<button onClick={toggle} className="mt">
{status.proxy_enabled ? '끄기' : '켜기'}
</button>
</div>
<div className="card">
<div className="card-title"> 접속 시도</div>
<div className="card-value">{status.stats.total.toLocaleString()}</div>
</div>
<div className="card">
<div className="card-title">허용</div>
<div className="card-value ok">{status.stats.allowed.toLocaleString()}</div>
</div>
<div className="card">
<div className="card-title">차단</div>
<div className="card-value warn">{status.stats.blocked.toLocaleString()}</div>
</div>
<div className="card">
<div className="card-title">리스닝 포트</div>
<div className="card-value small">:{status.listen_port}</div>
</div>
<div className="card">
<div className="card-title">백엔드</div>
<div className="card-value small">
{status.backend?.host}:{status.backend?.port}
</div>
</div>
<div className="card">
<div className="card-title">허용 도메인 </div>
<div className="card-value">{status.domain_count}</div>
</div>
<div className="card">
<div className="card-title">마지막 이벤트</div>
<div className="card-value small">
{status.stats.last_event_ts
? new Date(status.stats.last_event_ts * 1000).toLocaleString('ko-KR')
: '-'}
</div>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,115 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
export default function Domains() {
const [domains, setDomains] = useState([])
const [newDomain, setNewDomain] = useState('')
const [newNote, setNewNote] = useState('')
const [error, setError] = useState(null)
async function load() {
try {
setDomains(await api.domains())
setError(null)
} catch (e) {
setError(e.message)
}
}
useEffect(() => {
load()
}, [])
async function add(e) {
e.preventDefault()
if (!newDomain.trim()) return
try {
await api.addDomain({ domain: newDomain.trim(), enabled: true, note: newNote })
setNewDomain('')
setNewNote('')
setError(null)
await load()
} catch (e) {
setError(e.message)
}
}
async function toggle(d) {
try {
await api.patchDomain(d.domain, { enabled: !d.enabled })
await load()
} catch (e) {
setError(e.message)
}
}
async function remove(d) {
if (!confirm(`${d.domain} 을(를) 삭제할까요?`)) return
try {
await api.deleteDomain(d.domain)
await load()
} catch (e) {
setError(e.message)
}
}
return (
<div>
<h1>허용 도메인</h1>
{error && <div className="error">{error}</div>}
<form onSubmit={add} className="form-row card">
<input
placeholder="mc.example.com"
value={newDomain}
onChange={(e) => setNewDomain(e.target.value)}
/>
<input
placeholder="메모 (선택)"
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
/>
<button type="submit">추가</button>
</form>
<table className="table">
<thead>
<tr>
<th>도메인</th>
<th>메모</th>
<th style={{ width: 80 }}>활성</th>
<th style={{ width: 90 }}></th>
</tr>
</thead>
<tbody>
{domains.length === 0 && (
<tr>
<td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 24 }}>
등록된 도메인이 없습니다.
</td>
</tr>
)}
{domains.map((d) => (
<tr key={d.domain}>
<td>
<code>{d.domain}</code>
</td>
<td>{d.note}</td>
<td>
<label className="switch">
<input
type="checkbox"
checked={d.enabled}
onChange={() => toggle(d)}
/>
<span />
</label>
</td>
<td>
<button className="danger" onClick={() => remove(d)}>삭제</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
function fmtTime(ts) {
return new Date(ts * 1000).toLocaleString('ko-KR')
}
export default function Logs() {
const [data, setData] = useState({ total: 0, items: [] })
const [filter, setFilter] = useState('')
const [auto, setAuto] = useState(true)
const [error, setError] = useState(null)
async function load() {
try {
const params = { limit: 100 }
if (filter) params.action = filter
setData(await api.logs(params))
setError(null)
} catch (e) {
setError(e.message)
}
}
useEffect(() => {
load()
if (!auto) return
const id = setInterval(load, 3000)
return () => clearInterval(id)
}, [filter, auto])
return (
<div>
<h1>접속 로그</h1>
{error && <div className="error">{error}</div>}
<div className="toolbar">
<select value={filter} onChange={(e) => setFilter(e.target.value)}>
<option value="">전체</option>
<option value="allowed">허용</option>
<option value="blocked">차단</option>
<option value="error">에러</option>
</select>
<label className="inline">
<input
type="checkbox"
checked={auto}
onChange={(e) => setAuto(e.target.checked)}
/>
자동 갱신 (3)
</label>
<span className="muted"> {data.total.toLocaleString()}</span>
</div>
<table className="table">
<thead>
<tr>
<th style={{ width: 170 }}>시각</th>
<th style={{ width: 130 }}>클라이언트 IP</th>
<th>도메인</th>
<th style={{ width: 80 }}>상태</th>
<th>사유</th>
</tr>
</thead>
<tbody>
{data.items.length === 0 && (
<tr>
<td colSpan={5} className="muted" style={{ textAlign: 'center', padding: 24 }}>
기록된 접속이 없습니다.
</td>
</tr>
)}
{data.items.map((row) => (
<tr key={row.id} className={`row-${row.action}`}>
<td>{fmtTime(row.ts)}</td>
<td>{row.client_ip}</td>
<td>
<code>{row.domain || '-'}</code>
</td>
<td>
<span className={`pill pill-${row.action}`}>{row.action}</span>
</td>
<td>{row.reason || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useState } from 'react'
import { api } from '../api.js'
export default function Settings() {
const [cfg, setCfg] = useState(null)
const [msg, setMsg] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
api.config().then(setCfg).catch((e) => setError(e.message))
}, [])
if (error) return <div className="error">{error}</div>
if (!cfg) return <div className="muted">로딩 </div>
async function save() {
try {
await api.putConfig(cfg)
setMsg('저장됨 (프록시는 2초 안에 자동 반영)')
setError(null)
setTimeout(() => setMsg(null), 2500)
} catch (e) {
setError(e.message)
}
}
return (
<div>
<h1>설정</h1>
<section className="card">
<h2>프록시</h2>
<label>
리스닝 포트
<input
type="number"
value={cfg.proxy.listen_port}
onChange={(e) =>
setCfg({
...cfg,
proxy: { ...cfg.proxy, listen_port: +e.target.value },
})
}
/>
</label>
<label className="inline">
<input
type="checkbox"
checked={cfg.proxy.enabled}
onChange={(e) =>
setCfg({
...cfg,
proxy: { ...cfg.proxy, enabled: e.target.checked },
})
}
/>
프록시 활성화
</label>
</section>
<section className="card">
<h2>백엔드 (실제 MC 서버)</h2>
<label>
호스트 (IP 또는 hostname)
<input
value={cfg.backend.host}
onChange={(e) =>
setCfg({ ...cfg, backend: { ...cfg.backend, host: e.target.value } })
}
/>
</label>
<label>
포트
<input
type="number"
value={cfg.backend.port}
onChange={(e) =>
setCfg({
...cfg,
backend: { ...cfg.backend, port: +e.target.value },
})
}
/>
</label>
</section>
<div className="actions">
<button onClick={save}>저장</button>
{msg && <span className="ok">{msg}</span>}
</div>
</div>
)
}

301
frontend/src/styles.css Normal file
View File

@@ -0,0 +1,301 @@
:root {
color-scheme: dark light;
--bg: #0f1115;
--panel: #161a22;
--panel-2: #1d2230;
--border: #2a2f3a;
--text: #e6e8eb;
--muted: #8a93a6;
--accent: #4f8cff;
--ok: #3fb950;
--warn: #f0883e;
--danger: #f85149;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6f8;
--panel: #ffffff;
--panel-2: #f0f2f5;
--border: #d8dde6;
--text: #1d2230;
--muted: #5b6478;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
-webkit-font-smoothing: antialiased;
}
code {
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
background: var(--panel-2);
padding: 2px 6px;
border-radius: 4px;
font-size: 13px;
}
.layout {
display: grid;
grid-template-columns: 220px 1fr;
min-height: 100vh;
}
.sidebar {
background: var(--panel);
border-right: 1px solid var(--border);
padding: 24px 16px;
display: flex;
flex-direction: column;
}
.logo {
font-size: 18px;
font-weight: 700;
margin-bottom: 24px;
display: flex;
align-items: center;
gap: 8px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px var(--accent);
}
.sidebar nav {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
}
.sidebar nav a {
color: var(--muted);
text-decoration: none;
padding: 10px 12px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.sidebar nav a.active {
background: var(--bg);
color: var(--text);
}
.sidebar nav a:hover {
background: var(--panel-2);
color: var(--text);
}
.sidebar-footer {
color: var(--muted);
font-size: 12px;
padding: 8px 12px;
}
.content {
padding: 32px 40px;
max-width: 1200px;
}
h1 {
margin: 0 0 24px;
font-size: 24px;
font-weight: 700;
}
h2 {
margin: 0 0 16px;
font-size: 16px;
font-weight: 600;
}
.cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
}
.card-title {
color: var(--muted);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.card-value {
font-size: 26px;
font-weight: 700;
margin-top: 8px;
}
.card-value.ok { color: var(--ok); }
.card-value.warn { color: var(--warn); }
.card-value.small { font-size: 16px; font-weight: 600; }
button {
background: var(--accent);
color: white;
border: none;
padding: 8px 14px;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
font-size: 14px;
}
button:hover { filter: brightness(1.1); }
button.danger { background: var(--danger); }
button.mt { margin-top: 12px; }
input,
select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 14px;
font-family: inherit;
}
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
}
.form-row {
display: flex;
gap: 8px;
margin-bottom: 16px;
align-items: center;
}
.form-row input { flex: 1; }
.table {
width: 100%;
border-collapse: collapse;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.table th,
.table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid var(--border);
font-size: 14px;
vertical-align: middle;
}
.table th {
background: var(--panel-2);
color: var(--muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.table tr:last-child td { border-bottom: none; }
.toolbar {
display: flex;
gap: 16px;
align-items: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.muted { color: var(--muted); font-size: 13px; }
.ok { color: var(--ok); font-size: 13px; }
.error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--danger);
color: var(--danger);
padding: 12px 14px;
border-radius: 6px;
margin-bottom: 16px;
font-size: 14px;
}
.switch {
display: inline-block;
width: 36px;
height: 20px;
position: relative;
}
.switch input { opacity: 0; width: 0; height: 0; }
.switch span {
position: absolute;
cursor: pointer;
inset: 0;
background: var(--border);
border-radius: 20px;
transition: 0.2s;
}
.switch span:before {
content: "";
position: absolute;
height: 16px;
width: 16px;
left: 2px;
bottom: 2px;
background: white;
border-radius: 50%;
transition: 0.2s;
}
.switch input:checked + span { background: var(--ok); }
.switch input:checked + span:before { transform: translateX(16px); }
section.card { margin-bottom: 16px; }
section.card label {
display: block;
margin-bottom: 12px;
font-size: 13px;
color: var(--muted);
}
section.card label input {
display: block;
margin-top: 4px;
width: 100%;
max-width: 320px;
color: var(--text);
}
section.card label.inline {
display: flex;
align-items: center;
gap: 8px;
color: var(--text);
font-size: 14px;
}
section.card label.inline input { width: auto; margin-top: 0; }
label.inline {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}
label.inline input { margin: 0; }
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
.pill {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pill-allowed { background: rgba(63, 185, 80, 0.15); color: var(--ok); }
.pill-blocked { background: rgba(248, 81, 73, 0.15); color: var(--danger); }
.pill-error { background: rgba(240, 136, 62, 0.15); color: var(--warn); }

19
frontend/vite.config.js Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// Dev 모드(localhost)에서는 /api 를 별도로 띄운 FastAPI 로 직접 프록시.
// Docker 환경에서는 nginx 컨테이너가 /api 와 / 를 모두 한 host 에서 라우팅한다.
export default defineConfig({
plugins: [react()],
server: {
host: '0.0.0.0',
port: 3000,
proxy: {
'/api': 'http://localhost:8000',
},
},
preview: {
host: '0.0.0.0',
port: 3000,
},
})

View File

@@ -1,223 +0,0 @@
# MC Domain Filter Proxy
마인크래프트 서버 접속 시 특정 도메인(`mc.tkrmagid.kr`)으로만 접속 가능하게 필터링하는 프록시 + 웹 관리 대시보드
---
## 프로젝트 개요
### 목적
- 외부 IP 직접입력, 다른 도메인으로 접속 시 차단
- `mc.tkrmagid.kr` 도메인으로만 마인크래프트 서버 접속 허용
- Fabric, Paper, Spigot, NeoForge 등 **어떤 서버 종류든 무관하게** 동작
### 동작 원리
마인크래프트 클라이언트는 접속 시 첫 핸드셰이크 패킷에 사용자가 입력한 주소(서버 주소창의 문자열)를 담아 전송한다.
이 프록시는 해당 패킷을 읽어 허용된 도메인이 아니면 연결을 즉시 차단한다.
### 네트워크 구조
```
외부 인터넷
공유기 (포트포워딩 25565 → Proxmox IP)
Proxmox Docker 컨테이너 (MC Domain Filter)
게임PC 내부IP:25565 (실제 마인크래프트 서버)
```
---
## 기술 스택
| 구성 요소 | 기술 |
|-----------|------|
| 프록시 코어 | Python (asyncio) |
| 웹 대시보드 | React + Vite (또는 Next.js) |
| 백엔드 API | FastAPI |
| 설정 저장 | JSON 파일 (sqlite도 가능) |
| 컨테이너 | Docker + docker-compose |
| 리버스 프록시 | nginx (대시보드용) |
---
## 기능 요구사항
### 핵심 기능 (프록시)
- [ ] Minecraft 핸드셰이크 패킷 파싱 (varint 포함)
- [ ] 허용 도메인 리스트와 비교 후 차단/통과 결정
- [ ] 통과된 패킷을 백엔드 서버로 투명하게 중계
- [ ] 첫 패킷 이후 양방향 TCP 터널링
### 웹 대시보드 (NPM 스타일 UI)
- [ ] **허용 도메인 관리** - 추가 / 삭제 / 활성화 토글
- [ ] **백엔드 서버 설정** - 게임PC IP, 포트 변경
- [ ] **실시간 접속 로그** - 허용/차단 내역, 접속 IP, 사용 도메인, 시각
- [ ] **대시보드 홈** - 현재 상태, 총 접속 수, 차단 수 통계 카드
- [ ] **프록시 on/off** 토글
- [ ] 설정 변경 후 프록시 재시작 없이 즉시 반영 (hot reload)
### UI 디자인 레퍼런스
- **Nginx Proxy Manager Plus** 스타일
- 다크/라이트 모드
- 사이드바 네비게이션
- 카드 기반 레이아웃
- 토글 스위치, 모달 폼
---
## 프로젝트 구조
```
mc-domain-filter/
├── docker-compose.yml
├── proxy/
│ ├── Dockerfile
│ ├── main.py # asyncio TCP 프록시
│ ├── config.py # 설정 로드/저장
│ ├── handshake.py # MC 핸드셰이크 파서
│ └── requirements.txt
├── api/
│ ├── Dockerfile
│ ├── main.py # FastAPI 백엔드
│ ├── routes/
│ │ ├── config.py # 설정 CRUD
│ │ └── logs.py # 로그 조회
│ └── requirements.txt
├── frontend/
│ ├── Dockerfile
│ ├── src/
│ │ ├── pages/
│ │ │ ├── Dashboard.jsx
│ │ │ ├── Domains.jsx
│ │ │ └── Logs.jsx
│ │ └── components/
│ └── package.json
├── nginx/
│ └── nginx.conf
└── data/
├── config.json # 설정 파일 (볼륨 마운트)
└── logs.db # 접속 로그
```
---
## 설정 파일 구조 (config.json)
```json
{
"proxy": {
"listen_port": 25565,
"enabled": true
},
"backend": {
"host": "192.168.0.xx",
"port": 25565
},
"allowed_domains": [
{
"domain": "mc.tkrmagid.kr",
"enabled": true,
"note": "메인 도메인"
}
]
}
```
---
## Minecraft 핸드셰이크 파싱
```
패킷 구조:
[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)
```
서버 주소 끝에 `\x00` 널 문자가 붙는 경우 있음 → strip 처리 필요
---
## docker-compose.yml 예시
```yaml
version: '3.8'
services:
proxy:
build: ./proxy
ports:
- "25565:25565"
volumes:
- ./data:/data
restart: unless-stopped
network_mode: bridge
api:
build: ./api
expose:
- "8000"
volumes:
- ./data:/data
restart: unless-stopped
frontend:
build: ./frontend
expose:
- "3000"
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "8080:80" # 대시보드 접근 포트
volumes:
- ./nginx/nginx.conf:/etc/nginx/nginx.conf
depends_on:
- api
- frontend
restart: unless-stopped
```
---
## API 엔드포인트
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | `/api/config` | 전체 설정 조회 |
| PUT | `/api/config` | 설정 저장 |
| GET | `/api/domains` | 도메인 목록 |
| POST | `/api/domains` | 도메인 추가 |
| DELETE | `/api/domains/{domain}` | 도메인 삭제 |
| PATCH | `/api/domains/{domain}` | 도메인 활성화/비활성화 |
| GET | `/api/logs` | 접속 로그 (페이지네이션) |
| GET | `/api/status` | 프록시 상태, 통계 |
| POST | `/api/proxy/restart` | 프록시 재시작 |
---
## 환경 정보
### Proxmox 서버 스펙 (프록시 실행 환경)
- CPU: Intel Core i3-10100 (4코어 8스레드, 3.6GHz, 최대 4.3GHz)
- RAM: DDR4 32GB (Samsung 16GB × 2, 3200 MT/s, 듀얼채널)
- 가상화: VT-x 지원
### 게임PC (실제 MC 서버)
- 같은 공유기 내 LAN 환경
- 내부 IP로 통신
---
## 주의사항
- 프록시와 FastAPI는 `data/config.json`을 공유 볼륨으로 사용
- 설정 변경 시 프록시 프로세스에 SIGHUP 또는 내부 이벤트로 hot reload
- 게임PC의 MC 서버는 포트 변경 불필요 (프록시가 25565→25565 그대로 중계)
- 대시보드는 Proxmox 내부 IP:8080 으로 접근
- 대시보드 포트(8080)는 외부에 포트포워딩 하지 않을 것 권장

41
nginx/nginx.conf Normal file
View File

@@ -0,0 +1,41 @@
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
upstream mc_api { server api:8000; }
upstream mc_frontend { server frontend:3000; }
server {
listen 80 default_server;
server_name _;
client_max_body_size 1m;
location /api/ {
proxy_pass http://mc_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /health {
proxy_pass http://mc_api;
}
location / {
proxy_pass http://mc_frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}

13
proxy/Dockerfile Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
# 프록시는 stdlib (asyncio, sqlite3, json) 만 사용한다.