Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 90 additions & 2 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ enum SettingsTab {
BulkUpdate,
ModuleDelete,
Disk,
Traffic,
Backup,
}

Expand Down Expand Up @@ -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<ops::WpPluginRow>>,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -733,6 +739,9 @@ impl App {
let mut do_disk_health = false;
let mut do_disk_scrub: Option<u32> = None;
let mut do_disk_monitor: Option<bool> = None;
let mut do_traffic_snapshot = false;
let mut do_traffic_monitor: Option<bool> = None;
let mut do_traffic_status = false;
let mut do_bulk_update: Option<bool> = None;
let mut do_module_delete: Option<bool> = None;
let mut do_backup = false;
Expand All @@ -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() {
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) => {
Expand Down
215 changes: 215 additions & 0 deletions src/ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Job, String> {
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<Job, String> {
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<Job, String> {
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<Job, String> {
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> {
Expand Down