From d1cc9187d0afd9c05dad782bd7a794757fa90174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Tue, 7 Jul 2026 17:11:08 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=A4=EC=A0=95=20>=20=ED=8A=B8=EB=9E=98?= =?UTF-8?q?=ED=94=BD=20=EA=B0=90=EC=8B=9C=20=ED=83=AD=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?:=20=EC=A6=89=EC=84=9D=20=EC=A7=84=EB=8B=A8=20+=205=EB=B6=84=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=EC=9E=90=EB=8F=99=20=EA=B0=90=EC=8B=9C(?= =?UTF-8?q?=EC=84=A4=EC=B9=98/=EC=A0=9C=EA=B1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 통큰아이 계약 트래픽 초과 시 원인 도메인을 못 알려주는 문제 대응. 회선 사용량 샘플링 + 최근 15분 도메인별 로그 집계로 원인 도메인과 상위 IP·User-Agent까지 좁혀준다. HestiaCP API의 대역폭 통계는 월 1회 스냅샷이라 실시간 진단에 못 써 SSH 로그 집계 방식을 택함. - 지금 진단: 10초 회선 샘플 + 도메인별 Top10 + 1위 도메인 상위 IP/UA - 자동 감시: cron으로 5분마다 측정, 임계치(Mbps) 초과 시 자동 캡처 (설치/제거 버튼 + 상태 확인 버튼으로 켜고 끄기) 실서버(mars.eond.com)에서 스냅샷/설치/상태확인/제거 전 과정 검증함. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MzTB46Dj3tcc31NunpuHm5 --- src/app.rs | 92 ++++++++++++++++++++++- src/ops.rs | 215 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 305 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index f657242..21e1327 100644 --- a/src/app.rs +++ b/src/app.rs @@ -64,6 +64,7 @@ enum SettingsTab { BulkUpdate, ModuleDelete, Disk, + Traffic, Backup, } @@ -410,6 +411,8 @@ pub struct App { mod_del_acct: String, disk_path: String, disk_alert_email: String, + /// 트래픽 자동 감시 임계치(Mbps) 입력값 + traffic_threshold: String, /// WordPress 플러그인/테마 버전비교 스캔 결과 캐시 — (도메인id, 종류) → 행들. /// 재방문·플러그인↔테마 전환 시 네트워크 재조회 없이 즉시 표시(스캔 버튼=강제 새로고침). wp_cache: std::collections::HashMap<(u64, ops::WpAssetKind), Vec>, @@ -490,6 +493,7 @@ impl App { mod_del_acct: String::new(), disk_path: "/backup".to_string(), disk_alert_email: "eond@eond.com".to_string(), + traffic_threshold: "25".to_string(), wp_cache: std::collections::HashMap::new(), wp_sel: std::collections::HashSet::new(), wp_kind: ops::WpAssetKind::Plugin, @@ -532,7 +536,8 @@ impl App { }; let settings_tab = match self.settings_tab { SettingsTab::Connect => "connect", SettingsTab::Ssh => "ssh", SettingsTab::BulkUpdate => "bulk", - SettingsTab::ModuleDelete => "moddel", SettingsTab::Disk => "disk", SettingsTab::Backup => "backup", + SettingsTab::ModuleDelete => "moddel", SettingsTab::Disk => "disk", SettingsTab::Traffic => "traffic", + SettingsTab::Backup => "backup", }; crate::model::UiState { view: view.into(), @@ -566,7 +571,8 @@ impl App { }; self.settings_tab = match ui.settings_tab.as_str() { "ssh" => SettingsTab::Ssh, "bulk" => SettingsTab::BulkUpdate, "moddel" => SettingsTab::ModuleDelete, - "disk" => SettingsTab::Disk, "backup" => SettingsTab::Backup, _ => SettingsTab::Connect, + "disk" => SettingsTab::Disk, "traffic" => SettingsTab::Traffic, "backup" => SettingsTab::Backup, + _ => SettingsTab::Connect, }; self.view = match ui.view.as_str() { "settings" => MainView::Settings, @@ -733,6 +739,9 @@ impl App { let mut do_disk_health = false; let mut do_disk_scrub: Option = None; let mut do_disk_monitor: Option = None; + let mut do_traffic_snapshot = false; + let mut do_traffic_monitor: Option = None; + let mut do_traffic_status = false; let mut do_bulk_update: Option = None; let mut do_module_delete: Option = None; let mut do_backup = false; @@ -755,6 +764,7 @@ impl App { (SettingsTab::BulkUpdate, " 일괄 업데이트 "), (SettingsTab::ModuleDelete, " 모듈 일괄 삭제 "), (SettingsTab::Disk, " 디스크 점검 "), + (SettingsTab::Traffic, " 트래픽 감시 "), (SettingsTab::Backup, " 백업/복원 "), ] { if ui.selectable_label(self.settings_tab == t, label).clicked() { @@ -964,6 +974,37 @@ impl App { ui.label(egui::RichText::new("설치 마지막에 위 주소로 테스트 메일을 자동 발송하고, 사용된 전송수단(mail/sendmail)을 로그에 표시합니다. 메일이 안 오면 서버 MTA(exim/postfix) 큐·스팸함을 점검하세요.").weak()); }); } + SettingsTab::Traffic => { + card(ui, |ui| { + ui.strong(format!("{} 지금 트래픽 진단", ph::CHART_LINE)); + ui.label(egui::RichText::new("호스팅사(예: 통큰아이)에서 '계약 트래픽 초과' 알림을 받았을 때, 지금 이 순간 어느 도메인이 원인인지 확인합니다. 회선 사용량(10초 샘플) + 최근 15분 도메인별 트래픽 Top10 + 1위 도메인의 상위 요청 IP·User-Agent를 한 번에 수집합니다(읽기 전용).").weak()); + ui.label(egui::RichText::new("※ '서버 SSH' 설정 필요. 20~30초 정도 걸립니다.").weak()); + ui.add_space(6.0); + if ui.add_enabled(!running, btn_primary(format!("{} 지금 진단", ph::MAGNIFYING_GLASS))) + .clicked() { do_traffic_snapshot = true; } + }); + ui.add_space(8.0); + card(ui, |ui| { + ui.strong(format!("{} 자동 감시 (5분 단위, 초과 시 자동 캡처)", ph::GAUGE)); + ui.label(egui::RichText::new("서버에 cron 감시를 설치합니다: 5분마다 회선 사용량을 측정하고, 지정한 임계치(Mbps)를 넘으면 그 시점의 도메인별 트래픽 Top + 상위 IP·User-Agent를 자동으로 로그에 남깁니다.").weak()); + ui.add_space(6.0); + egui::Grid::new("traffic_monitor_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + grid_label(ui, "임계치 (Mbps)"); + ui.add(egui::TextEdit::singleline(&mut self.traffic_threshold).hint_text("25").desired_width(120.0).margin(FIELD_MARGIN)); + ui.end_row(); + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.add_enabled(!running, btn_primary(format!("{} 감시 설치", ph::BELL_RINGING))) + .on_hover_text("확인 후 서버에 cron 설치").clicked() { do_traffic_monitor = Some(true); } + if ui.add_enabled(!running, egui::Button::new(format!("{} 감시 제거", ph::BELL_SLASH))) + .clicked() { do_traffic_monitor = Some(false); } + if ui.add_enabled(!running, egui::Button::new(format!("{} 상태 확인", ph::MAGNIFYING_GLASS))) + .clicked() { do_traffic_status = true; } + }); + ui.label(egui::RichText::new("설치 여부·최근 측정값·최근 초과 이력은 '상태 확인' 버튼으로 확인하세요. 상세 로그는 서버의 /var/log/hm-traffic-monitor.log 에 쌓입니다(5MB 넘으면 자동 축소).").weak()); + }); + } SettingsTab::Backup => { card(ui, |ui| { ui.strong(format!("{} 백업 (암호화)", ph::FLOPPY_DISK)); @@ -1136,6 +1177,53 @@ impl App { } } } + if do_traffic_snapshot { + match ops::build_traffic_snapshot(&self.store.settings) { + Ok(job) => { + self.running = true; + self.last_ok = None; + self.status = "트래픽 스냅샷 진단 중...".into(); + let ctx2 = ctx.clone(); + ops::spawn(job, self.tx.clone(), move || ctx2.request_repaint()); + } + Err(e) => { + self.last_ok = Some(false); + self.status = format!("진단 실패: {e}"); + self.log.push(format!("트래픽 스냅샷: {e}")); + } + } + } + if let Some(install) = do_traffic_monitor { + let built = if install { + ops::build_traffic_monitor_install(&self.store.settings, &self.traffic_threshold) + } else { + ops::build_traffic_monitor_uninstall(&self.store.settings) + }; + match built { + Ok(job) => { self.eond_confirm = Some(job); } + Err(e) => { + self.last_ok = Some(false); + self.status = format!("자동 감시 준비 실패: {e}"); + self.log.push(format!("트래픽 자동 감시: {e}")); + } + } + } + if do_traffic_status { + match ops::build_traffic_monitor_status(&self.store.settings) { + Ok(job) => { + self.running = true; + self.last_ok = None; + self.status = "트래픽 감시 상태 확인 중...".into(); + let ctx2 = ctx.clone(); + ops::spawn(job, self.tx.clone(), move || ctx2.request_repaint()); + } + Err(e) => { + self.last_ok = Some(false); + self.status = format!("상태 확인 실패: {e}"); + self.log.push(format!("트래픽 감시 상태: {e}")); + } + } + } if let Some(run) = do_bulk_update { match ops::build_bulk_git_update(&self.store.settings) { Ok(job) => { diff --git a/src/ops.rs b/src/ops.rs index 4f25e63..fb2f3da 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -1457,6 +1457,221 @@ fi echo "===== 설치 끝 =====" "#; +// ===== 트래픽 감시 (호스팅사 계약 트래픽 초과 대응) ===== +// +// 배경(2026-07-07): 통큰아이 등 호스팅사가 "5분 단위 최대 트래픽이 계약량 초과"를 통보해도 +// 어느 도메인이 원인인지는 알려주지 않는다. HestiaCP 의 v-list-user-stats/v-list-users-stats 는 +// 월 1회 스냅샷(U_BANDWIDTH)만 제공해 실시간 원인 추적에는 못 쓴다(직접 SSH 로 확인해 확정). +// 대신 도메인 접속로그(주로 apache2/domains, 없으면 nginx/domains)에서 최근 N분 구간을 직접 집계해 +// 원인 도메인 → 상위 IP/User-Agent 순으로 좁혀가는 방식을 그대로 자동화한다. + +/// 최근 N분 구간을 매칭하기 위한 Apache/Nginx 로그 타임스탬프 패턴(day/Mon/Year:HH:MM) 생성 + 도메인별 +/// 트래픽 Top10, 1위 도메인의 상위 IP/User-Agent 를 뽑는 셸 로직. 여러 곳(즉석 진단/자동 감시)에서 +/// 그대로 재사용하도록 문자열 상수로 둔다 (파라미터는 셸 변수 MINWIN/DOMLOG_DIR 로 주입). +const TRAFFIC_TOP_DOMAINS_BODY: &str = r#" +NOW=$(date +%s) +PATS="" +i=0 +while [ $i -lt $MINWIN ]; do + T=$(LC_TIME=C date -d "@$((NOW-i*60))" +'%d/%b/%Y:%H:%M') + PATS="$PATS|$T" + i=$((i+1)) +done +PATS=${PATS#|} +DOMLOG_DIR=/var/log/apache2/domains +[ -d "$DOMLOG_DIR" ] || DOMLOG_DIR=/var/log/nginx/domains +TOP=$(for f in "$DOMLOG_DIR"/*.log; do + [ -f "$f" ] || continue + case "$f" in *.error.log) continue;; esac + dom=$(basename "$f" .log) + tail -n 20000 "$f" 2>/dev/null | grep -E "$PATS" | awk -v d="$dom" '{s+=$10; n++} END{if(n>0) printf "%12d bytes %6d reqs %s\n", s, n, d}' +done | sort -rn) +echo "$TOP" | head -10 +TOPDOM=$(echo "$TOP" | head -1 | awk '{print $NF}') +if [ -n "$TOPDOM" ] && [ -f "$DOMLOG_DIR/$TOPDOM.log" ]; then + echo "-- '$TOPDOM' 상위 요청 IP --" + tail -n 20000 "$DOMLOG_DIR/$TOPDOM.log" | grep -E "$PATS" | awk '{b[$1]+=$10; n[$1]++} END{for(i in b) printf "%12d bytes %6d reqs %s\n", b[i], n[i], i}' | sort -rn | head -8 + echo "-- '$TOPDOM' 상위 User-Agent --" + tail -n 20000 "$DOMLOG_DIR/$TOPDOM.log" | grep -E "$PATS" | awk -F'"' '{print substr($6,1,90)}' | sort | uniq -c | sort -rn | head -6 +fi +"#; + +/// 지금 즉시 회선 사용량 + 도메인별 트래픽 Top 진단 (SSH, sudo, 읽기 전용). +/// 호스팅사의 "계약 트래픽 초과" 통보를 받았을 때, 그 자리에서 원인 도메인을 좁히기 위한 버튼. +pub fn build_traffic_snapshot(s: &Settings) -> Result { + let (srv, who) = server_ssh_site(s)?; + let raw = format!( + r#"set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +echo "===== 트래픽 스냅샷 =====" +echo "[1] 회선 사용량 (10초 샘플)" +IFACE=$(ip -o route get 1.1.1.1 2>/dev/null | awk '{{for(i=1;i<=NF;i++) if($i=="dev"){{print $(i+1); exit}}}}') +[ -z "$IFACE" ] && IFACE=$(awk -F: 'NR>2 && $1 !~ /lo/{{gsub(/ /,"",$1); print $1; exit}}' /proc/net/dev) +read_bytes(){{ awk -v i="$1:" '$1==i{{print $2" "$10}}' /proc/net/dev; }} +R1=$(read_bytes "$IFACE") +sleep 10 +R2=$(read_bytes "$IFACE") +RX1=$(echo "$R1" | awk '{{print $1}}'); TX1=$(echo "$R1" | awk '{{print $2}}') +RX2=$(echo "$R2" | awk '{{print $1}}'); TX2=$(echo "$R2" | awk '{{print $2}}') +awk -v a="$RX1" -v b="$RX2" -v c="$TX1" -v d="$TX2" -v i="$IFACE" 'BEGIN{{printf " 인터페이스 %s RX %.2f Mbps TX %.2f Mbps\n", i, (b-a)*8/10/1000000, (d-c)*8/10/1000000}}' +echo "" +echo "[2] 도메인별 트래픽 Top10 (최근 15분)" +MINWIN=15 +{top_domains} +echo "" +echo "[3] 봇 차단 적용 상태" +if [ -f /etc/nginx/conf.d/block-scrapers.conf ]; then + echo " 적용됨 ($(grep -c '^deny' /etc/nginx/conf.d/block-scrapers.conf 2>/dev/null) 개 대역 차단 중)" +else + echo " 차단 설정 없음" +fi +echo "===== 진단 끝 =====" +echo "※ 1위 도메인의 상위 IP가 특정 대역(예: OVH/Hetzner 등 해외 데이터센터)에 몰려 있으면 nginx conf.d 에 deny 추가를 검토하세요." +"#, + top_domains = TRAFFIC_TOP_DOMAINS_BODY, + ); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: "트래픽 스냅샷 진단".into(), + script, + sshpass, + env, + note: format!("{who} → 회선 사용량(10초) + 최근 15분 도메인별 Top10 + 1위 도메인 상위 IP/UA (읽기 전용, 20~30초 소요)"), + }) +} + +/// 트래픽 자동 감시 설치 (SSH, sudo, 서버 상태 변경 — 확인 모달 권장). +/// 5분마다 회선 사용량을 측정해 임계치(Mbps) 초과 시 그 시점의 도메인별 트래픽 Top + 상위 IP/UA 를 +/// /var/log/hm-traffic-monitor.log 에 자동 기록한다. 최신 측정값은 /var/lib/hm-traffic-monitor/last.txt. +pub fn build_traffic_monitor_install(s: &Settings, threshold_mbps: &str) -> Result { + let (srv, who) = server_ssh_site(s)?; + let thr: u32 = threshold_mbps.trim().parse().map_err(|_| "임계치는 숫자(Mbps)여야 합니다".to_string())?; + if thr == 0 || thr > 100_000 { + return Err("임계치가 올바르지 않습니다 (1~100000 Mbps)".into()); + } + let head = format!("set +e\nTHRESHOLD={thr}\n"); + let raw = head + &TRAFFIC_MONITOR_INSTALL_BODY.replace("__TOP_DOMAINS__", TRAFFIC_TOP_DOMAINS_BODY); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: "트래픽 자동 감시 설치".into(), + script, + sshpass, + env, + note: format!("{who} → 5분마다 회선 사용량 확인, {thr}Mbps 초과 시 도메인별 원인 자동 캡처 (cron 설치, 서버 상태 변경)"), + }) +} + +/// 트래픽 자동 감시 제거 (SSH, sudo). 로그/상태파일은 보존. +pub fn build_traffic_monitor_uninstall(s: &Settings) -> Result { + let (srv, who) = server_ssh_site(s)?; + let raw = r#"set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +echo "===== 트래픽 자동 감시 제거 =====" +rm -f /etc/cron.d/hm-traffic-monitor /usr/local/sbin/hm-traffic-monitor.sh +echo " cron/스크립트 제거" +systemctl restart cron 2>/dev/null || service cron restart 2>/dev/null || systemctl restart crond 2>/dev/null || true +echo " ※ 상태파일(/var/lib/hm-traffic-monitor)·로그(/var/log/hm-traffic-monitor.log)는 보존됨" +echo "===== 제거 끝 =====" +"#; + let (script, sshpass, env) = eondcms_exec(&srv, raw, false, true); + Ok(Job { + title: "트래픽 자동 감시 제거".into(), + script, + sshpass, + env, + note: format!("{who} → cron/스크립트 제거"), + }) +} + +/// 트래픽 자동 감시 상태 확인 (SSH, sudo, 읽기 전용) — 설치여부 + 최근 측정값 + 최근 초과 이력. +pub fn build_traffic_monitor_status(s: &Settings) -> Result { + let (srv, who) = server_ssh_site(s)?; + let raw = r#"set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +echo "===== 트래픽 자동 감시 상태 =====" +if [ -f /etc/cron.d/hm-traffic-monitor ]; then + THR=$(grep -oE '^THRESHOLD=[0-9]+' /usr/local/sbin/hm-traffic-monitor.sh 2>/dev/null | cut -d= -f2) + echo " 설치됨 (임계치 ${THR:-알수없음}Mbps, 5분마다 실행)" +else + echo " 설치 안 됨" +fi +echo "" +echo "-- 최근 측정값 --" +cat /var/lib/hm-traffic-monitor/last.txt 2>/dev/null || echo " (아직 없음)" +echo "" +echo "-- 최근 초과 이력 (최대 60줄) --" +tail -n 60 /var/log/hm-traffic-monitor.log 2>/dev/null || echo " (기록 없음)" +echo "===== 끝 =====" +"#; + let (script, sshpass, env) = eondcms_exec(&srv, raw, false, true); + Ok(Job { + title: "트래픽 자동 감시 상태 확인".into(), + script, + sshpass, + env, + note: format!("{who} → 감시 설치여부 + 최근 측정값 + 초과 이력 (읽기 전용)"), + }) +} + +/// 설치 스크립트 본문(셸). __TOP_DOMAINS__ 는 build_traffic_monitor_install 에서 +/// TRAFFIC_TOP_DOMAINS_BODY 로 치환된다(진단/감시 로직 중복 방지). +const TRAFFIC_MONITOR_INSTALL_BODY: &str = r#" +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +echo "===== hostmover 트래픽 자동 감시 설치 (임계치 ${THRESHOLD}Mbps) =====" +mkdir -p /var/lib/hm-traffic-monitor +cat > /usr/local/sbin/hm-traffic-monitor.sh <<'EOS' +#!/usr/bin/env bash +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +THRESHOLD=__THRESHOLD__ +STATE=/var/lib/hm-traffic-monitor +LOG=/var/log/hm-traffic-monitor.log +DUR=10 +IFACE=$(ip -o route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev"){print $(i+1); exit}}') +[ -z "$IFACE" ] && IFACE=$(awk -F: 'NR>2 && $1 !~ /lo/{gsub(/ /,"",$1); print $1; exit}' /proc/net/dev) +read_bytes(){ awk -v i="$1:" '$1==i{print $2" "$10}' /proc/net/dev; } +R1=$(read_bytes "$IFACE") +sleep "$DUR" +R2=$(read_bytes "$IFACE") +RX1=$(echo "$R1" | awk '{print $1}'); TX1=$(echo "$R1" | awk '{print $2}') +RX2=$(echo "$R2" | awk '{print $1}'); TX2=$(echo "$R2" | awk '{print $2}') +RXMBPS=$(awk -v a="$RX1" -v b="$RX2" -v d="$DUR" 'BEGIN{ if(a=="") a=0; if(b=="") b=0; printf "%.2f", (b-a)*8/d/1000000}') +TXMBPS=$(awk -v a="$TX1" -v b="$TX2" -v d="$DUR" 'BEGIN{ if(a=="") a=0; if(b=="") b=0; printf "%.2f", (b-a)*8/d/1000000}') +NOW_H=$(date '+%F %T') +echo "$NOW_H iface=$IFACE rx=${RXMBPS}Mbps tx=${TXMBPS}Mbps threshold=${THRESHOLD}Mbps" > "$STATE/last.txt" +OVER=$(awk -v t="$TXMBPS" -v r="$RXMBPS" -v th="$THRESHOLD" 'BEGIN{m=(t>r)?t:r; print (m>th)?"1":"0"}') +if [ "$OVER" = "1" ]; then + { + echo "===== $NOW_H 초과 감지 (TX=${TXMBPS}Mbps RX=${RXMBPS}Mbps, 임계치=${THRESHOLD}Mbps) =====" + MINWIN=15 +__TOP_DOMAINS__ + echo "" + } >> "$LOG" + if [ -f "$LOG" ] && [ "$(stat -c%s "$LOG" 2>/dev/null || echo 0)" -gt 5242880 ]; then + tail -c 2621440 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" + fi +fi +EOS +sed -i "s|__THRESHOLD__|$THRESHOLD|g" /usr/local/sbin/hm-traffic-monitor.sh +chmod +x /usr/local/sbin/hm-traffic-monitor.sh + +cat > /etc/cron.d/hm-traffic-monitor <<'EOS' +# hostmover 트래픽 감시 (자동 생성) +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +*/5 * * * * root /usr/local/sbin/hm-traffic-monitor.sh +EOS +chmod 644 /etc/cron.d/hm-traffic-monitor + +/usr/local/sbin/hm-traffic-monitor.sh +systemctl restart cron 2>/dev/null || service cron restart 2>/dev/null || systemctl restart crond 2>/dev/null || true +echo "[완료]" +echo " 5분마다 /usr/local/sbin/hm-traffic-monitor.sh 실행 (임계치 ${THRESHOLD}Mbps)" +echo " 최근 상태: /var/lib/hm-traffic-monitor/last.txt" +echo " 초과 시 상세 로그: /var/log/hm-traffic-monitor.log (5MB 넘으면 자동 축소)" +cat /var/lib/hm-traffic-monitor/last.txt 2>/dev/null +echo "===== 설치 끝 =====" +"#; + // ===== 일반 CMS 설치 (WordPress / Rhymix / 그누보드) ===== fn cms_validate(server: &Site, c: &CmsInstall, use_root: bool, need_db: bool, need_admin: bool) -> Result<(), String> {