Compare commits
6 Commits
75c4242365
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7362b45846 | |||
| 8312cfe861 | |||
| d9a1ee1a69 | |||
| 58b112e449 | |||
| 9540a3a576 | |||
| 9489bdb362 |
11
README.md
11
README.md
@@ -10,6 +10,7 @@
|
||||
- 허용 도메인 화이트리스트 매칭, 불일치 시 Login Disconnect 패킷으로 차단 사유(커스텀 가능) 표시 후 연결 종료
|
||||
- 통과한 연결은 백엔드 MC 서버로 투명 TCP 중계
|
||||
(Fabric / Paper / Spigot / NeoForge 등 서버 종류 무관)
|
||||
- **도메인별 백엔드 라우팅**: 도메인마다 다른 IP:포트로 보낼 수 있어 하나의 25565 포트로 여러 MC 서버를 동시에 운영 가능 (예: `mc.tkrmagid.kr` → 게임PC:25565, `creative.tkrmagid.kr` → NAS:25566)
|
||||
- 설정 파일(`data/config.json`) 변경을 프록시가 자동 감지해 hot reload (재시작 불필요)
|
||||
- 모든 연결 시도(허용 / 차단 / 에러)를 SQLite 에 기록
|
||||
- 웹 대시보드 (NPM 스타일): 도메인 관리, 실시간 로그, 통계 카드, 백엔드/포트 설정
|
||||
@@ -43,13 +44,18 @@ mc-filter-proxy 컨테이너 (25565)
|
||||
{
|
||||
"proxy": { "listen_port": 25565, "enabled": true },
|
||||
"backend": { "host": "192.168.0.20", "port": 25565 },
|
||||
"block_message": "이 서버는 허용된 도메인에서만 접속 가능합니다.",
|
||||
"allowed_domains": [
|
||||
{ "domain": "mc.tkrmagid.kr", "enabled": true, "note": "메인 도메인" }
|
||||
{ "domain": "mc.tkrmagid.kr", "enabled": true, "note": "메인 서버" },
|
||||
{ "domain": "creative.tkrmagid.kr", "enabled": true, "note": "크리에이티브",
|
||||
"backend": { "host": "192.168.0.21", "port": 25566 } }
|
||||
]
|
||||
}
|
||||
EOF
|
||||
```
|
||||
|
||||
각 도메인 entry 에 `backend` 필드가 있으면 그 host:port 로, 없으면 top-level `backend` 로 라우팅됩니다.
|
||||
|
||||
3. 전체 스택 빌드 & 실행
|
||||
|
||||
```bash
|
||||
@@ -82,7 +88,8 @@ mc-filter-proxy 컨테이너 (25565)
|
||||
| POST | `/api/domains` | 도메인 추가 |
|
||||
| PATCH | `/api/domains/{domain}` | 활성/메모 변경 |
|
||||
| DELETE | `/api/domains/{domain}` | 도메인 삭제 |
|
||||
| GET | `/api/logs?limit&offset&action` | 접속 로그 (페이지네이션) |
|
||||
| GET | `/api/logs?limit&offset&action&from_ts&to_ts` | 접속 로그 (페이지네이션, 날짜 필터) |
|
||||
| DELETE | `/api/logs?from_ts&to_ts` | 접속 로그 삭제 (범위 미지정 시 전체) |
|
||||
| GET | `/api/status` | 프록시 상태 + 통계 |
|
||||
| POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) |
|
||||
|
||||
|
||||
@@ -19,10 +19,16 @@ class BackendConfig(BaseModel):
|
||||
port: int = Field(ge=1, le=65535)
|
||||
|
||||
|
||||
class DomainBackend(BaseModel):
|
||||
host: str
|
||||
port: int = Field(ge=1, le=65535)
|
||||
|
||||
|
||||
class DomainEntry(BaseModel):
|
||||
domain: str
|
||||
enabled: bool = True
|
||||
note: str = ""
|
||||
backend: DomainBackend | None = None # 없으면 top-level backend 로 fallback
|
||||
|
||||
|
||||
class FullConfig(BaseModel):
|
||||
|
||||
@@ -2,22 +2,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from config_io import load_config, save_config
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class DomainBackend(BaseModel):
|
||||
host: str
|
||||
port: int = Field(ge=1, le=65535)
|
||||
|
||||
|
||||
class DomainCreate(BaseModel):
|
||||
domain: str
|
||||
enabled: bool = True
|
||||
note: str = ""
|
||||
backend: DomainBackend | None = None # 비우면 기본 backend 사용
|
||||
|
||||
|
||||
class DomainPatch(BaseModel):
|
||||
enabled: bool | None = None
|
||||
note: str | None = None
|
||||
# 명시적으로 None 을 보내려면 클라이언트가 "backend": null 을 보내야 한다.
|
||||
# 빠뜨리면 기존 값 유지.
|
||||
backend: DomainBackend | None = None
|
||||
clear_backend: bool = False # True 면 기존 backend 삭제 → 기본값으로 복귀
|
||||
|
||||
|
||||
@router.get("/domains")
|
||||
@@ -34,7 +44,9 @@ def add_domain(body: DomainCreate) -> dict:
|
||||
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}
|
||||
entry: dict = {"domain": name, "enabled": body.enabled, "note": body.note}
|
||||
if body.backend is not None:
|
||||
entry["backend"] = body.backend.model_dump()
|
||||
domains.append(entry)
|
||||
save_config(cfg)
|
||||
return entry
|
||||
@@ -64,6 +76,10 @@ def patch_domain(domain: str, body: DomainPatch) -> dict:
|
||||
d["enabled"] = body.enabled
|
||||
if body.note is not None:
|
||||
d["note"] = body.note
|
||||
if body.clear_backend:
|
||||
d.pop("backend", None)
|
||||
elif body.backend is not None:
|
||||
d["backend"] = body.backend.model_dump()
|
||||
save_config(cfg)
|
||||
return d
|
||||
raise HTTPException(status_code=404, detail="domain not found")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""접속 로그 조회."""
|
||||
"""접속 로그 조회 및 초기화."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
@@ -15,16 +15,26 @@ def list_logs(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
action: str | None = Query(None),
|
||||
from_ts: float | None = Query(None, description="unix epoch seconds, inclusive"),
|
||||
to_ts: float | None = Query(None, description="unix epoch seconds, exclusive"),
|
||||
) -> dict:
|
||||
if not LOG_DB.exists():
|
||||
return {"total": 0, "items": []}
|
||||
con = sqlite3.connect(LOG_DB)
|
||||
try:
|
||||
con.row_factory = sqlite3.Row
|
||||
where, params = "", []
|
||||
conds: list[str] = []
|
||||
params: list = []
|
||||
if action:
|
||||
where = "WHERE action = ?"
|
||||
conds.append("action = ?")
|
||||
params.append(action)
|
||||
if from_ts is not None:
|
||||
conds.append("ts >= ?")
|
||||
params.append(from_ts)
|
||||
if to_ts is not None:
|
||||
conds.append("ts < ?")
|
||||
params.append(to_ts)
|
||||
where = ("WHERE " + " AND ".join(conds)) if conds else ""
|
||||
total = con.execute(
|
||||
f"SELECT COUNT(*) FROM connections {where}", params
|
||||
).fetchone()[0]
|
||||
@@ -36,3 +46,40 @@ def list_logs(
|
||||
finally:
|
||||
con.close()
|
||||
return {"total": total, "items": [dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.delete("/logs")
|
||||
def clear_logs(
|
||||
from_ts: float | None = Query(None, description="ts >= from_ts 만 삭제"),
|
||||
to_ts: float | None = Query(None, description="ts < to_ts 만 삭제"),
|
||||
) -> dict:
|
||||
"""접속 로그를 삭제한다.
|
||||
|
||||
- from_ts/to_ts 둘 다 없으면 전체 삭제 (AUTOINCREMENT 카운터까지 리셋)
|
||||
- 둘 중 하나만 있으면 그 조건만 적용
|
||||
- 둘 다 있으면 [from_ts, to_ts) 범위 삭제 — 날짜 선택 삭제 용도
|
||||
"""
|
||||
if not LOG_DB.exists():
|
||||
return {"deleted": 0}
|
||||
con = sqlite3.connect(LOG_DB, timeout=5)
|
||||
try:
|
||||
conds: list[str] = []
|
||||
params: list = []
|
||||
if from_ts is not None:
|
||||
conds.append("ts >= ?")
|
||||
params.append(from_ts)
|
||||
if to_ts is not None:
|
||||
conds.append("ts < ?")
|
||||
params.append(to_ts)
|
||||
if conds:
|
||||
where = "WHERE " + " AND ".join(conds)
|
||||
cur = con.execute(f"DELETE FROM connections {where}", params)
|
||||
deleted = cur.rowcount
|
||||
else:
|
||||
cur = con.execute("DELETE FROM connections")
|
||||
deleted = cur.rowcount
|
||||
con.execute("DELETE FROM sqlite_sequence WHERE name='connections'")
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
return {"deleted": deleted}
|
||||
|
||||
@@ -25,18 +25,16 @@ services:
|
||||
build: ./frontend
|
||||
container_name: mc-filter-frontend
|
||||
expose:
|
||||
- "3000"
|
||||
- "80"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- mc-filter
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
build: ./nginx
|
||||
container_name: mc-filter-nginx
|
||||
ports:
|
||||
- "8080:80" # 대시보드 접근 포트 (외부 포트포워딩 금지 권장)
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
depends_on:
|
||||
- api
|
||||
- frontend
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 빌드 단계
|
||||
# 빌드 단계: vite 로 dist/ 만든다
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
@@ -6,12 +6,10 @@ 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"]
|
||||
# 서빙 단계: nginx 가 정적으로 dist/ 를 서빙한다.
|
||||
# (vite preview 는 dev/preview 용이고 docker production 환경에서 연결 끊김
|
||||
# 현상이 보고된 적이 있어 nginx 정적 서빙으로 통일)
|
||||
FROM nginx:alpine
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
|
||||
20
frontend/nginx.conf
Normal file
20
frontend/nginx.conf
Normal file
@@ -0,0 +1,20 @@
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# 정적 자산 캐시 (해시가 붙은 vite 산출물)
|
||||
location /assets/ {
|
||||
access_log off;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# SPA fallback: 브라우저에서 /domains 같은 경로를 새로고침 해도 index.html 을 반환
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@@ -32,5 +32,11 @@ export const api = {
|
||||
).toString()
|
||||
return req('/logs' + (q ? `?${q}` : ''))
|
||||
},
|
||||
clearLogs: (params = {}) => {
|
||||
const q = new URLSearchParams(
|
||||
Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v != null))
|
||||
).toString()
|
||||
return req('/logs' + (q ? `?${q}` : ''), { method: 'DELETE' })
|
||||
},
|
||||
restart: () => req('/proxy/restart', { method: 'POST' }),
|
||||
}
|
||||
|
||||
@@ -1,32 +1,42 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../api.js'
|
||||
|
||||
const blankBackend = { host: '', port: '' }
|
||||
|
||||
export default function Domains() {
|
||||
const [domains, setDomains] = useState([])
|
||||
const [defaultBackend, setDefaultBackend] = useState(null)
|
||||
const [newDomain, setNewDomain] = useState('')
|
||||
const [newNote, setNewNote] = useState('')
|
||||
const [newBackend, setNewBackend] = useState({ ...blankBackend })
|
||||
const [error, setError] = useState(null)
|
||||
const [editing, setEditing] = useState(null) // {domain, host, port}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setDomains(await api.domains())
|
||||
const [doms, cfg] = await Promise.all([api.domains(), api.config()])
|
||||
setDomains(doms)
|
||||
setDefaultBackend(cfg.backend)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function add(e) {
|
||||
e.preventDefault()
|
||||
if (!newDomain.trim()) return
|
||||
try {
|
||||
await api.addDomain({ domain: newDomain.trim(), enabled: true, note: newNote })
|
||||
const body = { domain: newDomain.trim(), enabled: true, note: newNote }
|
||||
if (newBackend.host.trim() && newBackend.port) {
|
||||
body.backend = { host: newBackend.host.trim(), port: +newBackend.port }
|
||||
}
|
||||
await api.addDomain(body)
|
||||
setNewDomain('')
|
||||
setNewNote('')
|
||||
setNewBackend({ ...blankBackend })
|
||||
setError(null)
|
||||
await load()
|
||||
} catch (e) {
|
||||
@@ -38,9 +48,7 @@ export default function Domains() {
|
||||
try {
|
||||
await api.patchDomain(d.domain, { enabled: !d.enabled })
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
async function remove(d) {
|
||||
@@ -48,66 +56,163 @@ export default function Domains() {
|
||||
try {
|
||||
await api.deleteDomain(d.domain)
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
function startEdit(d) {
|
||||
setEditing({
|
||||
domain: d.domain,
|
||||
host: d.backend?.host ?? '',
|
||||
port: d.backend?.port ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
try {
|
||||
const { domain, host, port } = editing
|
||||
if (!host.trim() || !port) {
|
||||
// 둘 다 비우면 backend 삭제 → 기본값으로 폴백
|
||||
await api.patchDomain(domain, { clear_backend: true })
|
||||
} else {
|
||||
await api.patchDomain(domain, {
|
||||
backend: { host: host.trim(), port: +port },
|
||||
})
|
||||
}
|
||||
setEditing(null)
|
||||
await load()
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
async function clearBackend(d) {
|
||||
if (!confirm(`${d.domain} 의 개별 backend 설정을 지우고 기본값으로 되돌릴까요?`)) return
|
||||
try {
|
||||
await api.patchDomain(d.domain, { clear_backend: true })
|
||||
await load()
|
||||
} catch (e) { setError(e.message) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>허용 도메인</h1>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<form onSubmit={add} className="form-row card">
|
||||
|
||||
<form onSubmit={add} className="card">
|
||||
<h2>새 도메인</h2>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
도메인
|
||||
<input
|
||||
placeholder="mc.example.com"
|
||||
value={newDomain}
|
||||
onChange={(e) => setNewDomain(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
메모 (선택)
|
||||
<input
|
||||
placeholder="메모 (선택)"
|
||||
placeholder="메인 서버, 모드팩 등"
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
백엔드 호스트 (선택)
|
||||
<input
|
||||
placeholder={defaultBackend ? `기본값: ${defaultBackend.host}` : ''}
|
||||
value={newBackend.host}
|
||||
onChange={(e) => setNewBackend({ ...newBackend, host: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
백엔드 포트 (선택)
|
||||
<input
|
||||
type="number"
|
||||
placeholder={defaultBackend ? `기본값: ${defaultBackend.port}` : ''}
|
||||
value={newBackend.port}
|
||||
onChange={(e) => setNewBackend({ ...newBackend, port: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="submit">추가</button>
|
||||
<span className="muted">백엔드 호스트/포트를 비워두면 설정 페이지의 기본 backend 로 라우팅됩니다.</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>도메인</th>
|
||||
<th>메모</th>
|
||||
<th>백엔드</th>
|
||||
<th style={{ width: 80 }}>활성</th>
|
||||
<th style={{ width: 90 }}></th>
|
||||
<th style={{ width: 200 }}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{domains.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
<td colSpan={5} className="muted" style={{ textAlign: 'center', padding: 24 }}>
|
||||
등록된 도메인이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{domains.map((d) => (
|
||||
{domains.map((d) => {
|
||||
const isEditing = editing?.domain === d.domain
|
||||
return (
|
||||
<tr key={d.domain}>
|
||||
<td>
|
||||
<code>{d.domain}</code>
|
||||
</td>
|
||||
<td><code>{d.domain}</code></td>
|
||||
<td>{d.note}</td>
|
||||
<td>
|
||||
<label className="switch">
|
||||
{isEditing ? (
|
||||
<div className="inline-edit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={d.enabled}
|
||||
onChange={() => toggle(d)}
|
||||
placeholder="host"
|
||||
value={editing.host}
|
||||
onChange={(e) => setEditing({ ...editing, host: e.target.value })}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="port"
|
||||
value={editing.port}
|
||||
onChange={(e) => setEditing({ ...editing, port: e.target.value })}
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
) : d.backend ? (
|
||||
<code>{d.backend.host}:{d.backend.port}</code>
|
||||
) : (
|
||||
<span className="muted">
|
||||
기본값 ({defaultBackend ? `${defaultBackend.host}:${defaultBackend.port}` : '-'})
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button onClick={saveEdit}>저장</button>
|
||||
<button className="ghost" onClick={() => setEditing(null)} style={{ marginLeft: 4 }}>취소</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button className="ghost" onClick={() => startEdit(d)}>백엔드</button>
|
||||
{d.backend && (
|
||||
<button className="ghost" onClick={() => clearBackend(d)} style={{ marginLeft: 4 }}>초기화</button>
|
||||
)}
|
||||
<button className="danger" onClick={() => remove(d)} style={{ marginLeft: 4 }}>삭제</button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -5,16 +5,33 @@ function fmtTime(ts) {
|
||||
return new Date(ts * 1000).toLocaleString('ko-KR')
|
||||
}
|
||||
|
||||
// 'YYYY-MM-DD' 문자열을 로컬 자정 unix epoch (초) 로 바꾼다.
|
||||
function dayStart(dateStr) {
|
||||
if (!dateStr) return null
|
||||
const [y, m, d] = dateStr.split('-').map((s) => parseInt(s, 10))
|
||||
return new Date(y, m - 1, d, 0, 0, 0, 0).getTime() / 1000
|
||||
}
|
||||
function dayEnd(dateStr) {
|
||||
const start = dayStart(dateStr)
|
||||
return start == null ? null : start + 86400
|
||||
}
|
||||
|
||||
export default function Logs() {
|
||||
const [data, setData] = useState({ total: 0, items: [] })
|
||||
const [filter, setFilter] = useState('')
|
||||
const [date, setDate] = useState('')
|
||||
const [auto, setAuto] = useState(true)
|
||||
const [error, setError] = useState(null)
|
||||
const [msg, setMsg] = useState(null)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const params = { limit: 100 }
|
||||
if (filter) params.action = filter
|
||||
if (date) {
|
||||
params.from_ts = dayStart(date)
|
||||
params.to_ts = dayEnd(date)
|
||||
}
|
||||
setData(await api.logs(params))
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
@@ -27,7 +44,30 @@ export default function Logs() {
|
||||
if (!auto) return
|
||||
const id = setInterval(load, 3000)
|
||||
return () => clearInterval(id)
|
||||
}, [filter, auto])
|
||||
}, [filter, date, auto])
|
||||
|
||||
async function clearLogs() {
|
||||
const scope = date ? `${date} 하루치` : '전체'
|
||||
if (!window.confirm(`${scope} 접속 로그를 정말 삭제할까요? (되돌릴 수 없음)`)) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const params = {}
|
||||
if (date) {
|
||||
params.from_ts = dayStart(date)
|
||||
params.to_ts = dayEnd(date)
|
||||
}
|
||||
const res = await api.clearLogs(params)
|
||||
setMsg(`${res.deleted.toLocaleString()}건 삭제됨`)
|
||||
setError(null)
|
||||
setTimeout(() => setMsg(null), 2500)
|
||||
load()
|
||||
} catch (e) {
|
||||
setError(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
const clearLabel = date ? `${date} 하루치 삭제` : '전체 로그 초기화'
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -40,6 +80,19 @@ export default function Logs() {
|
||||
<option value="blocked">차단</option>
|
||||
<option value="error">에러</option>
|
||||
</select>
|
||||
<label className="inline">
|
||||
날짜
|
||||
<input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => setDate(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{date && (
|
||||
<button className="ghost" onClick={() => setDate('')}>
|
||||
오늘 해제
|
||||
</button>
|
||||
)}
|
||||
<label className="inline">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -49,6 +102,10 @@ export default function Logs() {
|
||||
자동 갱신 (3초)
|
||||
</label>
|
||||
<span className="muted">총 {data.total.toLocaleString()}건</span>
|
||||
<button className="danger" onClick={clearLogs}>
|
||||
{clearLabel}
|
||||
</button>
|
||||
{msg && <span className="ok">{msg}</span>}
|
||||
</div>
|
||||
<table className="table">
|
||||
<thead>
|
||||
|
||||
@@ -69,7 +69,11 @@ export default function Settings() {
|
||||
</label>
|
||||
</section>
|
||||
<section className="card">
|
||||
<h2>백엔드 (실제 MC 서버)</h2>
|
||||
<h2>기본 백엔드 (도메인별 backend 가 없을 때 fallback)</h2>
|
||||
<p className="muted" style={{ marginTop: 0 }}>
|
||||
허용 도메인 페이지에서 각 도메인에 별도 backend (host:port) 를 지정할 수 있습니다.
|
||||
지정하지 않은 도메인은 여기 값으로 라우팅됩니다.
|
||||
</p>
|
||||
<label>
|
||||
호스트 (IP 또는 hostname)
|
||||
<input
|
||||
|
||||
@@ -189,6 +189,22 @@ textarea:focus {
|
||||
align-items: center;
|
||||
}
|
||||
.form-row input { flex: 1; }
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.form-grid input { width: 100%; }
|
||||
.inline-edit { display: flex; gap: 6px; }
|
||||
.inline-edit input { padding: 4px 8px; font-size: 13px; }
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
|
||||
6
nginx/Dockerfile
Normal file
6
nginx/Dockerfile
Normal file
@@ -0,0 +1,6 @@
|
||||
# nginx.conf 를 image 에 굽는다.
|
||||
# (volume mount 로 운영하다 보면 호스트 측 conf 가 stale 일 때 영문도 모르고
|
||||
# 502 가 나는 경우가 있어 image 안에 직접 포함시킨다.)
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
EXPOSE 80
|
||||
@@ -6,7 +6,7 @@ http {
|
||||
sendfile on;
|
||||
|
||||
upstream mc_api { server api:8000; }
|
||||
upstream mc_frontend { server frontend:3000; }
|
||||
upstream mc_frontend { server frontend:80; }
|
||||
|
||||
server {
|
||||
listen 80 default_server;
|
||||
|
||||
@@ -129,9 +129,31 @@ class ProxyState:
|
||||
return cfg_mod.allowed_domain_set(self.cfg)
|
||||
|
||||
def backend(self) -> tuple[str, int]:
|
||||
"""기본 백엔드 (도메인 entry 에 backend 가 없을 때 fallback)."""
|
||||
b = self.cfg["backend"]
|
||||
return b["host"], int(b["port"])
|
||||
|
||||
def backend_for(self, domain: str) -> tuple[str, int] | None:
|
||||
"""주어진 도메인이 활성 화이트리스트에 있으면 라우팅 대상을 돌려준다.
|
||||
|
||||
도메인 entry 에 `backend.host`/`backend.port` 가 있으면 그 값을 우선,
|
||||
없으면 top-level `backend` 로 fallback. 도메인이 비활성이거나 없으면
|
||||
None.
|
||||
"""
|
||||
d = domain.lower().strip()
|
||||
for entry in self.cfg.get("allowed_domains", []):
|
||||
if entry["domain"].lower().strip() != d:
|
||||
continue
|
||||
if not entry.get("enabled", True):
|
||||
return None
|
||||
be = entry.get("backend") or {}
|
||||
host = (be.get("host") or "").strip()
|
||||
port = be.get("port")
|
||||
if host and port:
|
||||
return host, int(port)
|
||||
return self.backend()
|
||||
return None
|
||||
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.cfg.get("proxy", {}).get("enabled", True))
|
||||
|
||||
@@ -206,14 +228,21 @@ async def handle_client(
|
||||
)
|
||||
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)
|
||||
# str(exc) 가 빈 문자열인 예외들(OSError(), ConnectionResetError())
|
||||
# 도 있어서 class 이름을 함께 남긴다 — 빈 reason 로 보이는 문제 회피.
|
||||
reason = f"handshake error: {type(exc).__name__}: {exc}".rstrip(": ")
|
||||
log_event(client_ip, None, None, "blocked", reason)
|
||||
log.info("BLOCK %s reason=%s", client_ip, reason)
|
||||
try:
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return
|
||||
|
||||
domain = hs.server_address.lower().strip()
|
||||
allowed = state.allowed()
|
||||
if domain not in allowed:
|
||||
target = state.backend_for(domain)
|
||||
if target is None:
|
||||
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
|
||||
@@ -225,12 +254,23 @@ async def handle_client(
|
||||
msg = state.cfg.get("block_message") or DEFAULT_BLOCK_MESSAGE
|
||||
client_writer.write(build_login_disconnect(msg))
|
||||
await client_writer.drain()
|
||||
# FIN 으로 마무리해서 클라이언트가 disconnect 패킷을 다 읽기 전에
|
||||
# RST 가 가는 (그러면 "Connection reset" 으로 보인다) 일을 막는다.
|
||||
try:
|
||||
if client_writer.can_write_eof():
|
||||
client_writer.write_eof()
|
||||
except (OSError, NotImplementedError):
|
||||
pass
|
||||
except (OSError, ConnectionResetError, BrokenPipeError):
|
||||
pass
|
||||
try:
|
||||
client_writer.close()
|
||||
await client_writer.wait_closed()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return
|
||||
|
||||
backend_host, backend_port = state.backend()
|
||||
backend_host, backend_port = target
|
||||
try:
|
||||
backend_reader, backend_writer = await asyncio.wait_for(
|
||||
asyncio.open_connection(backend_host, backend_port), timeout=5
|
||||
|
||||
Reference in New Issue
Block a user