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:
0
api/routes/__init__.py
Normal file
0
api/routes/__init__.py
Normal file
42
api/routes/config.py
Normal file
42
api/routes/config.py
Normal 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
69
api/routes/domains.py
Normal 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
38
api/routes/logs.py
Normal 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
59
api/routes/status.py
Normal 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"}
|
||||
Reference in New Issue
Block a user