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