diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb35af6 --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..47892ee --- /dev/null +++ b/README.md @@ -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://: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). diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 0000000..7318b85 --- /dev/null +++ b/api/Dockerfile @@ -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"] diff --git a/api/config_io.py b/api/config_io.py new file mode 100644 index 0000000..f2df5d0 --- /dev/null +++ b/api/config_io.py @@ -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) diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..eeebb6b --- /dev/null +++ b/api/main.py @@ -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} diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 0000000..da958cb --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,3 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +pydantic==2.9.2 diff --git a/api/routes/__init__.py b/api/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/routes/config.py b/api/routes/config.py new file mode 100644 index 0000000..d640e3f --- /dev/null +++ b/api/routes/config.py @@ -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() diff --git a/api/routes/domains.py b/api/routes/domains.py new file mode 100644 index 0000000..01cf8c5 --- /dev/null +++ b/api/routes/domains.py @@ -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") diff --git a/api/routes/logs.py b/api/routes/logs.py new file mode 100644 index 0000000..a48825a --- /dev/null +++ b/api/routes/logs.py @@ -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]} diff --git a/api/routes/status.py b/api/routes/status.py new file mode 100644 index 0000000..e839953 --- /dev/null +++ b/api/routes/status.py @@ -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"} diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..19acc63 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..b431156 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,3 @@ +node_modules +dist +.vite diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..065c73e --- /dev/null +++ b/frontend/Dockerfile @@ -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"] diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..b1da55e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + MC Domain Filter + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..fb3c3aa --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 0000000..30c8e6d --- /dev/null +++ b/frontend/src/App.jsx @@ -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 ( +
+ +
+ + } /> + } /> + } /> + } /> + +
+
+ ) +} diff --git a/frontend/src/api.js b/frontend/src/api.js new file mode 100644 index 0000000..f368576 --- /dev/null +++ b/frontend/src/api.js @@ -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' }), +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 0000000..2d616b8 --- /dev/null +++ b/frontend/src/main.jsx @@ -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( + + + + + , +) diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx new file mode 100644 index 0000000..0475faf --- /dev/null +++ b/frontend/src/pages/Dashboard.jsx @@ -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
에러: {error}
+ if (!status) return
로딩 중…
+ + return ( +
+

대시보드

+
+
+
프록시 상태
+
+ {status.proxy_enabled ? '동작 중' : '중지됨'} +
+ +
+
+
총 접속 시도
+
{status.stats.total.toLocaleString()}
+
+
+
허용
+
{status.stats.allowed.toLocaleString()}
+
+
+
차단
+
{status.stats.blocked.toLocaleString()}
+
+
+
리스닝 포트
+
:{status.listen_port}
+
+
+
백엔드
+
+ {status.backend?.host}:{status.backend?.port} +
+
+
+
허용 도메인 수
+
{status.domain_count}
+
+
+
마지막 이벤트
+
+ {status.stats.last_event_ts + ? new Date(status.stats.last_event_ts * 1000).toLocaleString('ko-KR') + : '-'} +
+
+
+
+ ) +} diff --git a/frontend/src/pages/Domains.jsx b/frontend/src/pages/Domains.jsx new file mode 100644 index 0000000..f537358 --- /dev/null +++ b/frontend/src/pages/Domains.jsx @@ -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 ( +
+

허용 도메인

+ {error &&
{error}
} +
+ setNewDomain(e.target.value)} + /> + setNewNote(e.target.value)} + /> + +
+ + + + + + + + + + + {domains.length === 0 && ( + + + + )} + {domains.map((d) => ( + + + + + + + ))} + +
도메인메모활성
+ 등록된 도메인이 없습니다. +
+ {d.domain} + {d.note} + + + +
+
+ ) +} diff --git a/frontend/src/pages/Logs.jsx b/frontend/src/pages/Logs.jsx new file mode 100644 index 0000000..eb7da76 --- /dev/null +++ b/frontend/src/pages/Logs.jsx @@ -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 ( +
+

접속 로그

+ {error &&
{error}
} +
+ + + 총 {data.total.toLocaleString()}건 +
+ + + + + + + + + + + + {data.items.length === 0 && ( + + + + )} + {data.items.map((row) => ( + + + + + + + + ))} + +
시각클라이언트 IP도메인상태사유
+ 기록된 접속이 없습니다. +
{fmtTime(row.ts)}{row.client_ip} + {row.domain || '-'} + + {row.action} + {row.reason || '-'}
+
+ ) +} diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx new file mode 100644 index 0000000..d993b98 --- /dev/null +++ b/frontend/src/pages/Settings.jsx @@ -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
{error}
+ if (!cfg) return
로딩 중…
+ + async function save() { + try { + await api.putConfig(cfg) + setMsg('저장됨 (프록시는 2초 안에 자동 반영)') + setError(null) + setTimeout(() => setMsg(null), 2500) + } catch (e) { + setError(e.message) + } + } + + return ( +
+

설정

+
+

프록시

+ + +
+
+

백엔드 (실제 MC 서버)

+ + +
+
+ + {msg && {msg}} +
+
+ ) +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..6f6fafc --- /dev/null +++ b/frontend/src/styles.css @@ -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); } diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..dc80a0c --- /dev/null +++ b/frontend/vite.config.js @@ -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, + }, +}) diff --git a/mc-domain-filter.md b/mc-domain-filter.md deleted file mode 100644 index 9cbc7c6..0000000 --- a/mc-domain-filter.md +++ /dev/null @@ -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)는 외부에 포트포워딩 하지 않을 것 권장 diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..691123f --- /dev/null +++ b/nginx/nginx.conf @@ -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; + } + } +} diff --git a/proxy/Dockerfile b/proxy/Dockerfile new file mode 100644 index 0000000..aae2ac6 --- /dev/null +++ b/proxy/Dockerfile @@ -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"] diff --git a/proxy/config.py b/proxy/config.py new file mode 100644 index 0000000..0d82089 --- /dev/null +++ b/proxy/config.py @@ -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) + } diff --git a/proxy/handshake.py b/proxy/handshake.py new file mode 100644 index 0000000..55bd38a --- /dev/null +++ b/proxy/handshake.py @@ -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) diff --git a/proxy/main.py b/proxy/main.py new file mode 100644 index 0000000..aa0bdd8 --- /dev/null +++ b/proxy/main.py @@ -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 diff --git a/proxy/requirements.txt b/proxy/requirements.txt new file mode 100644 index 0000000..bdc0bd7 --- /dev/null +++ b/proxy/requirements.txt @@ -0,0 +1 @@ +# 프록시는 stdlib (asyncio, sqlite3, json) 만 사용한다.