Compare commits
3 Commits
58b112e449
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7362b45846 | |||
| 8312cfe861 | |||
| d9a1ee1a69 |
@@ -88,7 +88,8 @@ mc-filter-proxy 컨테이너 (25565)
|
|||||||
| POST | `/api/domains` | 도메인 추가 |
|
| POST | `/api/domains` | 도메인 추가 |
|
||||||
| PATCH | `/api/domains/{domain}` | 활성/메모 변경 |
|
| PATCH | `/api/domains/{domain}` | 활성/메모 변경 |
|
||||||
| DELETE | `/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` | 프록시 상태 + 통계 |
|
| GET | `/api/status` | 프록시 상태 + 통계 |
|
||||||
| POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) |
|
| POST | `/api/proxy/restart` | config 파일 touch (프록시 재로드 트리거) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""접속 로그 조회."""
|
"""접속 로그 조회 및 초기화."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlite3
|
import sqlite3
|
||||||
@@ -15,16 +15,26 @@ def list_logs(
|
|||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
action: str | None = Query(None),
|
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:
|
) -> dict:
|
||||||
if not LOG_DB.exists():
|
if not LOG_DB.exists():
|
||||||
return {"total": 0, "items": []}
|
return {"total": 0, "items": []}
|
||||||
con = sqlite3.connect(LOG_DB)
|
con = sqlite3.connect(LOG_DB)
|
||||||
try:
|
try:
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
where, params = "", []
|
conds: list[str] = []
|
||||||
|
params: list = []
|
||||||
if action:
|
if action:
|
||||||
where = "WHERE action = ?"
|
conds.append("action = ?")
|
||||||
params.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(
|
total = con.execute(
|
||||||
f"SELECT COUNT(*) FROM connections {where}", params
|
f"SELECT COUNT(*) FROM connections {where}", params
|
||||||
).fetchone()[0]
|
).fetchone()[0]
|
||||||
@@ -36,3 +46,40 @@ def list_logs(
|
|||||||
finally:
|
finally:
|
||||||
con.close()
|
con.close()
|
||||||
return {"total": total, "items": [dict(r) for r in rows]}
|
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}
|
||||||
|
|||||||
@@ -31,12 +31,10 @@ services:
|
|||||||
- mc-filter
|
- mc-filter
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
build: ./nginx
|
||||||
container_name: mc-filter-nginx
|
container_name: mc-filter-nginx
|
||||||
ports:
|
ports:
|
||||||
- "8080:80" # 대시보드 접근 포트 (외부 포트포워딩 금지 권장)
|
- "8080:80" # 대시보드 접근 포트 (외부 포트포워딩 금지 권장)
|
||||||
volumes:
|
|
||||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- api
|
- api
|
||||||
- frontend
|
- frontend
|
||||||
|
|||||||
@@ -32,5 +32,11 @@ export const api = {
|
|||||||
).toString()
|
).toString()
|
||||||
return req('/logs' + (q ? `?${q}` : ''))
|
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' }),
|
restart: () => req('/proxy/restart', { method: 'POST' }),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,16 +5,33 @@ function fmtTime(ts) {
|
|||||||
return new Date(ts * 1000).toLocaleString('ko-KR')
|
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() {
|
export default function Logs() {
|
||||||
const [data, setData] = useState({ total: 0, items: [] })
|
const [data, setData] = useState({ total: 0, items: [] })
|
||||||
const [filter, setFilter] = useState('')
|
const [filter, setFilter] = useState('')
|
||||||
|
const [date, setDate] = useState('')
|
||||||
const [auto, setAuto] = useState(true)
|
const [auto, setAuto] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
|
const [msg, setMsg] = useState(null)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const params = { limit: 100 }
|
const params = { limit: 100 }
|
||||||
if (filter) params.action = filter
|
if (filter) params.action = filter
|
||||||
|
if (date) {
|
||||||
|
params.from_ts = dayStart(date)
|
||||||
|
params.to_ts = dayEnd(date)
|
||||||
|
}
|
||||||
setData(await api.logs(params))
|
setData(await api.logs(params))
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -27,7 +44,30 @@ export default function Logs() {
|
|||||||
if (!auto) return
|
if (!auto) return
|
||||||
const id = setInterval(load, 3000)
|
const id = setInterval(load, 3000)
|
||||||
return () => clearInterval(id)
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -40,6 +80,19 @@ export default function Logs() {
|
|||||||
<option value="blocked">차단</option>
|
<option value="blocked">차단</option>
|
||||||
<option value="error">에러</option>
|
<option value="error">에러</option>
|
||||||
</select>
|
</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">
|
<label className="inline">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -49,6 +102,10 @@ export default function Logs() {
|
|||||||
자동 갱신 (3초)
|
자동 갱신 (3초)
|
||||||
</label>
|
</label>
|
||||||
<span className="muted">총 {data.total.toLocaleString()}건</span>
|
<span className="muted">총 {data.total.toLocaleString()}건</span>
|
||||||
|
<button className="danger" onClick={clearLogs}>
|
||||||
|
{clearLabel}
|
||||||
|
</button>
|
||||||
|
{msg && <span className="ok">{msg}</span>}
|
||||||
</div>
|
</div>
|
||||||
<table className="table">
|
<table className="table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|||||||
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
|
||||||
@@ -228,9 +228,16 @@ async def handle_client(
|
|||||||
)
|
)
|
||||||
hs = parse_handshake(hs_bytes)
|
hs = parse_handshake(hs_bytes)
|
||||||
except (HandshakeError, asyncio.TimeoutError, asyncio.IncompleteReadError, OSError) as exc:
|
except (HandshakeError, asyncio.TimeoutError, asyncio.IncompleteReadError, OSError) as exc:
|
||||||
log_event(client_ip, None, None, "blocked", f"handshake error: {exc}")
|
# str(exc) 가 빈 문자열인 예외들(OSError(), ConnectionResetError())
|
||||||
log.info("BLOCK %s reason=handshake_error (%s)", client_ip, exc)
|
# 도 있어서 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()
|
client_writer.close()
|
||||||
|
await client_writer.wait_closed()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
domain = hs.server_address.lower().strip()
|
domain = hs.server_address.lower().strip()
|
||||||
@@ -247,9 +254,20 @@ async def handle_client(
|
|||||||
msg = state.cfg.get("block_message") or DEFAULT_BLOCK_MESSAGE
|
msg = state.cfg.get("block_message") or DEFAULT_BLOCK_MESSAGE
|
||||||
client_writer.write(build_login_disconnect(msg))
|
client_writer.write(build_login_disconnect(msg))
|
||||||
await client_writer.drain()
|
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):
|
except (OSError, ConnectionResetError, BrokenPipeError):
|
||||||
pass
|
pass
|
||||||
|
try:
|
||||||
client_writer.close()
|
client_writer.close()
|
||||||
|
await client_writer.wait_closed()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
return
|
return
|
||||||
|
|
||||||
backend_host, backend_port = target
|
backend_host, backend_port = target
|
||||||
|
|||||||
Reference in New Issue
Block a user