feat: implement MC domain filter proxy, API, dashboard

- proxy: asyncio TCP proxy with handshake parser, domain whitelist,
  transparent backend tunneling, SQLite logging, mtime hot reload
- api: FastAPI routes for config/domains/logs/status + restart trigger
- frontend: React + Vite NPM-style dashboard (dashboard/domains/logs/settings)
- nginx: reverse proxy for /api -> api:8000 and / -> frontend:3000
- docker-compose: full stack with shared data volume
- replace spec mc-domain-filter.md with README.md
This commit is contained in:
2026-05-20 16:39:18 +09:00
parent b45e884633
commit d10dae5cb9
33 changed files with 1872 additions and 223 deletions

32
frontend/src/App.jsx Normal file
View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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); }