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

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"}