휴고서버 관리 대시보드 코드 공개 (hugodash.py)
·41 분
더 편하게 읽는 방법 (초보자 추천)
아래 코드는 1,400줄이 넘어서 이 페이지에서 쭉 훑어보기엔 불편할 수 있습니다. 함수마다 색깔로 구분되고, 우측 목차에서 원하는 함수를 클릭하면 바로 그 자리로 이동하는 정리된 버전을 따로 만들어뒀습니다. 코드를 직접 뜯어보고 싶으시다면 이쪽을 먼저 열어보시길 추천합니다.
이 글은 제가 VMware + 우분투 위에서 Hugo 블로그를 돌리는 서버를 웹 화면에서 살펴보고
관리하기 위해 만든 대시보드 프로그램(hugodash.py) 코드를 공개하는 글입니다.
프레임워크도 PHP도 없이 파이썬 표준 라이브러리(http.server)만으로, 화면(HTML)과 처리
기능을 한 파일에서 모두 만들었습니다. 코딩을 잘 몰라도 흐름을 따라올 수 있도록 함수마다
한글 주석을 달았습니다. 다만 실제 운영 서버의 보안과 직결되는 값들(접근 차단 경로 목록 등)은
***로 가리고 계정 이름은 myuser로 바꿨으니, 그대로 복붙해서 쓰기보다는 참고용으로 봐주세요.
이 대시보드의 보안 설계는 이렇습니다.
- 로그인 화면이 없습니다. 대신 서버 자기 자신(
127.0.0.1)에서만 접속을 받아서, SSH 터널을 열 수 있는 사람(= SSH 키를 가진 사람)만 화면을 볼 수 있습니다. 인터넷에서는 아예 접속이 안 됩니다. - 화면에서 버튼을 숨기는 것만 믿지 않습니다. 경로·IP·권한 숫자 같은 값은 요청이 올 때마다 서버에서 다시 검사합니다. (막힌 경로 판정, 블로그 폴더 밖 저장 거부, IP 형식 검사 등)
- 바꾸는 동작은 최소한으로 만들었습니다. 파일 수정은 블로그 폴더 안에서만 되고, 저장할 때마다
원본을
.bak-시각파일로 먼저 백업합니다. 권한 변경은 읽기·쓰기·실행 숫자만 바꾸고, 주인·그룹은 바꾸지 않습니다. - 작업 기록: 저장, 권한 변경, 차단 해제는 전부 기록 파일에 한 줄씩 남깁니다.
- 다른 사이트가 내 브라우저를 시켜 몰래 요청을 보내는 것(CSRF)과, 다른 이름으로 접속을 속이는 것 (DNS 리바인딩)도 막는 장치가 들어 있습니다.
전체 코드는 아래와 같습니다.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
휴고서버 대시보드 (터널 전용)
- 127.0.0.1 에만 바인딩한다. SSH 터널(ssh -L 8081:127.0.0.1:8081 ...)을 열어야만 접속 가능.
- 로그인/OTP 없음. "터널을 열 수 있는 사람 = SSH 키를 가진 사람" 이 곧 인증이다.
- 화면(HTML)과 API 를 이 파일 하나가 모두 처리한다. PHP/nginx 를 쓰지 않는다.
- 파일 탐색기: 서버 전체(/)가 보이고, 막을 곳(BLOCKED_PATHS)만 🔒 처리한다.
쓰기 가능 : WRITE_ROOTS (블로그 폴더) 뿐
읽기 전용 : 막은 곳을 뺀 나머지 전부
접근 불가 : BLOCKED_PATHS (목록에는 보이되 열 수 없음). 서버 쪽에서 직접 검사한다.
- 목록에 소유(주인:그룹)와 권한(rwx)을 보여 준다. 쓰기 가능 영역(블로그 폴더)의 것은 눌러서 '권한'만 바꿀 수 있다(최소 접근 원칙).
주인·그룹은 바꾸지 않는다. 이 프로그램이 일반 계정으로 직접 하며 새 sudo 규칙이 없다.
주인이 myuser 가 아닌 파일은 바꿀 수 없다.
"""
import grp # 사용자 그룹 이름 조회 (그룹 번호 → 이름)
import html # 화면에 찍을 글자를 안전하게 바꾸기(html.escape)
import json # 작업 기록을 JSON 한 줄로 저장할 때
import os # 경로·파일·환경변수 등 운영체제 기능
import pwd # 사용자 이름 조회 (사용자 번호 → 이름)
import re # 정규식(패턴 검사): 로그 줄 분석, 입력값 검사
import shutil # 파일 복사(백업), 디스크 용량 조회
import stat # 파일 종류·권한 비트 해석
import subprocess # systemctl, fail2ban-client 같은 리눅스 명령 실행
import sys # 프로그램 종료·오류 출력
import time # 시간 관련 기능 (표준 라이브러리)
from datetime import datetime # 현재 시각 계산·기록
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer # 웹서버의 핵심 부품 (표준 라이브러리라 따로 설치할 것이 없음)
from urllib.parse import urlparse, parse_qs, urlencode, quote # 주소·폼 값 분해, 주소 만들기/인코딩
# =====================================================================
# 이 코드는 무엇인가? (초보자를 위한 개요)
# =====================================================================
# 블로그를 돌리는 서버(nginx + Hugo)를 웹 화면(대시보드)에서 살펴보고
# 관리할 수 있게 해주는 파이썬 프로그램 하나(hugodash.py)입니다.
#
# - 프레임워크(Django, Flask 같은 것)도, PHP도, 별도 웹서버 설정도 없이
# 파이썬 표준 라이브러리의 http.server 만으로 화면(HTML)과 처리 기능을
# 한 파일에서 모두 만들었습니다.
# - 로그인 화면이 없습니다. 대신 이 프로그램은 127.0.0.1(서버 자기 자신)에서만
# 접속을 받기 때문에, SSH 터널(ssh -L)을 열 수 있는 사람만 화면을 볼 수 있습니다.
# "SSH 키를 가진 사람 = 관리자" 라는 뜻입니다.
# - 화면은 탭 4개: 대시보드 / 파일 탐색기 / 서버 로그 / 보안 점검.
# - 파일 탐색기는 서버 전체를 보여주되, 위험한 곳(계정정보·SSH·인증서 개인키 등)은
# 목록에는 보여도 열 수 없게(🔒) 서버 쪽에서 막습니다. 수정은 블로그 폴더 안에서만
# 되고, 저장할 때마다 원본을 .bak-시각 파일로 먼저 백업합니다.
# - 바꾸는 동작(저장, 권한 변경, 차단 해제)은 전부 작업 기록 파일에 남깁니다.
# - 바깥에서 오는 값(경로, IP, 권한 숫자)은 항상 서버에서 다시 검사합니다.
# 화면에서 버튼을 숨기는 것만 믿지 않습니다.
# - do_GET(화면 보기) / do_POST(바꾸는 동작) 두 함수가 요청을 받아 알맞은
# 함수로 넘겨주는 교통정리 역할을 합니다.
#
# ※ 이 글은 학습/공유 목적으로 일부 보안에 민감한 값(***)을 가리고 계정 이름을
# myuser 로 바꿔서 공개합니다. 실제 운영 시에는 이 코드를 그대로 복붙하지 말고,
# 본인 환경에 맞게 검토 후 사용하세요.
# =====================================================================
# ----------------------------------------------------------------------------
# 설정
# ----------------------------------------------------------------------------
# 이 서버가 "어느 주소로 오는 접속을 받을지" 정하는 값입니다.
# 127.0.0.1 은 "이 컴퓨터 자기 자신"이라는 뜻이라, 인터넷 쪽에서는 이 대시보드에 접속할 수 없습니다.
# (그래서 내 PC에서 SSH 터널을 열어야만 화면이 보입니다. 이 프로그램은 로그인 화면이 없습니다.)
BIND_HOST = "127.0.0.1" # 절대 바꾸지 말 것 (0.0.0.0 으로 바꾸면 외부에 공개됨)
# 접속 포트 번호. 환경변수 HUGODASH_PORT 가 있으면 그 값, 없으면 8081 을 씁니다.
BIND_PORT = int(os.environ.get("HUGODASH_PORT", "8081"))
# --- 파일 탐색기 권한 (막을 곳만 지정) ---
# "저장(수정)이 허용되는 폴더" 목록입니다. 블로그 폴더 하나뿐이고, 여기 밖의 파일은 볼 수만 있습니다.
WRITE_ROOTS = ["/home/myuser/blog.snsoz.com"] # 저장(수정)은 여기서만 가능
# [보안 마스킹]
# 파일탐색기가 "접근 금지"로 취급하는 실제 경로 목록입니다.
# 이 값을 그대로 공개하면 "여기만 피하면 뚫린다"는 지도를 넘겨주는 셈이라
# 블로그 공개용 코드에서는 실제 값 대신 자리표시자(***)로 가려두었습니다.
# 실제로 운영할 때는 시스템 계정 정보(/etc/shadow, /etc/passwd 등), SSH 설정,
# 크론, systemd, PAM/보안 설정, /root, 인증서 개인키, 로그인 열쇠(.ssh) 등
# "건드리면 서버 전체가 위험해지는 경로"를 최대한 넉넉하게 등록해두세요.
BLOCKED_PATHS = [ # 목록에는 보이되 열람·수정 모두 차단
"***", "***", "***", "***", "***", # 예: 계정정보 / SSH / 크론 / systemd / 보안설정 등 (실제 값은 비공개)
"***", "***", "***", # 예: 인증서 개인키, 로그인 열쇠 등 (실제 값은 비공개)
]
# 파일 탐색기 위쪽에 나오는 바로가기 버튼들 (표시할 이름과 이동할 경로).
QUICKLINKS = [
{"path": "/", "label": "전체 (/)"},
{"path": "/home/myuser/blog.snsoz.com", "label": "블로그 (수정 가능)"},
{"path": "/home/myuser", "label": "홈 (myuser)"},
{"path": "/etc/nginx", "label": "Nginx 설정"},
{"path": "/opt/hugodash", "label": "대시보드"},
{"path": "/usr/local/bin", "label": "스크립트"},
{"path": "/var/log", "label": "로그"},
]
# 15MB 를 넘는 파일은 화면에서 열지 않습니다 (브라우저가 멈추는 것 방지).
MAX_VIEW_BYTES = 15 * 1024 * 1024
# 3MB 를 넘는 파일은 볼 수는 있어도 수정·저장은 거부합니다.
MAX_EDIT_BYTES = 3 * 1024 * 1024
# --- 권한 변경 (최소 접근 원칙) ---
# 바꿀 수 있는 곳: WRITE_ROOTS(블로그 폴더) 안의, 주인이 myuser 인 일반 파일·폴더 하나씩. 폴더 안까지 한꺼번에 바꾸는 기능은 없다.
# 바꾸는 것은 읽기·쓰기·실행 권한뿐이다(주인·그룹 변경 없음, 새 sudo 규칙 없음).
# --- 로그 목록 ---
# "서버 로그" 탭에 버튼으로 나오는 로그 목록입니다. 한 줄이 로그 하나입니다.
# 종류가 file 이면 로그 파일의 끝부분을 읽고, journal 이면 journalctl 로 서비스 기록을 읽습니다.
LOG_SOURCES = [
# (키, 라벨, 종류, 값, 그룹)
("nginx_access", "Nginx 접속 로그", "file", "/var/log/nginx/access.log", "웹 서버"),
("blog_access", "블로그 전용 접속 로그", "file", "/var/log/nginx/blog-access.log", "웹 서버"),
("nginx_error", "Nginx 에러 로그", "file", "/var/log/nginx/error.log", "웹 서버"),
("hugo_build", "Hugo 빌드 로그", "file", "/home/myuser/hugo-build.log", "블로그"),
("letsencrypt", "SSL 인증서(Certbot) 로그", "file", "/var/log/letsencrypt/letsencrypt.log", "블로그"),
("fail2ban_log", "fail2ban 로그", "file", "/var/log/fail2ban.log", "보안"),
("auth_log", "인증 로그 (SSH 등)", "file", "/var/log/auth.log", "보안"),
("syslog", "시스템 로그", "file", "/var/log/syslog", "시스템"),
("kern", "커널 로그", "file", "/var/log/kern.log", "시스템"),
("dpkg", "패키지 설치/업데이트 로그", "file", "/var/log/dpkg.log", "시스템"),
("nginx_journal", "Nginx 서비스 기록", "journal", "nginx", "서비스"),
("ssh_journal", "SSH 서비스 기록", "journal", "ssh", "서비스"),
("fail2ban_journal", "fail2ban 서비스 기록", "journal", "fail2ban", "서비스"),
]
# 대시보드 "서비스 상태"에 살아있는지 보여줄 systemd 서비스들 (서비스 이름, 화면에 쓸 이름).
SERVICE_UNITS = [("nginx", "Nginx"), ("ssh", "SSH"), ("fail2ban", "fail2ban")]
# 큰 로그 파일도 끝에서 최대 2MB 만 읽어서 빠르게 보여줍니다.
LOG_TAIL_MAX_BYTES = 2 * 1024 * 1024
# 로그 화면에서 고를 수 있는 "몇 줄 보기" 선택지.
LINE_CHOICES = [50, 100, 200, 500]
# --- 감사(작업) 기록: 파일 저장 같은 쓰기 작업만 남긴다 ---
# 작업 기록 파일 위치. 환경변수 HUGODASH_AUDIT_LOG 로 바꿀 수 있습니다.
AUDIT_LOG_PATH = os.environ.get("HUGODASH_AUDIT_LOG", "/home/myuser/hugodash-audit.log")
def audit(action, target, result, detail=""):
# 쓰기 작업(파일 저장, 권한 변경, 차단 해제)을 한 줄짜리 JSON 으로 기록 파일에 남기는 함수.
# 나중에 "언제, 무엇을, 어떻게 바꿨는지" 되짚어 볼 때 씁니다. 기록이 실패해도 본 작업은 막히지 않게 오류는 무시합니다.
try:
rec = { # 기록 한 건의 내용을 사전(dict)으로 만든다
"ts": datetime.now().isoformat(timespec="seconds"),
"action": action,
"target": target,
"result": result,
"detail": detail,
}
with open(AUDIT_LOG_PATH, "a", encoding="utf-8") as f: # 기록 파일을 '이어쓰기(a)' 모드로 연다
f.write(json.dumps(rec, ensure_ascii=False) + "\n") # JSON 한 줄로 저장 (한글이 깨지지 않게 ensure_ascii=False)
except Exception: # 기록이 실패해도 원래 작업(저장 등)은 막지 않는다
pass
# ----------------------------------------------------------------------------
# 경로 권한 판정 (핵심 보안 부분)
# ----------------------------------------------------------------------------
def _real(p):
# 심볼릭 링크(바로가기)를 전부 풀어서 "진짜 경로"를 돌려줍니다. 보안 검사는 항상 이 진짜 경로로 합니다.
return os.path.realpath(p) # 링크를 모두 풀고 .. 도 정리한 절대경로
def _is_under(path, root):
# path 가 root 폴더 "안"(또는 root 자신)인지 확인하는 함수. 접두사만 비교하면 /home/a 와 /home/ab 를 헷갈리므로 끝에 / 를 붙여 비교합니다.
root = _real(root) # 비교 기준 폴더도 진짜 경로로
return path == root or path.startswith(root + os.sep) # 같은 경로이거나, '기준폴더/' 로 시작하면 그 안
def classify(path):
"""실제 경로(심볼릭 링크를 풀어낸 뒤)로 권한을 판정한다.
반환: 'deny' (막은 곳) | 'write' (블로그 폴더 안) | 'read' (그 외 전부)
"""
# 이 프로그램의 핵심 보안 판정 함수. 어떤 경로든 세 가지 중 하나로 분류합니다.
# deny = 막은 곳 (목록에는 보여도 열 수 없음) / write = 블로그 폴더 안 (수정 가능) / read = 그 외 전부 (읽기만)
# 화면에서 버튼을 숨기는 것으로 끝내지 않고, 파일을 열거나 저장하기 직전에도 서버 쪽에서 매번 다시 호출합니다.
real = _real(path) # 링크를 다 풀어낸 진짜 경로를 먼저 구한다 (링크로 벽을 넘는 꼼수 방지)
for d in BLOCKED_PATHS: # 1순위: 막아둔 경로들을 하나씩 확인
if _is_under(real, d): # 이 경로가 막힌 곳 '안'이면
return "deny" # 접근 불가로 판정 (목록에는 보여도 열 수 없음)
for r in WRITE_ROOTS: # 2순위: 쓰기 허용 폴더(블로그 폴더)인지 확인
if _is_under(real, r): # 그 폴더 '안'이면
return "write" # 수정 가능으로 판정
return "read" # 나머지는 전부 '읽기만 가능'
def resolve(raw_path):
"""사용자가 준 경로를 정규화해 실제 경로로 바꾼다. 절대경로만 허용."""
# 사용자가 URL 로 보낸 경로 문자열을 안전한 진짜 경로로 바꿉니다. ../ 같은 꼼수도 여기서 다 정리됩니다.
raw_path = (raw_path or "/").strip() # 비어 있으면 루트(/), 앞뒤 공백은 제거
if "\x00" in raw_path: # 글자 중간에 숨긴 널 문자(\x00)가 있으면 거부 (경로 검사를 속이는 수법)
raise ValueError("bad_path") # 잘못된 경로라고 알림
if not raw_path.startswith("/"): # 절대경로(/ 로 시작)만 허용, 아니면 앞에 / 를 붙임
raw_path = "/" + raw_path
return _real(raw_path) # 링크와 ../ 를 모두 정리한 진짜 경로를 돌려줌
# ----------------------------------------------------------------------------
# 소유·권한 (표시와 변경)
# ----------------------------------------------------------------------------
def uid_name(uid):
# 숫자 사용자 번호(uid)를 이름으로 바꿔서 화면에 보여줍니다. 이름이 없으면 숫자 그대로.
try:
return pwd.getpwuid(uid).pw_name
except KeyError:
return str(uid)
def gid_name(gid):
# 숫자 그룹 번호(gid)를 이름으로 바꿉니다.
try:
return grp.getgrgid(gid).gr_name
except KeyError:
return str(gid)
def mode_octal(mode):
# 권한 숫자를 3자리 8진수 문자열로 (예: 0o100644 → "644").
return format(mode & 0o777, "03o")
def mode_rwx(mode):
# 권한을 rwxr-xr-x 모양의 글자로 (ls -l 에서 보던 그 모양).
return stat.filemode(mode)[1:]
def perm_block_reason(real, st):
"""이 화면에서 소유·권한을 바꿀 수 없는 이유. 바꿀 수 있으면 빈 문자열."""
# "이건 여기서 권한을 바꾸면 안 되는 대상"이면 그 이유를, 바꿔도 되면 빈 문자열을 돌려줍니다.
# 화면(변경 가능 여부 표시)과 실제 변경 함수가 같은 기준을 쓰도록 한 곳에 모아뒀습니다.
if classify(real) != "write": # 블로그 폴더 밖이면 바꾸지 않음
return "블로그 폴더 밖이라 여기서 바꿀 수 없습니다."
if stat.S_ISLNK(st.st_mode): # 링크(바로가기)이면 링크가 가리키는 곳이 바뀌어 버리므로 바꾸지 않음
return "링크(바로가기)는 바꾸지 않습니다."
if not (stat.S_ISREG(st.st_mode) or stat.S_ISDIR(st.st_mode)): # 일반 파일도 폴더도 아니면(장치·파이프 등) 바꾸지 않음
return "일반 파일과 폴더만 바꿉니다."
if st.st_mode & 0o7000: # setuid/setgid/sticky 같은 특수 권한 비트가 붙어 있으면 바꾸지 않음
return "특수 권한(setuid·setgid·sticky)이 붙어 있어 바꾸지 않습니다."
return "" # 문제 없음 → 빈 문자열
def perm_apply(real, mode_s):
"""권한(읽기·쓰기·실행)을 바꾸고 결과를 다시 읽어 확인한다. (성공 여부, 화면에 보여 줄 문장)"""
# 파일·폴더 하나의 권한(읽기·쓰기·실행)을 바꾸고, 바뀐 결과를 다시 읽어서 확인하는 함수.
# 돌려주는 값: (성공 여부, 화면에 보여줄 문장). 주인·그룹은 바꾸지 않고 권한 숫자만 바꿉니다.
def fail(msg, detail=""): # 실패 처리를 한 곳에 모은 작은 도우미: 실패를 기록하고 (False, 메시지)를 돌려준다
audit("file_perm", real, "fail", (detail or msg)) # 실패도 작업 기록에 남긴다
return False, msg
if not re.fullmatch(r"[0-7]{3}", mode_s or ""): # 권한 값은 숫자 3자리(각 0~7)여야 한다. 엉뚱한 값은 거부
return fail("권한 값이 올바르지 않습니다.")
if int(mode_s[2]) & 2: # 마지막 자리가 '나머지 모두'의 권한. 거기에 쓰기(2)가 들어 있으면 거부
return fail("'나머지 모두'에게 쓰기 권한은 줄 수 없습니다.")
try:
st = os.lstat(real) # 링크를 따라가지 않고 이 파일 자체의 정보를 읽는다
except FileNotFoundError: # 파일이 없으면
return fail("파일을 찾을 수 없습니다.")
except PermissionError: # 위쪽 폴더에 들어갈 권한이 없으면
return fail("이 파일이나 그 위 폴더가 myuser 에게 열려 있지 않아 확인할 수 없습니다.")
except OSError as e: # 그 밖의 파일 시스템 오류
return fail(f"파일 상태를 확인하지 못했습니다: {e.strerror}")
reason = perm_block_reason(real, st) # 블로그 밖·링크·특수 파일처럼 '바꾸면 안 되는 대상'인지 확인
if reason:
return fail(reason)
if st.st_uid != os.geteuid(): # 이 프로그램을 실행 중인 계정이 파일 주인이 아니면 chmod 가 어차피 안 되므로 미리 거절
return fail(f"이 파일의 주인은 {uid_name(st.st_uid)} 입니다. 주인이 myuser 인 것만 바꿀 수 있습니다.", "not_owner")
cur_mode = st.st_mode & 0o777 # 지금 권한 숫자 (예: 644)
want_mode = int(mode_s, 8) # 바꾸려는 권한 (8진수 문자열 → 숫자)
if want_mode == cur_mode: # 이미 같은 값이면 아무것도 하지 않고 종료
return True, "바뀐 것이 없습니다 (지금 값과 같음)."
try:
os.chmod(real, want_mode) # 실제로 권한을 바꾸는 유일한 줄
except OSError as e: # 권한 변경이 실패한 경우 (예: 읽기 전용 디스크)
return fail(f"적용하지 못했습니다: {e.strerror}", f"chmod_failed:{e.strerror}")
try:
st2 = os.lstat(real) # 바꾼 뒤 다시 읽어서 진짜 바뀌었는지 확인 ("됐겠지" 하고 넘어가지 않는다)
except OSError as e: # 다시 읽기가 실패한 경우
return fail(f"바꾼 뒤 확인하지 못했습니다: {e.strerror}", "verify_failed")
if (st2.st_mode & 0o777) != want_mode: # 다시 읽은 값이 원하는 값과 다르면 실패로 처리
return fail(f"적용은 했지만 다시 읽어 보니 값이 다릅니다: {mode_octal(st2.st_mode)}", "verify_mismatch")
# 성공도 기록에 남긴다 (예전 값 -> 새 값)
audit("file_perm", real, "ok", f"권한 {mode_octal(cur_mode)} -> {mode_octal(want_mode)}")
# 성공 메시지
return True, f"변경 완료 (권한 {mode_octal(cur_mode)} → {mode_octal(want_mode)})"
# ----------------------------------------------------------------------------
# 시스템 조회 함수
# ----------------------------------------------------------------------------
def run_lines(cmd, timeout=15):
# 리눅스 명령어를 실행하고 결과 글자를 돌려주는 공용 도우미. (출력, 오류메시지) 중 하나만 채워서 돌려줍니다.
# 명령이 없거나 시간이 오래 걸려도 대시보드가 멈추지 않도록 시간 제한(timeout)을 둡니다.
try:
# 명령 실행: 출력을 글자로 받고(text), 정해진 시간(timeout) 안에 끝나야 함
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
out = (r.stdout or "").rstrip("\n") # 정상 출력 (끝의 줄바꿈 제거)
err = (r.stderr or "").strip() # 오류 출력
if not out and err: # 출력이 없고 오류만 있으면 오류를 돌려줌
return None, err
return out, None
except FileNotFoundError: # 그런 명령어가 설치되어 있지 않으면
return None, "명령 없음: " + cmd[0]
except subprocess.TimeoutExpired: # 시간 안에 안 끝나면 (멈춘 명령 때문에 화면이 안 열리는 것 방지)
return None, "명령 시간 초과"
except Exception as e: # noqa
return None, str(e)
def stat_state(path):
"""경로가 어떤 상태인지만 본다(읽기 권한은 따지지 않음).
('file'|'dir'|'other', 정보) / ('missing'|'denied'|'error', None)
- missing : 그런 경로가 없다
- denied : 있는지 확인하려는데 상위 폴더에 들어갈 권한이 없다 (os.path.isfile 은 이 경우도 그냥 False 라서 없는 것과 구분이 안 됐음)
- error : 그 밖의 오류
"""
try:
st = os.stat(path) # 경로 정보를 읽어본다 (읽기 권한은 따지지 않음)
except (FileNotFoundError, NotADirectoryError): # 그런 파일이 없다
return "missing", None
except PermissionError: # 상위 폴더에 들어갈 권한이 없다 → '없음'과 구분해서 알려주는 게 이 함수의 핵심
return "denied", None
except OSError:
return "error", None
if stat.S_ISREG(st.st_mode):
return "file", st
if stat.S_ISDIR(st.st_mode):
return "dir", st
return "other", st
def probe_file(path):
"""로그 파일을 읽을 수 있는지. ('ok', 크기) / ('missing'|'denied'|'error', None)"""
# 로그 파일을 실제로 열어볼 수 있는지 미리 확인 (열 수 있으면 'ok' 와 파일 크기).
state, st = stat_state(path)
if state in ("dir", "other"):
return "missing", None # 파일이 아니면 로그로 볼 수 없으니 '없음'으로 본다
if state != "file":
return state, None
try:
with open(path, "rb"):
pass
except PermissionError:
return "denied", None
except OSError:
return "error", None
return "ok", st.st_size
# 파일 상태별로 화면에 보여줄 짧은 이름과 마우스를 올렸을 때 나오는 설명.
FILE_STATE_LABEL = {"missing": "파일 없음", "denied": "권한 없음", "error": "읽기 실패"}
FILE_STATE_HINT = {
"missing": "그 파일이 없습니다",
"denied": "이 대시보드는 myuser 계정으로 실행됩니다. 파일이나 그 위 폴더가 myuser 에게 열려 있지 않습니다",
"error": "파일 상태를 확인하지 못했습니다",
}
def read_problem(path, state):
"""읽을 수 없는 이유를 화면에 보여 줄 문장으로."""
# 로그를 못 읽었을 때 그 이유(없음/권한 없음/기타)를 화면에 보여줄 문장으로 바꿔줍니다.
if state == "missing":
return "파일이 아직 없습니다: " + path
if state == "denied":
return "권한이 없어 읽을 수 없습니다 (" + FILE_STATE_HINT["denied"] + "): " + path
return "읽을 수 없습니다: " + path
def tail_file(path, n):
"""파일 끝에서 n줄을 최신순(아래→위)으로 돌려준다."""
# 파일의 끝에서 n 줄을 최신순(아래→위)으로 가져옵니다. 파일 전체를 읽지 않고 끝부분만 읽어서 큰 로그도 빠릅니다.
state, _size = probe_file(path) # 먼저 읽을 수 있는 파일인지 확인
if state != "ok":
return None, read_problem(path, state)
try:
size = os.path.getsize(path)
with open(path, "rb") as f:
f.seek(max(0, size - min(size, LOG_TAIL_MAX_BYTES))) # 파일 끝에서 최대 2MB 앞으로 이동해서 그 뒤만 읽는다
data = f.read()
lines = data.decode("utf-8", errors="replace").splitlines()
lines = lines[-n:] # 마지막 n 줄만 남기고
lines.reverse() # 최신 줄이 위로 오게 뒤집는다
return lines, None
except PermissionError:
return None, "권한이 없어 읽을 수 없습니다: " + path
except Exception as e: # noqa
return None, str(e)
def journal_tail(unit, n):
# journalctl 로 서비스(systemd unit)의 최근 기록 n 줄을 가져옵니다.
out, err = run_lines(["journalctl", "-u", unit, "-n", str(n), "-r", "--no-pager"])
if out is None:
return None, err or "내용 없음"
return out.splitlines(), None
def is_active(unit):
# systemctl is-active 로 서비스가 실행 중인지 확인 ("active" 이면 True).
out, _ = run_lines(["systemctl", "is-active", unit])
return (out or "").strip() == "active"
def fail2ban_bans():
"""[(jail, [ip,...]), ...] 또는 None"""
# fail2ban 이 지금 차단 중인 IP 목록을 [(jail 이름, [IP, ...]), ...] 로 돌려줍니다.
# fail2ban-client 는 root 권한이 필요해서 sudo -n(비밀번호 묻지 않음)으로 부르고, 이 명령만 허용하도록 sudo 규칙을 좁게 만들어 둡니다.
# sudo -n : 비밀번호를 묻지 않는 sudo. 허용 안 된 명령이면 그냥 실패한다
out, err = run_lines(["sudo", "-n", "fail2ban-client", "status"])
if out is None:
return None
m = re.search(r"Jail list:\s*(.+)", out) # 출력에서 'Jail list:' 뒤의 jail 이름 목록을 뽑는다
if not m:
return None
jails = [j.strip() for j in m.group(1).split(",") if j.strip()]
result = []
for jail in jails: # jail 마다 다시 조회해서
jo, _ = run_lines(["sudo", "-n", "fail2ban-client", "status", jail])
ips = []
if jo:
bm = re.search(r"Banned IP list:\s*(.*)", jo) # 그 jail 이 차단 중인 IP 목록을 뽑는다
if bm:
ips = [x for x in bm.group(1).split() if x]
result.append((jail, ips))
return result
def fail2ban_unban(ip, jail=""):
# 입력받은 IP 의 차단을 풀어주는 함수. 아무 글자나 명령에 끼워 넣지 못하도록 IP 형식부터 검사합니다.
import ipaddress # IP 형식 검사용 표준 라이브러리
try:
ipaddress.ip_address(ip) # 올바른 IPv4/IPv6 주소가 아니면 여기서 오류가 난다
except ValueError: # 형식이 틀리면 명령에 넣지 않고 바로 거절
return False, "올바른 IP 형식이 아닙니다."
bans = fail2ban_bans() # 지금 존재하는 jail 목록을 조회
if bans is None: # 조회 실패 (sudo 규칙이 없거나 fail2ban 이 꺼진 경우)
return False, "fail2ban 조회 실패 (sudo 권한 확인 필요)"
jails = [j for j, _ in bans] # jail 이름만 뽑는다
if jail: # 특정 jail 을 골랐으면
if jail not in jails: # 목록에 없는 이름이면 거절 (임의 글자를 명령에 넣지 못하게)
return False, "없는 jail 입니다."
jails = [jail] # 그 jail 하나만 대상으로
done = False # 한 곳이라도 풀었는지 표시
for j in jails: # 대상 jail 마다
# fail2ban-client set <jail> unbanip <IP> 실행
out, _ = run_lines(["sudo", "-n", "fail2ban-client", "set", j, "unbanip", ip])
if out and "is not banned" not in out: # 그 jail 에서 실제로 풀렸으면
done = True # 풀렸다고 표시
if done: # 한 곳이라도 풀었으면
audit("unban", ip, "ok", jail or "all") # 누가 어느 IP 를 풀었는지 기록
return True, f"{ip} 차단 해제 완료" # 성공 메시지
return False, f"{ip} 는 어떤 jail 에도 차단되어 있지 않거나 해제에 실패했습니다." # 아무 jail 에도 없었거나 실패
def top404(path, limit=20):
# 접속 기록에서 404(없는 주소) 요청을 세어서 많이 두드린 주소 상위 N개를 보여줍니다. 자동 스캐너 탐지용.
state, _size = probe_file(path)
if state != "ok":
return None, read_problem(path, state)
counts = {}
try:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
m = re.search(r'"[A-Z]+ (\S+)[^"]*"\s+404\s', line)
if m:
counts[m.group(1)] = counts.get(m.group(1), 0) + 1
except PermissionError:
return None, "권한이 없어 읽을 수 없습니다: " + path
if not counts:
return ["(404 없음)"], None
top = sorted(counts.items(), key=lambda x: x[1], reverse=True)[:limit]
return [f"{c}회 {p}" for p, c in top], None
def security_checks(n):
# "보안 점검" 탭이 실행하는 진단 모음. 항목마다 (이름, 힌트, 결과 줄들)을 모아서 돌려줍니다.
# 전부 읽기만 하는 명령이며, 탭에 들어올 때마다 실제 서버에서 새로 실행합니다.
items = []
def add(key, label, hint, lines, err=None): # 항목 하나를 결과 목록에 넣는 작은 도우미 (결과가 비었을 때 안내 문구도 채워줌)
if lines is None:
lines = ["(" + (err or "내용 없음") + ")"]
elif not lines:
lines = ["(해당 없음)"]
items.append({"key": key, "label": label, "hint": hint, "lines": lines})
out, err = run_lines(["last", "-a", "-n", str(n)]) # 1) 최근 SSH 로그인 성공 이력 (last)
lines = None
if out is not None:
lines = [l for l in out.splitlines() if l.strip() and not re.match(r"^(wtmp|btmp) begins", l.strip())]
add("ssh_success", f"SSH 로그인 성공 이력 (최근 {n}건)",
"낯선 IP나 본인이 접속하지 않은 시간대가 있으면 침해 의심 신호입니다.", lines, err)
out, err = run_lines(["sudo", "-n", "lastb", "-n", str(n)]) # 2) 로그인 실패 이력 (lastb, sudo 필요)
lines = None
if out is not None:
lines = [l for l in out.splitlines() if l.strip() and not re.match(r"^(wtmp|btmp) begins", l.strip())]
add("ssh_failed", f"SSH 로그인 실패 이력 (최근 {n}건)",
"반복 실패는 흔한 브루트포스 시도입니다. fail2ban 이 차단 중이면 큰 문제는 아닙니다.", lines, err)
fl, ferr = tail_file("/var/log/fail2ban.log", 100000) # 3) fail2ban 로그에서 'Ban' 이 들어간 줄만 (최근 차단 이력)
lines = None
if fl is not None:
lines = [l for l in fl if re.search(r"\bBan\b", l)][:n]
add("fail2ban_bans", f"fail2ban 최근 차단 이력 (최근 {n}건)",
"평소보다 차단 빈도가 급증했으면 스캔/공격이 진행 중이라는 신호입니다.", lines, ferr)
tl, terr = top404("/var/log/nginx/blog-access.log", 20) # 4) 블로그 404 상위 경로 (스캐너 탐지)
add("blog_404_top", "블로그 404 상위 경로 (상위 20개)",
"존재하지 않는 경로(/wp-admin, /.env 등)를 다수 두드리는 건 흔한 자동 스캐너입니다.", tl, terr)
cmds = [ # 5) 나머지는 (키, 이름, 힌트, 실행할 명령) 표로 만들어 반복 실행
("crontabs", "등록된 크론잡", "본인이 등록하지 않은 항목이 있으면 확인하세요.",
["bash", "-c", "for u in root www-data myuser; do echo \"--- $u ---\"; sudo -n crontab -u $u -l 2>/dev/null || echo '(등록된 크론잡 없음)'; done"]),
("cron_dirs", "/etc/cron.d, /etc/cron.daily 목록", "낯선 파일이 있으면 확인하세요.",
["bash", "-c", "echo '--- /etc/cron.d ---'; ls -la /etc/cron.d/ 2>/dev/null; echo '--- /etc/cron.daily ---'; ls -la /etc/cron.daily/ 2>/dev/null"]),
("running_services", "실행 중인 systemd 서비스", "낯선 서비스가 있으면 확인하세요.",
["bash", "-c", f"systemctl list-units --type=service --state=running --no-legend --no-pager | head -n {n}"]),
("recent_files", "최근 7일 내 수정된 시스템 파일", "본인이 바꾸지 않은 파일이 있으면 침해 의심 신호입니다.",
["bash", "-c", f"find /etc /usr/local/bin /opt -mtime -7 -type f 2>/dev/null | head -n {n}"]),
("established_conn", "현재 연결 세션 (ESTABLISHED)", "낯선 IP와의 연결이 있으면 확인하세요.",
["bash", "-c", f"sudo -n ss -tnp state established 2>/dev/null | head -n {n}"]),
]
for key, label, hint, cmd in cmds: # 표의 명령을 하나씩 실행해서 결과를 담는다
out, err = run_lines(cmd)
lines = out.splitlines() if out else None
add(key, label, hint, lines, err)
return items
def read_uptime_load():
# 서버가 켜진 지 얼마나 됐는지(가동 시간)와 최근 1/5/15분 평균 부하를 읽습니다.
try:
with open("/proc/uptime") as f:
up = float(f.read().split()[0])
days, rem = divmod(int(up), 86400)
hours, rem = divmod(rem, 3600)
mins = rem // 60
up_txt = (f"{days}일 " if days else "") + f"{hours}시간 {mins}분"
load = os.getloadavg()
return up_txt, f"{load[0]:.2f} / {load[1]:.2f} / {load[2]:.2f}"
except Exception:
return "-", "-"
def disk_usage():
# 루트(/) 디스크의 사용량과 전체 용량을 돌려줍니다.
try:
u = shutil.disk_usage("/")
return u.used, u.total
except Exception:
return 0, 0
def human_size(n):
# 바이트 숫자를 KB/MB/GB 처럼 사람이 읽기 좋게 바꿉니다.
v = float(n)
for unit in ["B", "KB", "MB", "GB", "TB"]:
if v < 1024.0 or unit == "TB":
return f"{v:.2f} {unit}" if unit != "B" else f"{int(v)} B"
v /= 1024.0
return f"{n} B"
# ----------------------------------------------------------------------------
# HTML 조각
# ----------------------------------------------------------------------------
def h(s):
# 글자를 HTML 에 안전하게 넣기 위한 변환(< > & " 를 무해한 글자로). 화면에 값을 찍을 때는 거의 항상 이 함수를 거칩니다.
return html.escape(str(s), quote=True)
# 로그 줄에서 색을 칠할 단어 패턴 3종: 보안(ban/blocked/attack 등), 경고(warn), 오류(error/fail 등).
# (단어 경계 \b 를 써서 다른 단어 속 글자에는 반응하지 않게 했습니다.)
HL_PATTERNS = [
(re.compile(r"\b(ban(?:ned)?|block(?:ed)?|attack|intrusion|unauthorized|suspicious|deny|denied|refused)\b", re.I), "log-hl-security"),
(re.compile(r"\b(warn(?:ing)?)\b", re.I), "log-hl-warn"),
(re.compile(r"\b(error|err|fail(?:ed)?|critical|fatal|exception|panic|timed out|timeout)\b", re.I), "log-hl-error"),
]
def highlight_line(line):
# 로그 한 줄 안에서 위험/경고/오류 단어를 찾아 색을 입힙니다.
esc = h(line)
for pat, cls in HL_PATTERNS:
esc = pat.sub(lambda m, c=cls: f'<span class="{c}">{m.group(0)}</span>', esc)
return esc
def log_box(lines, big=True):
# 로그 줄들을 검은 상자(<pre>) 안에 색칠해서 보여주는 HTML 을 만듭니다.
cls = "log-box log-box-lg" if big else "log-box log-box-sm"
return f'<pre class="{cls}">' + "\n".join(highlight_line(l) for l in lines) + "</pre>"
# 아이콘들은 작은 SVG 그림을 문자열로 넣어둔 것입니다. 별도 이미지 파일이 필요 없습니다.
ICON_FILE = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>'
'<polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line>'
'<line x1="16" y1="17" x2="8" y2="17"></line></svg>')
ICON_SVC = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round"><rect x="2" y="3" width="20" height="8" rx="2"></rect><rect x="2" y="13" width="20" height="8" rx="2"></rect>'
'<line x1="6" y1="7" x2="6.01" y2="7"></line><line x1="6" y1="17" x2="6.01" y2="17"></line></svg>')
ICON_SHIELD = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>')
ICON_DISK = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"></ellipse><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"></path>'
'<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"></path></svg>')
ICON_CLOCK = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>')
ICON_FOLDER = ('<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" '
'stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path></svg>')
# 대시보드 바로가기: (이름, 이동 주소, 색, 아이콘) — 4개씩 한 줄. 바꾸고 싶으면 이 목록만 고치면 된다.
SHORTCUTS = [
("블로그 폴더 열기", "?tab=files&fpath=/home/myuser/blog.snsoz.com", "badge-blue", ICON_FOLDER),
("Hugo 빌드 로그", "?tab=svclogs&logkey=hugo_build", "badge-green", ICON_FILE),
("Nginx 에러 로그", "?tab=svclogs&logkey=nginx_error", "badge-amber", ICON_FILE),
("보안 점검", "?tab=seccheck", "badge-purple", ICON_FILE),
("Nginx 접속 로그", "?tab=svclogs&logkey=nginx_access", "badge-sky", ICON_FILE),
("블로그 전용 접속 로그", "?tab=svclogs&logkey=blog_access", "badge-green", ICON_FILE),
("fail2ban 로그", "?tab=svclogs&logkey=fail2ban_log", "badge-purple", ICON_SHIELD),
("인증 로그 (SSH 등)", "?tab=svclogs&logkey=auth_log", "badge-amber", ICON_FILE),
("차단 상태 · 해제", "?tab=svclogs", "badge-purple", ICON_SHIELD),
("SSL 인증서 로그", "?tab=svclogs&logkey=letsencrypt", "badge-green", ICON_FILE),
("Nginx 설정 폴더", "?tab=files&fpath=/etc/nginx", "badge-blue", ICON_FOLDER),
("스크립트 폴더", "?tab=files&fpath=/usr/local/bin", "badge-sky", ICON_FOLDER),
]
# 왼쪽 메뉴 구성: (그룹 이름, [(탭 키, 화면에 보일 이름), ...]).
TABS = [
("개요", [("dashboard", "대시보드")]),
("시스템", [("files", "파일 탐색기"), ("svclogs", "서버 로그"), ("seccheck", "보안 점검")]),
]
# 탭 키 → 이름 사전. 화면 제목과 "이 탭이 실제로 있는 탭인지" 확인에 씁니다.
TAB_LABEL = {"dashboard": "대시보드", "files": "파일 탐색기", "svclogs": "서버 로그", "seccheck": "보안 점검"}
# ----------------------------------------------------------------------------
# 화면 페이지 조립
# ----------------------------------------------------------------------------
# hugodash.css 뒤에 덧붙이는 휴고서버 전용 규칙.
# 바로가기 박스: 화면이 넓어져 가로가 커지면 세로도 같은 비율(3:1)로 커진다. 글자·아이콘도 같이 커진다.
# 기본 스타일시트 뒤에 덧붙이는 이 대시보드 전용 CSS (바로가기 박스 크기 규칙).
EXTRA_CSS = """
.shortcut-grid { gap: 14px; }
.shortcut-grid .quick-btn {
aspect-ratio: 3 / 1; justify-content: center; text-align: center;
padding: 8px; gap: clamp(7px, 0.6vw, 14px);
font-size: clamp(12px, 0.95vw, 18px);
}
.shortcut-grid .quick-btn .icon-badge {
width: clamp(34px, 2.6vw, 64px); height: clamp(34px, 2.6vw, 64px);
border-radius: clamp(10px, 0.8vw, 18px);
}
.shortcut-grid .quick-btn .icon-badge svg { width: 46%; height: 46%; }
"""
def load_css():
# 같은 폴더의 hugodash.css 를 읽고 이 프로그램 전용 규칙(EXTRA_CSS)을 덧붙입니다. 파일이 없으면 빈 스타일로 계속 동작합니다.
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hugodash.css")
try:
with open(p, "r", encoding="utf-8") as f:
return f.read() + EXTRA_CSS
except Exception:
return ""
def load_js():
# 같은 폴더의 hugodash.js(확인 창 등 화면 동작 코드)를 읽습니다. 없으면 빈 값.
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hugodash.js")
try:
with open(p, "r", encoding="utf-8") as f:
return f.read()
except Exception:
return ""
def build_notifications():
"""상단 종 아이콘에 보여줄 알림 목록."""
# 오른쪽 위 종(벨) 아이콘에 보여줄 알림을 만듭니다: 차단 중인 IP 와 멈춘 서비스.
notes = []
bans = fail2ban_bans()
ban_total = 0
if bans:
for jail, ips in bans:
if ips:
ban_total += len(ips)
notes.append(("fail2ban", f"{jail} — {len(ips)}개", ", ".join(ips[:5])))
down = [label for unit, label in SERVICE_UNITS if not is_active(unit)]
for label in down:
notes.insert(0, ("service", f"{label} 서비스 중단", "systemctl 로 상태를 확인하세요."))
return notes, ban_total, bans
def badge_text(n):
"""벨 아이콘의 숫자 표시. 99 를 넘으면 99+."""
# 종 아이콘 숫자에 쓰는 글자. 99 가 넘으면 "99+".
return "99+" if n > 99 else str(n)
def page_dashboard(bans, ban_total):
# "대시보드" 탭의 화면(HTML)을 만듭니다. 서비스 상태·차단 IP 수·디스크·가동 시간 카드와 바로가기 버튼으로 구성됩니다.
states = [(label, is_active(unit)) for unit, label in SERVICE_UNITS]
up_count = sum(1 for _, a in states if a)
up_txt, load_txt = read_uptime_load()
used, total = disk_usage()
pct = int(used * 100 / total) if total else 0
svc_rows = "".join(
f'<div class="log-mini-row"><span>{h(l)}</span>'
f'<span class="status-dot {"status-ok" if a else "status-bad"}"><span class="d {"on" if a else "off"}"></span>{"정상" if a else "중단"}</span></div>'
for l, a in states
)
if bans is None:
ban_body = '<div class="small">fail2ban 상태를 불러오지 못했습니다 (sudo 권한 확인 필요).</div>'
elif ban_total == 0:
ban_body = '<div class="small">지금 차단 중인 IP가 없습니다.</div>'
else:
rows = "".join(
f'<tr><td><b>{h(j)}</b></td><td style="text-align:right;">{len(ips)}</td>'
f'<td class="small mono">{h(", ".join(ips[:8]))}{" …" if len(ips) > 8 else ""}</td></tr>'
for j, ips in bans if ips
)
ban_body = (f'<table><thead><tr><th>Jail</th><th style="white-space:nowrap; text-align:right;">차단 수</th><th>IP (일부)</th></tr></thead>'
f'<tbody>{rows}</tbody></table>')
shortcut_html = "".join(
f'<a class="quick-btn" style="text-decoration:none;" href="{h(href)}">'
f'<span class="icon-badge {badge}">{icon}</span>{h(label)}</a>'
for label, href, badge, icon in SHORTCUTS
)
return f'''
<div class="grid stat-row">
<div class="stat-card">
<div class="icon-row"><span class="icon-badge badge-green">{ICON_SVC}</span>
<div><div class="label">서비스 상태</div><div class="value">{up_count}/{len(states)}</div></div></div>
<div class="foot">{"<b>전체 정상</b>" if up_count == len(states) else '<span class="bad">일부 중단됨</span>'}</div>
</div>
<div class="stat-card">
<div class="icon-row"><span class="icon-badge badge-purple">{ICON_SHIELD}</span>
<div><div class="label">차단 중 IP</div><div class="value">{ban_total}</div></div></div>
<div class="foot">fail2ban 모든 jail 합계</div>
</div>
<div class="stat-card">
<div class="icon-row"><span class="icon-badge badge-amber">{ICON_DISK}</span>
<div><div class="label">디스크 사용량 (/)</div><div class="value">{pct}%</div></div></div>
<div class="foot">{h(human_size(used))} / {h(human_size(total))}</div>
</div>
<div class="stat-card">
<div class="icon-row"><span class="icon-badge badge-blue">{ICON_CLOCK}</span>
<div><div class="label">가동 시간</div><div class="value" style="font-size:18px;">{h(up_txt)}</div></div></div>
<div class="foot">부하 {h(load_txt)}</div>
</div>
</div>
<div class="grid grid-2 section">
<div class="card"><h2>서비스 상태</h2><div class="desc">systemctl is-active 실시간 조회</div>{svc_rows}</div>
<div class="card"><h2>현재 차단(Ban) 요약</h2><div class="desc">자세한 목록과 차단 해제는 「서버 로그」 탭</div>{ban_body}</div>
</div>
<div class="card section"><h2>바로가기</h2>
<div class="quick-grid shortcut-grid">{shortcut_html}</div>
</div>'''
def page_svclogs(qs, csrf, bans, flash_msg):
# "서버 로그" 탭: 위쪽은 차단 IP 목록과 차단 해제 폼, 아래쪽은 로그 버튼 격자와 선택한 로그 내용입니다.
# 읽을 수 없는 로그(파일 없음/권한 없음)는 버튼을 흐리게 하고 이유를 함께 보여줍니다.
logkey = (qs.get("logkey", [""])[0] or "")
try:
n = int(qs.get("loglines", ["200"])[0])
except ValueError:
n = 200
if n not in LINE_CHOICES:
n = 200
# 차단 상태
total = sum(len(ips) for _, ips in bans) if bans else 0
jail_opts = "".join(f'<option value="{h(j)}">{h(j)}</option>' for j, _ in (bans or []))
if bans is None:
ban_html = '<div class="small">fail2ban 상태를 불러오지 못했습니다.</div>'
elif total == 0:
ban_html = '<div class="small">지금 차단 중인 IP가 없습니다.</div>'
else:
rows = "".join(
f'<tr><td><b>{h(j)}</b></td><td style="text-align:right;">{len(ips)}</td>'
f'<td class="small mono">{h(", ".join(ips))}</td></tr>'
for j, ips in bans if ips
)
ban_html = (f'<details><summary style="cursor:pointer; font-weight:700; font-size:13.5px;">'
f'현재 <span style="color:var(--bad);">{total}개 IP</span> 차단 중 — 클릭해서 목록 보기</summary>'
f'<table style="margin-top:10px;"><thead><tr><th>Jail</th><th style="white-space:nowrap; text-align:right;">차단 수</th>'
f'<th>IP 목록</th></tr></thead><tbody>{rows}</tbody></table></details>')
flash_html = f'<div class="alert success" style="margin-bottom:12px;">{h(flash_msg)}</div>' if flash_msg else ""
# 로그 버튼: 하나의 격자 (한 줄 7개는 CSS .svclog-grid 에서 정함)
btns = '<div class="quick-grid svclog-grid">'
for key, label, typ, val, group in LOG_SOURCES:
state = "ok"
if typ == "file":
state, fsize = probe_file(val)
exists = (state == "ok")
size_txt = human_size(fsize) if exists else FILE_STATE_LABEL[state]
else:
exists = True
size_txt = "journalctl"
sel = "outline:2px solid var(--accent);" if key == logkey else ""
if exists:
btns += (f'<a class="quick-btn" style="text-decoration:none; {sel}" '
f'href="?tab=svclogs&logkey={h(key)}&loglines={n}">'
f'<span class="icon-badge badge-blue">{ICON_FILE}</span>{h(label)}'
f'<span class="small">{h(size_txt)}</span></a>')
else:
btns += (f'<div class="quick-btn" style="opacity:.4; cursor:not-allowed;" title="{h(FILE_STATE_HINT[state])}">'
f'<span class="icon-badge badge-blue">{ICON_FILE}</span>{h(label)}'
f'<span class="small">{h(FILE_STATE_LABEL[state])}</span></div>')
btns += "</div>"
# 선택된 로그 내용
body = '<div class="small section">위에서 로그를 선택하세요.</div>'
if logkey:
src = next((s for s in LOG_SOURCES if s[0] == logkey), None)
if src is None:
body = '<div class="alert error">알 수 없는 로그입니다.</div>'
else:
_, label, typ, val, _g = src
if typ == "file":
lines, err = tail_file(val, n)
where = val
else:
lines, err = journal_tail(val, n)
where = "journalctl -u " + val
if lines is None:
content = f'<pre class="log-box">{h(err)}</pre>'
meta = h(where)
else:
content = log_box(lines) if lines else '<pre class="log-box">(내용 없음)</pre>'
meta = h(where)
sel_opts = "".join(f'<option value="{c}" {"selected" if c == n else ""}>{c}줄</option>' for c in LINE_CHOICES)
body = f'''<div class="section">
<div class="small" style="margin-bottom:10px;">
<span class="mono">{meta}</span> ·
<form method="get" style="display:inline-flex; align-items:center; gap:6px;">
<input type="hidden" name="tab" value="svclogs"><input type="hidden" name="logkey" value="{h(logkey)}">
<label class="small" style="margin:0;">줄 수:</label>
<select name="loglines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:4px 8px;">{sel_opts}</select>
</form> ·
<a href="?tab=svclogs&logkey={h(logkey)}&loglines={n}">새로고침</a>
</div>{content}</div>'''
return f'''
<div class="card">
<h2>현재 차단(Ban) 상태</h2>
<div class="desc">fail2ban-client 실시간 조회 — 지금 이 순간 차단 중인 IP</div>
{flash_html}
<form method="post" action="/unban" autocomplete="off" style="margin-bottom:16px;" onsubmit="return appConfirm(this, '이 IP의 차단을 해제하시겠습니까?');">
<input type="hidden" name="csrf" value="{h(csrf)}">
<div class="grid grid-3">
<div><label for="unban_ip">차단 해제할 IP</label><input type="text" id="unban_ip" name="unban_ip" placeholder="1.2.3.4"></div>
<div><label for="unban_jail">Jail (비우면 전체에서 시도)</label>
<select id="unban_jail" name="unban_jail"><option value="">전체</option>{jail_opts}</select></div>
<div style="display:flex; align-items:flex-end;"><button type="submit" class="btn-blue" style="margin-bottom:12px;">차단 해제</button></div>
</div>
</form>
{ban_html}
</div>
<div class="card section">
<h2>서버 로그</h2>
<div class="desc">휴고서버가 실제로 기록 중인 로그를 원클릭으로 조회 (읽기 전용, 파일당 최근 500줄까지)</div>
{btns}
{body}
</div>'''
def page_seccheck(qs):
# "보안 점검" 탭: security_checks() 결과를 항목별 상자로 보여줍니다.
try:
n = int(qs.get("seclines", ["50"])[0])
except ValueError:
n = 50
if n not in LINE_CHOICES:
n = 50
items = security_checks(n)
sel_opts = "".join(f'<option value="{c}" {"selected" if c == n else ""}>{c}줄</option>' for c in LINE_CHOICES)
blocks = "".join(
f'<div class="section" style="border-top:1px solid var(--line); padding-top:14px;">'
f'<div style="font-weight:800; font-size:14px; margin-bottom:2px;">{h(it["label"])}</div>'
f'<div class="small" style="margin-bottom:8px;">⚑ {h(it["hint"])}</div>'
f'{log_box(it["lines"])}</div>'
for it in items
)
return f'''
<div class="card">
<h2>보안 점검</h2>
<div class="desc">탭에 들어올 때마다 실제 서버에서 진단 명령을 실행해 결과를 보여줍니다 (읽기 전용).
각 항목의 힌트를 참고해서 낯선/이상한 게 있는지 눈으로 확인하세요.</div>
<div class="inline-actions" style="margin-bottom:6px; align-items:center;">
<form method="get" style="display:inline-flex; align-items:center; gap:6px;">
<input type="hidden" name="tab" value="seccheck">
<label class="small" style="margin:0;">항목별 줄 수:</label>
<select name="seclines" onchange="this.form.requestSubmit();" style="width:auto; margin:0; padding:6px 10px;">{sel_opts}</select>
<button type="submit" class="btn-blue">다시 실행</button>
</form>
</div>
{blocks}
</div>'''
# 압축파일로 보고 빨간색으로 칠할 확장자들.
ARCHIVE_EXTS = (".zip", ".tar", ".gz", ".tgz", ".bz2", ".tbz2", ".xz", ".txz", ".zst", ".7z", ".rar", ".z", ".lz4")
def name_style(name):
"""파일 이름 색: 압축파일은 빨간색, .bak 백업 파일은 밝은 연두색. 그 밖에는 기본색."""
# 파일 이름 색: 압축파일은 빨간색, .bak 백업 파일은 밝은 연두색. 그 밖에는 기본색.
low = name.lower()
if low.endswith(ARCHIVE_EXTS):
return ' style="color:#ef4444;"'
if ".bak" in low:
return ' style="color:#a3e635;"'
return ""
def perm_badge(kind):
# 파일 탐색기의 "접근" 표시: 읽기·쓰기 / 읽기 전용 / 🔒 접근 불가.
if kind == "write":
return '<span class="small" style="color:var(--ok); font-weight:700;">읽기·쓰기</span>'
if kind == "read":
return '<span class="small" style="color:var(--muted); font-weight:700;">읽기 전용</span>'
return '<span class="small">🔒 접근 불가</span>'
def page_files(qs, csrf, flash_msg):
# "파일 탐색기" 탭. 주소의 값에 따라 화면이 세 가지로 나뉩니다.
# fperm=경로 → 권한 변경 화면 / ffile=경로 → 파일 보기·편집 / 그 외 → 폴더 목록
fpath = qs.get("fpath", [""])[0]
ffile = qs.get("ffile", [""])[0]
fperm = qs.get("fperm", [""])[0] # 주소의 fperm 값이 있으면 권한 변경 화면
quick = "".join( # 위쪽 바로가기 버튼 HTML
f'<a class="btn-gray" href="?tab=files&fpath={h(q["path"])}">{h(q["label"])}</a>' for q in QUICKLINKS
)
flash_html = f'<div class="alert success" style="margin-bottom:12px;">{h(flash_msg)}</div>' if flash_msg else ""
# 이 탭 공통 머리말(설명 문구 + 바로가기)
head = f'''<h2>파일 탐색기</h2>
<div class="desc">
서버 전체 폴더 구조가 그대로 보입니다. 시스템 핵심 영역(계정정보, SSH, 크론/systemd, fail2ban, 인증서 개인키 등, 🔒 표시)은
목록에는 나오되 열람·수정이 차단됩니다. <b>수정은 블로그 폴더만 가능</b>하고 나머지는 읽기 전용입니다.
저장 시 원본은 자동 백업(.bak-타임스탬프)됩니다. 새 파일 생성/삭제는 불가 — 기존 파일 수정만 가능.<br>
<b>소유·권한</b>은 목록에 표시되고, 블로그 폴더 안의 것은 <b>눌러서 권한을 바꿀 수 있습니다</b>(파일·폴더 하나씩, 폴더 안까지 한꺼번에 바꾸지는 않음. 주인·그룹은 바꾸지 않음).
</div>{flash_html}
<div class="inline-actions" style="margin-bottom:14px;">{quick}</div>'''
# ---------- 소유·권한 변경 화면 ----------
if fperm: # 권한 변경 화면이면 page_perm 에 맡기고 끝
return page_perm(head, fperm, csrf)
# ---------- 파일 보기/편집 ----------
if ffile: # 파일 보기·편집 화면
try:
real = resolve(ffile)
except ValueError:
return f'<div class="card">{head}<div class="alert error">잘못된 경로입니다.</div></div>'
kind = classify(real) # 이 경로가 막힘/쓰기/읽기 중 무엇인지 서버 쪽에서 판정
parent = os.path.dirname(real) or "/"
back = f'<a href="?tab=files&fpath={h(parent)}">‹ 목록으로</a> · <span class="mono">{h(real)}</span> · {perm_badge(kind)}'
if kind == "deny":
return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">이 파일은 시스템 영역이라 열 수 없습니다.</div></div>'
fstate, _fst = stat_state(real)
if fstate != "file":
if fstate == "denied":
msg = "권한이 없어 열 수 없습니다. 이 대시보드는 myuser 계정으로 실행되는데, 이 파일이나 그 위 폴더가 myuser 에게 열려 있지 않습니다."
elif fstate == "error":
msg = "파일 상태를 확인하지 못했습니다."
else:
msg = "파일을 찾을 수 없습니다."
return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">{h(msg)}</div></div>'
size = os.path.getsize(real)
if size > MAX_VIEW_BYTES:
return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">파일이 너무 커서({h(human_size(size))}) 여기서 볼 수 없습니다.</div></div>'
try:
with open(real, "r", encoding="utf-8", errors="replace") as f:
content = f.read()
except PermissionError:
return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">권한이 없어 읽을 수 없습니다.</div></div>'
# 저장 버튼을 보여줄 조건: 블로그 폴더 안 + 3MB 이하 + 실제 쓰기 권한 있음
can_edit = (kind == "write") and size <= MAX_EDIT_BYTES and os.access(real, os.W_OK)
notes = ""
if kind == "read":
notes = '<div class="alert error" style="margin-bottom:10px;">읽기 전용 영역입니다 (수정/저장 불가).</div>'
elif size > MAX_EDIT_BYTES:
notes = '<div class="alert error" style="margin-bottom:10px;">파일이 3MB보다 커서 미리보기만 가능합니다 (수정/저장 불가).</div>'
elif not os.access(real, os.W_OK):
notes = '<div class="alert error" style="margin-bottom:10px;">이 파일은 현재 계정에 쓰기 권한이 없어 수정할 수 없습니다.</div>'
ro = "" if can_edit else "readonly"
save_btn = '<button type="submit" class="btn-blue">저장 (자동 백업 후 덮어쓰기)</button>' if can_edit else ""
return f'''<div class="card">{head}
<div class="small" style="margin-bottom:10px;">{back}</div>{notes}
<form method="post" action="/file/write" autocomplete="off" id="fmEditForm" onsubmit="return appConfirm(this, '이 파일을 저장하시겠습니까? 저장 전 원본은 자동으로 백업됩니다.');">
<input type="hidden" name="csrf" value="{h(csrf)}"><input type="hidden" name="path" value="{h(real)}">
<textarea name="content" id="fmEditor" spellcheck="false" {ro} style="width:100%; min-height:480px; font-family:Consolas,Monaco,monospace; font-size:13px; padding:14px; border:1px solid #cfd7e3; border-radius:10px;">{h(content)}</textarea>
<div class="inline-actions" style="margin-top:10px;">{save_btn}<span class="small">크기: {size} bytes</span></div>
</form></div>'''
# ---------- 폴더 목록 ----------
try:
real = resolve(fpath or "/")
except ValueError:
return f'<div class="card">{head}<div class="alert error">잘못된 경로입니다.</div></div>'
kind = classify(real)
if kind == "deny":
return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;"><span class="mono">{h(real)}</span></div><div class="alert error">이 폴더는 시스템 영역이라 열 수 없습니다.</div></div>'
dstate, _dst = stat_state(real)
if dstate != "dir":
if dstate == "denied":
dmsg = "이 폴더에 들어갈 권한이 없습니다. 이 대시보드는 myuser 계정으로 실행되는데, 이 폴더나 그 위 폴더가 myuser 에게 열려 있지 않습니다."
elif dstate == "error":
dmsg = "폴더 상태를 확인하지 못했습니다."
else:
dmsg = "폴더를 찾을 수 없습니다."
return f'<div class="card">{head}<div class="alert error">{h(dmsg)}</div></div>'
entries = [] # 폴더 목록 화면: 폴더 안 항목들을 모은다
try:
with os.scandir(real) as it:
for e in it:
try:
full = os.path.join(real, e.name)
st = e.stat(follow_symlinks=False)
is_dir = e.is_dir(follow_symlinks=False)
# 심볼릭 링크는 실제 목적지 기준으로 판정한다 (링크로 벽을 넘지 못하게)
if e.is_symlink():
tgt_kind = classify(full)
is_dir_link = os.path.isdir(full)
is_dir = is_dir_link
else:
tgt_kind = classify(full)
entries.append({
"name": e.name, "is_dir": is_dir, "kind": tgt_kind,
"size": None if is_dir else st.st_size,
"mtime": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
"full": full,
"is_link": e.is_symlink(),
"owner": uid_name(st.st_uid), "group": gid_name(st.st_gid),
"oct": mode_octal(st.st_mode), "rwx": mode_rwx(st.st_mode),
})
except OSError:
continue
except PermissionError:
return f'<div class="card">{head}<div class="alert error">이 폴더를 읽을 권한이 없습니다.</div></div>'
# 폴더 먼저, 그다음 이름순
entries.sort(key=lambda x: (not x["is_dir"], x["name"].lower()))
up = os.path.dirname(real) if real != "/" else ""
up_html = f' · <a href="?tab=files&fpath={h(up or "/")}">‹ 상위 폴더</a>' if real != "/" else ""
rows = ""
for e in entries:
ok = e["kind"] != "deny"
dim = "" if ok else ' style="opacity:.55;"'
if not ok:
name_html = f'🔒 <span title="시스템 영역 — 접근 차단됨">{h(e["name"])}</span>'
size_html, time_html, perm_html = "—", "—", perm_badge("deny")
owner_html, mode_html = "—", "—"
else:
if e["is_dir"]:
name_html = f'📁 <a href="?tab=files&fpath={h(e["full"])}">{h(e["name"])}</a>'
size_html, time_html = "", h(e["mtime"])
else:
name_html = f'📄 <a href="?tab=files&ffile={h(e["full"])}"{name_style(e["name"])}>{h(e["name"])}</a>'
size_html, time_html = h(human_size(e["size"])), h(e["mtime"])
perm_html = perm_badge(e["kind"])
owner_txt = f'{h(e["owner"])}:{h(e["group"])}'
mode_txt = f'{h(e["rwx"])} <span class="small">({h(e["oct"])})</span>'
if e["is_link"]:
owner_html = f'<span class="mono small" style="color:var(--muted);">{owner_txt}</span>'
mode_html = '<span class="small" style="color:var(--muted);" title="링크(바로가기)는 바꾸지 않습니다">링크</span>'
elif e["kind"] == "write":
href = "?tab=files&fperm=" + quote(e["full"], safe="/")
lk = 'style="text-decoration:none; color:inherit; border-bottom:1px dashed var(--muted);" title="눌러서 소유·권한 변경"'
owner_html = f'<a class="mono small" href="{href}" {lk}>{owner_txt}</a>'
mode_html = f'<a class="mono small" href="{href}" {lk}>{mode_txt}</a>'
else:
owner_html = f'<span class="mono small" style="color:var(--muted);">{owner_txt}</span>'
mode_html = f'<span class="mono small" style="color:var(--muted);">{mode_txt}</span>'
rows += (f'<tr{dim}><td>{name_html}</td><td>{size_html}</td><td class="small">{time_html}</td>'
f'<td>{owner_html}</td><td>{mode_html}</td><td>{perm_html}</td></tr>')
table = ('<div class="small">빈 폴더입니다.</div>' if not entries else
f'<table><thead><tr><th>이름</th><th>크기</th><th>수정시각</th><th>소유(주인:그룹)</th><th>권한</th><th>변경</th></tr></thead><tbody>{rows}</tbody></table>')
return f'''<div class="card">{head}
<div class="small" style="margin-bottom:10px;"><span class="mono">{h(real)}</span> · {perm_badge(kind)}{up_html}</div>{table}</div>'''
def page_perm(head, raw, csrf):
"""소유·권한 변경 화면 (블로그 폴더 안의 파일·폴더 하나)."""
# 권한 변경 화면: 읽기·쓰기·실행 체크박스를 보여주고 [적용]을 누르면 /file/perm 으로 보냅니다.
# 바꿀 수 없는 대상이면 이유만 보여주고 폼은 그리지 않습니다.
try:
real = resolve(raw)
except ValueError:
return f'<div class="card">{head}<div class="alert error">잘못된 경로입니다.</div></div>'
parent = os.path.dirname(real) or "/"
back = (f'<a href="?tab=files&fpath={quote(parent, safe="/")}">‹ 목록으로</a> · '
f'<span class="mono">{h(real)}</span>')
def err(msg):
return f'<div class="card">{head}<div class="small" style="margin-bottom:10px;">{back}</div><div class="alert error">{h(msg)}</div></div>'
if classify(real) == "deny":
return err("이 경로는 시스템 영역이라 열 수 없습니다.")
try:
st = os.lstat(real)
except FileNotFoundError:
return err("파일을 찾을 수 없습니다.")
except PermissionError:
return err("권한이 없어 확인할 수 없습니다. 이 파일이나 그 위 폴더가 myuser 에게 열려 있지 않습니다.")
except OSError:
return err("파일 상태를 확인하지 못했습니다.")
reason = perm_block_reason(real, st) # 바꿀 수 없는 대상이면 이유만 보여주고 끝
if reason:
return err(reason)
is_dir = stat.S_ISDIR(st.st_mode)
cur_owner, cur_group = uid_name(st.st_uid), gid_name(st.st_gid)
m = st.st_mode & 0o777
mine = (st.st_uid == os.geteuid()) # 이 프로그램을 실행 중인 계정이 주인인가?
notes = ""
if not mine:
notes = (f'<div class="alert error" style="margin-bottom:10px;">이 {"폴더" if is_dir else "파일"}의 주인은 {h(cur_owner)} 입니다. '
'주인이 myuser 인 것만 바꿀 수 있습니다.</div>')
def cb(name, on, disabled=False): # 체크박스 하나를 그리는 작은 함수
return (f'<input type="checkbox" name="{name}" value="1"{" checked" if on else ""}{" disabled" if disabled else ""}'
' style="width:18px; height:18px;">')
def row(label, who, shift, other=False): # '주인/같은 그룹/나머지' 한 줄(읽기·쓰기·실행 3칸)을 그리는 함수
r, w, x = bool(m & (4 << shift)), bool(m & (2 << shift)), bool(m & (1 << shift))
return (f'<tr><td>{label}</td><td style="text-align:center;">{cb("pm_" + who + "r", r)}</td>'
f'<td style="text-align:center;">{cb("pm_" + who + "w", w, disabled=other)}</td>'
f'<td style="text-align:center;">{cb("pm_" + who + "x", x)}</td></tr>')
# 권한 표 전체
grid = (f'<table style="width:auto; margin-bottom:12px;"><thead><tr><th></th><th>읽기</th><th>쓰기</th>'
f'<th>{"들어가기" if is_dir else "실행"}</th></tr></thead><tbody>'
+ row(f'주인 ({h(cur_owner)})', "u", 6)
+ row(f'같은 그룹 ({h(cur_group)})', "g", 3)
+ row('나머지 모두 (쓰기는 줄 수 없음)', "o", 0, other=True)
+ '</tbody></table>')
save_btn = '<button type="submit" class="btn-blue">적용</button>' if mine else ""
form = f'''<form method="post" action="/file/perm" autocomplete="off" onsubmit="return appConfirm(this, '소유·권한을 바꾸시겠습니까?');">
<input type="hidden" name="csrf" value="{h(csrf)}"><input type="hidden" name="path" value="{h(real)}">
{grid}
<div class="inline-actions">{save_btn}
<button type="button" class="btn-gray" onclick="location.href='?tab=files&fpath={quote(parent, safe="/")}'">취소</button></div>
</form>'''
kind_word = "폴더" if is_dir else "파일"
return f'''<div class="card">{head}
<div class="small" style="margin-bottom:10px;">{back}</div>
<h3 style="margin:6px 0 4px;">소유·권한 변경 ({kind_word})</h3>
<div class="small" style="margin-bottom:12px;">지금: <span class="mono">{h(mode_rwx(st.st_mode))} ({h(mode_octal(st.st_mode))})</span>
· 소유 <span class="mono">{h(cur_owner)}:{h(cur_group)}</span>
{"<br>이 폴더 하나만 바뀌고, 안에 있는 파일·폴더는 그대로입니다." if is_dir else ""}</div>{notes}
{form}</div>'''
def render_page(qs, csrf, flash_msg=""):
# 화면 전체를 조립하는 함수: 선택된 탭의 본문을 만들고, 왼쪽 메뉴·위쪽 바·알림 벨·확인 창을 감싸서 완성된 HTML 한 장을 돌려줍니다.
tab = qs.get("tab", ["dashboard"])[0] # 주소의 ?tab= 값으로 어떤 탭인지 정한다
if tab not in TAB_LABEL:
tab = "dashboard"
notes, ban_total, bans = build_notifications() # 종 아이콘용 알림을 먼저 계산
if tab == "dashboard": # 탭에 맞는 화면 조각(body)을 만든다
body = page_dashboard(bans, ban_total)
elif tab == "files":
body = page_files(qs, csrf, flash_msg)
elif tab == "svclogs":
body = page_svclogs(qs, csrf, bans, flash_msg)
else:
body = page_seccheck(qs)
nav = "" # 왼쪽 메뉴 HTML
for group, items in TABS:
nav += f'<div class="nav-group"><div class="nav-label">{h(group)}</div>'
for key, label in items:
act = "active" if key == tab else ""
nav += f'<a class="nav-item {act}" href="?tab={key}" style="text-decoration:none;"><span class="dot"></span>{h(label)}</a>'
nav += "</div>"
# 벨 숫자 = 차단된 IP 전체 + 멈춘 서비스 수
notif_total = ban_total + sum(1 for n in notes if n[0] == "service")
if notes:
note_rows = "".join(
f'<div style="padding:6px 10px; border-bottom:1px solid var(--line); font-size:12.5px;">'
f'<div style="font-weight:700; color:{"#b42318" if k == "service" else "#b45309"};">{h(t)}</div>'
f'<div class="small" style="margin-top:2px;">{h(d)}</div></div>'
for k, t, d in notes
)
badge = (f'<span style="position:absolute; top:-4px; right:-4px; background:#dc2626; color:#fff; font-size:10px; '
f'font-weight:800; min-width:18px; height:16px; border-radius:8px; display:flex; align-items:center; '
f'justify-content:center; padding:0 3px;">{badge_text(notif_total)}</span>')
else:
note_rows = '<div class="small" style="padding:8px 10px;">알림 없음 — 이상 없음</div>'
badge = ""
now = datetime.now().strftime("%Y-%m-%d %H:%M") # 위쪽 '터널 연결됨' 옆에 보여줄 현재 시각
return f'''<!DOCTYPE html>
<html lang="ko"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hugo Admin — {h(TAB_LABEL[tab])}</title>
<style>{load_css()}</style></head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><span class="logo">H</span>
<span>Hugo Admin<div style="font-size:10px; font-weight:400; color:var(--muted);">v1.0.0</div></span></div>
<nav>{nav}</nav>
</aside>
<main class="main">
<div class="topbar">
<div class="topbar-left">
<div><div class="page-title">{h(TAB_LABEL[tab])}</div>
<div class="sub">Access: <span class="mono">SSH 터널 전용</span></div></div>
<div class="conn-pill"><span class="dot2"></span>터널 연결됨 · <span class="mono">127.0.0.1:{BIND_PORT}</span> · {now}</div>
</div>
<div class="topbar-right">
<div class="user-menu">
<button type="button" class="icon-btn" id="notifBtn" title="알림" style="position:relative; cursor:pointer;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>{badge}
</button>
<div class="user-dropdown" id="notifDropdown" style="min-width:340px; max-height:420px; overflow:auto;">
{note_rows}
<div style="padding:8px 10px; border-top:1px solid var(--line); margin-top:4px;">
<a href="?tab=svclogs" class="small">차단 상태·로그 전체 보기 ›</a></div>
</div>
</div>
<button type="button" class="icon-btn" id="themeBtn" title="다크모드 전환" style="cursor:pointer;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:17px; height:17px;"><circle cx="12" cy="12" r="5"></circle><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"></path></svg>
</button>
<div class="user-menu"><span class="user-pill"><span class="avatar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="width:13px; height:13px;"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg></span>myuser</span></div>
</div>
</div>
{body}
</main>
</div>
<div id="appConfirmOverlay" style="display:none; position:fixed; inset:0; background:rgba(15,20,30,0.55); z-index:9999; align-items:center; justify-content:center; padding:20px;">
<div style="background:var(--panel); color:var(--ink); border-radius:14px; padding:24px; max-width:400px; width:100%; box-shadow:0 24px 64px rgba(0,0,0,0.35); border:1px solid var(--line);">
<div id="appConfirmMsg" style="margin-bottom:22px; font-size:14.5px; line-height:1.55; text-align:center;"></div>
<div style="display:flex; gap:8px; justify-content:center;">
<button type="button" class="btn-gray" onclick="appConfirmCancel()">취소</button>
<button type="button" class="btn-red" onclick="appConfirmOk()">확인</button>
</div>
</div>
</div>
<script>{load_js()}</script>
</body></html>'''
# ----------------------------------------------------------------------------
# HTTP 서버
# ----------------------------------------------------------------------------
# 페이지 이동 때마다 바뀌지 않는 CSRF 토큰(프로세스가 살아있는 동안 유지).
# 터널 전용이라 외부에서 이 서버에 요청을 보낼 수 없지만, 브라우저가 다른 탭의
# 악성 페이지 요청을 대신 보내는 것(CSRF)은 막아야 하므로 토큰을 둔다.
# 서버가 켜질 때 한 번 만드는 무작위 비밀 문자열. 모든 POST 요청에 이 값이 같이 와야만 처리합니다.
# (다른 사이트가 내 브라우저를 시켜 몰래 요청을 보내는 CSRF 공격 방지)
CSRF_TOKEN = os.urandom(24).hex()
class Handler(BaseHTTPRequestHandler):
server_version = "HugoDash/1.0"
def log_message(self, fmt, *args):
pass # 접속 기록은 남기지 않는다 (터널 전용, 본인만 사용)
def _send(self, code, body, ctype="text/html; charset=utf-8", headers=None):
# 응답을 보내는 공통 함수. 보안 헤더(캐시 금지, iframe 금지 등)를 항상 같이 붙입니다.
data = body.encode("utf-8") if isinstance(body, str) else body
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(data)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Frame-Options", "DENY")
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("Referrer-Policy", "no-referrer")
for k, v in (headers or {}).items():
self.send_header(k, v)
self.end_headers()
self.wfile.write(data)
def _host_ok(self):
"""DNS 리바인딩 방어: Host 헤더가 127.0.0.1/localhost 일 때만 응답한다."""
# 요청의 Host 가 127.0.0.1 또는 localhost 인지 확인합니다. 다른 이름으로 들어오는 요청(DNS 리바인딩 공격)은 거부합니다.
host = (self.headers.get("Host") or "").split(":")[0].strip().lower()
return host in ("127.0.0.1", "localhost")
def _redirect(self, location):
# 처리 뒤 다른 주소로 보내는 응답(303). 새로고침해도 같은 작업이 두 번 실행되지 않게 합니다.
self._send(303, "", headers={"Location": location})
def do_GET(self):
# GET 요청(화면 보기) 처리. 주소는 "/" 하나뿐이고, 어떤 화면인지는 ?tab=... 같은 값으로 정합니다.
if not self._host_ok(): # Host 가 127.0.0.1/localhost 가 아니면 거부 (DNS 리바인딩 방어)
return self._send(403, "forbidden", "text/plain; charset=utf-8")
parsed = urlparse(self.path) # 주소를 경로와 ?뒤 값으로 분해
if parsed.path != "/": # 화면은 "/" 하나뿐. 다른 주소는 404
return self._send(404, "not found", "text/plain; charset=utf-8")
qs = parse_qs(parsed.query, keep_blank_values=True) # ?tab=files&fpath=... 같은 값을 사전으로
flash = qs.get("msg", [""])[0] # 직전 작업 결과 메시지(✅/⚠️)가 있으면 꺼내 둔다
try: # 화면 만들기
page = render_page(qs, CSRF_TOKEN, flash) # HTML 한 장을 만든다
# 만들다 오류가 나면
except Exception as e: # noqa
page = f"<pre>내부 오류: {h(e)}</pre>" # 오류 내용을 보여주고
return self._send(500, page) # 500 으로 응답
self._send(200, page) # 정상이면 200 과 함께 화면을 보낸다
def do_POST(self):
# POST 요청(저장·차단 해제·권한 변경 같은 "바꾸는 동작") 처리.
# 먼저 Host 와 CSRF 토큰을 검사하고, 주소에 따라 /unban, /file/write, /file/perm 중 하나를 실행합니다.
if not self._host_ok(): # Host 검사 (GET 과 동일)
return self._send(403, "forbidden", "text/plain; charset=utf-8")
try: # 보낸 내용의 길이를 읽는다
length = int(self.headers.get("Content-Length") or 0) # 요청 본문 크기
except ValueError: # 숫자가 아니면 0 으로 취급
length = 0
if length > MAX_EDIT_BYTES * 2: # 너무 큰 요청은 읽지 않고 거부 (메모리 보호)
return self._send(413, "too large", "text/plain; charset=utf-8")
# 본문을 읽고
raw = self.rfile.read(length).decode("utf-8", errors="replace")
form = parse_qs(raw, keep_blank_values=True) # 이름=값 형태로 분해
def f(name): # 폼 값을 꺼내는 짧은 도우미 (없으면 빈 문자열)
return (form.get(name, [""])[0])
if f("csrf") != CSRF_TOKEN: # 비밀 토큰이 다르면 거부 (CSRF 방어)
return self._send(403, "CSRF 검증 실패", "text/plain; charset=utf-8")
path = urlparse(self.path).path # 어떤 동작인지는 주소로 구분
if path == "/unban": # ① 차단 해제
# IP 와 jail 을 넘겨 해제하고
ok, msg = fail2ban_unban(f("unban_ip").strip(), f("unban_jail").strip())
# 결과 메시지를 붙여 화면으로 되돌린다
return self._redirect("/?" + urlencode({"tab": "svclogs", "msg": ("✅ " if ok else "⚠️ ") + msg}))
if path == "/file/write": # ② 파일 저장
target = f("path") # 저장할 파일 경로
content = f("content") # 새 내용
try:
real = resolve(target) # 진짜 경로로 정리
except ValueError:
return self._redirect("/?" + urlencode({"tab": "files", "msg": "⚠️ 잘못된 경로입니다."}))
back = os.path.dirname(real) or "/" # 끝나면 돌아갈 폴더
def fail(msg): # 실패 처리 도우미: 기록 + 메시지와 함께 되돌아가기
audit("file_write", real, "fail", msg)
return self._redirect("/?" + urlencode({"tab": "files", "fpath": back, "msg": "⚠️ " + msg}))
if classify(real) != "write": # 블로그 폴더 안이 아니면 저장 거부 (서버 쪽에서 다시 검사)
return fail("이 파일은 수정할 수 없는 영역입니다.")
wstate, _wst = stat_state(real) # 파일 상태 확인
if wstate == "denied": # 폴더 권한 문제
return fail("권한이 없어 저장할 수 없습니다 (이 파일이나 그 위 폴더가 myuser 계정에게 열려 있지 않음).")
if wstate != "file": # 없는 파일이면 거부 (새 파일 만들기는 없음)
return fail("파일이 없습니다 (새 파일 생성은 불가).")
if os.path.getsize(real) > MAX_EDIT_BYTES: # 원본이 3MB 초과면 거부
return fail("파일이 너무 커서 수정할 수 없습니다.")
if len(content.encode("utf-8")) > MAX_EDIT_BYTES: # 새 내용이 3MB 초과여도 거부
return fail("내용이 너무 큽니다.")
try:
ts = datetime.now().strftime("%Y%m%d-%H%M%S") # 백업 이름에 넣을 시각
bak = f"{real}.bak-{ts}" # 원본 옆에 .bak-시각 이름으로 백업
shutil.copy2(real, bak) # 저장하기 전에 원본을 먼저 복사해 둔다
content = content.replace("\r\n", "\n") # 윈도우식 줄바꿈을 리눅스식으로 통일
with open(real, "w", encoding="utf-8") as fh: # 실제 저장
fh.write(content)
except PermissionError: # 쓰기 권한이 없으면
return fail("쓰기 권한이 없습니다.")
# 그 밖의 실패
except Exception as e: # noqa
return fail(f"저장 실패: {e}")
audit("file_write", real, "ok", "backup=" + bak) # 성공 기록 (어떤 백업을 만들었는지 포함)
return self._redirect("/?" + urlencode({"tab": "files", "fpath": back, "msg": f"✅ 저장 완료 (백업: {os.path.basename(bak)})"}))
if path == "/file/perm": # ③ 권한 변경
try:
real = resolve(f("path")) # 대상 경로를 진짜 경로로
except ValueError:
return self._redirect("/?" + urlencode({"tab": "files", "msg": "⚠️ 잘못된 경로입니다."}))
# 링크(바로가기) 경로를 주면 링크가 가리키는 곳이 바뀌어 버리므로 링크는 받지 않는다.
# 링크 경로가 오면 거부 (링크가 가리키는 다른 곳이 바뀌는 것 방지)
if os.path.islink(os.path.normpath("/" + f("path").lstrip("/"))):
audit("file_perm", real, "fail", "link_refused")
return self._redirect("/?" + urlencode({"tab": "files", "fperm": real, "msg": "⚠️ 링크(바로가기)는 바꾸지 않습니다."}))
digits = [] # 체크박스 → 권한 숫자 만들기
for who in ("u", "g", "o"): # 주인(u) / 그룹(g) / 나머지(o) 차례로
# 읽기=4 + 쓰기=2 + 실행=1 을 더해 한 자리로 (예: 읽기+쓰기 = 6)
digits.append(str((4 if f(f"pm_{who}r") else 0) + (2 if f(f"pm_{who}w") else 0) + (1 if f(f"pm_{who}x") else 0)))
ok, msg = perm_apply(real, "".join(digits)) # 세 자리를 합쳐 (예: "640") 변경 실행
return self._redirect("/?" + urlencode({"tab": "files", "fperm": real, "msg": ("✅ " if ok else "⚠️ ") + msg}))
# 어느 주소에도 안 맞으면 404
return self._send(404, "not found", "text/plain; charset=utf-8")
def main():
# 프로그램 시작 지점: 바인딩 주소가 127.0.0.1 인지 다시 확인하고 서버를 켭니다.
if BIND_HOST != "127.0.0.1": # 누가 실수로 주소를 바꿔 외부에 공개하는 것을 마지막으로 막는 안전장치
print("BIND_HOST 는 127.0.0.1 이어야 합니다.", file=sys.stderr)
sys.exit(1)
srv = ThreadingHTTPServer((BIND_HOST, BIND_PORT), Handler) # 요청마다 별도 스레드로 처리하는 서버 (동시에 여러 요청이 와도 밀리지 않음)
print(f"hugodash listening on {BIND_HOST}:{BIND_PORT}", flush=True)
try:
srv.serve_forever() # 멈출 때까지 계속 요청을 기다린다
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()
간단히 구조만 짚어보면:
do_GET/do_POST: 요청이 들어오면 화면을 보여줄지(GET), 저장·차단 해제·권한 변경 같은 “바꾸는 동작"을 할지(POST) 정해서 알맞은 함수로 넘겨주는 교통정리 역할을 합니다.page_*함수들: 탭마다 화면(HTML)을 만드는 함수들입니다. (page_dashboard,page_svclogs,page_seccheck,page_files,page_perm)classify/resolve: 어떤 경로든 “막힘 / 수정 가능 / 읽기만"으로 분류하는 핵심 보안 함수입니다.perm_apply: 권한을 바꾼 뒤 다시 읽어서 진짜 바뀌었는지 확인하는 함수입니다._로 시작하는 함수들: 다른 함수들이 공통으로 쓰는 내부 도우미 함수입니다.
AI의 도움으로 작성되었습니다.