From 44764c87fb832977ef97f98a073dcbd5c665dc68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Thu, 30 Jul 2026 06:59:43 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=EA=B3=84=EC=A0=95/=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=84=9C=EB=B2=84=20=EC=82=AD=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B3=84=ED=9A=8D=20+=20.claude=20=EC=BB=A4?= =?UTF-8?q?=EB=B0=8B=20=EB=B0=A9=EC=96=B4=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요구: 계정 관리에서 유저/도메인을 골라 삭제하면 파일·DB·HestiaCP 등록까지 정리. 조사 결과 셋 다 가능하다. HestiaCP 쪽 도메인 제거는 어려운 게 아니라 오히려 정석이다 — v-delete-web-domain 이 파일·설정·nginx·SSL 을 한 번에 정리한다. 반대로 rm -rf 로 파일만 지우면 패널에 유령 도메인이 남아 재등록 때 충돌한다. 계획의 핵심은 "가능한가"가 아니라 안전장치다: - 사이드바의 기존 '삭제'는 로컬 기록 휴지통이고 서버를 안 건드린다. 여기에 서버 실삭제를 같은 모양으로 붙이면 언젠가 잘못 누른다. → 서버 삭제는 계정 관리 페이지 안에만, 문구도 '서버에서 완전 삭제' 로 분리 - 점검(읽기) → 백업 → 삭제 3단계 분리, 백업 실패 시 삭제 차단 - 계정/도메인 이름 타이핑 확인 - DB 공유 검사: 한 DB 를 여러 도메인이 참조하면(스테이징이 운영 DB 를 쓰는 흔한 경우) 도메인 하나 지우려다 다른 사이트가 죽는다 → DB 삭제 건너뜀 - 보호 대상 거부: admin, 설정의 ssh_user 자신, 시스템 계정, 비-Hestia 계정 - 삭제 후 잔존 검증 + /var/log/hostmover/deletions.log 이력 읽기 기능부터 4단계로 끊어 진행하도록 제안했다. 미확정 4건은 문서 7장. 함께: .gitignore 에 .claude/* (plans/ 만 예외) 추가. settings.local.json 은 승인한 명령 전문이 저장돼 비밀번호·토큰이 섞일 수 있다. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WxEiSzFMdeAzvohAQ39nyJ --- .../plans/2026-07-30-account-domain-delete.md | 237 ++++++++++++++++++ .gitignore | 7 + 2 files changed, 244 insertions(+) create mode 100644 .claude/plans/2026-07-30-account-domain-delete.md diff --git a/.claude/plans/2026-07-30-account-domain-delete.md b/.claude/plans/2026-07-30-account-domain-delete.md new file mode 100644 index 0000000..c8b1f99 --- /dev/null +++ b/.claude/plans/2026-07-30-account-domain-delete.md @@ -0,0 +1,237 @@ +# 계정/도메인 삭제 기능 — 계획 + +작성 2026-07-30. **구현 전 검토용 문서.** 아직 코드는 없다. + +요구: 사이드바나 계정 관리에서 유저/도메인을 골라 삭제하면 **파일·DB·HestiaCP 등록까지** +정리되게. HestiaCP 쪽 도메인 제거가 가능한지 판단. + +--- + +## 1. 결론 먼저 + +| 질문 | 답 | +|---|---| +| 파일 + DB 삭제 가능? | **가능.** DB 이름은 이미 `CMS_DETECT` 가 CMS별로 찾아낸다 | +| HestiaCP 에서 도메인도 없어지게 가능? | **가능하고, 오히려 이게 정석이다.** 어렵지 않다 | +| 계정 통째로 삭제 가능? | **가능.** `v-delete-user` 한 방에 도메인·DB·메일·크론·파일 전부 | + +**중요한 건 "가능한가"가 아니라 "잘못 눌렀을 때 어떻게 되는가"다.** 이 기능은 되돌릴 수 +없다. 그래서 아래 설계의 절반이 안전장치다. + +### 파일을 직접 지우면 안 된다 + +`rm -rf /home/rokmc/web/example.com` 같은 직접 삭제는 하면 안 된다. HestiaCP 는 자기 +설정(`/usr/local/hestia/data/users//web.conf`)과 nginx/apache 설정을 따로 들고 있어서, +파일만 지우면 **패널에는 도메인이 계속 남고 웹서버 설정도 남는다.** 유령 항목이 되고 +나중에 같은 도메인을 다시 추가할 때 충돌한다. + +반드시 HestiaCP CLI 를 경유한다. 그게 파일·DB·설정·nginx 재생성까지 한 번에 처리한다. + +--- + +## 2. 쓸 명령 (HestiaCP CLI) + +`/usr/local/hestia/bin/` 아래. 이미 `v-add-*`, `v-list-*` 는 이 프로젝트에서 쓰고 있다. + +**조사 (읽기 전용)** + +```bash +v-list-web-domains json # 웹 도메인 목록 +v-list-databases json # DB 목록 (+ 크기) +v-list-dns-domains json +v-list-mail-domains json +v-list-user json # 디스크 사용량, 도메인 수 +``` + +**삭제** + +```bash +v-delete-web-domain # 파일·설정·nginx/apache·SSL 까지 +v-delete-dns-domain +v-delete-mail-domain # 메일함 포함 +v-delete-database # DB + DB유저 +v-delete-user # 위 전부 + /home/ + 크론 +``` + +**백업 (삭제 전 필수)** + +```bash +v-backup-user # /backup/.<날짜>.tar — 메일·크론·DNS까지 포함 +``` + +`v-backup-user` 가 이 프로젝트의 `build_local_backup`(웹루트+DB덤프 tar.gz)보다 완전하다. +**계정 삭제 전에는 이걸 쓴다.** 도메인 단위 삭제는 로컬 백업으로 충분하다. + +--- + +## 3. 두 종류의 "삭제"를 절대 섞지 말 것 + +지금 사이드바에는 **이미 삭제가 있다.** `app.rs` 의 "고객 삭제(휴지통)" / "도메인 삭제" — +이건 **hostmover 로컬 기록만** 휴지통으로 옮긴다. 서버는 건드리지 않는다. + +여기에 서버 실삭제를 같은 모양으로 붙이면 사고가 난다. 사용자가 "도메인 삭제"를 눌렀을 때 +그게 메모 정리인지 서버 파괴인지 구분이 안 되면, 언젠가 반드시 잘못 누른다. + +| | 위치 | 하는 일 | 되돌리기 | +|---|---|---|---| +| **로컬 기록 삭제** (기존) | 사이드바 🗑 | hostmover 기록만 휴지통 | 휴지통에서 복구 | +| **서버 실삭제** (신규) | 계정 관리 페이지 | 서버의 파일·DB·HestiaCP 등록 | **불가** (백업 복원만) | + +원칙: + +- 서버 실삭제는 **사이드바에 두지 않는다.** 계정 관리 페이지 안에만 둔다. + (사이드바는 "내 메모장", 계정 관리는 "실제 서버"라는 경계를 유지) +- 버튼 문구를 다르게 한다: 휴지통은 `삭제`, 서버는 **`서버에서 완전 삭제`** +- 서버 삭제 버튼은 빨간색 + 경고 아이콘. 확인 모달 제목에 서버 IP/호스트명을 박는다. + +--- + +## 4. 안전장치 (이 기능의 핵심) + +### 4-1. 3단계로 나눈다 + +한 번의 클릭으로 지워지지 않게 **점검 → 백업 → 삭제**를 분리한다. + +1. **점검 (읽기 전용)** — 무엇이 지워지는지 먼저 보여준다. + 도메인, 웹루트 크기, DB 이름·크기, DNS/메일 도메인 유무, SSL, 그리고 아래 4-3 의 공유 검사. + 이 단계는 확인 모달 없이 바로 실행(아무것도 안 바꾸므로). +2. **백업** — 도메인 단위는 로컬 tar.gz, 계정 단위는 `v-backup-user`. + **백업이 실패하면 삭제로 넘어가지 않는다.** 성공 시 백업 경로·크기를 로그에 남긴다. +3. **삭제** — 확인 모달 + 아래 4-2 의 타이핑 확인. + +### 4-2. 이름을 직접 입력하게 한다 + +기존 `eond_confirm` 모달(예/아니오)만으로는 부족하다. 계정 삭제는 **계정명을 타이핑**해야 +버튼이 활성화되게 한다. 도메인 삭제는 도메인명. + +``` +⚠ 서버에서 완전 삭제 — mars.eond.com + + rokmc / example.com + 파일 2.4GB · DB rokmc_wp (180MB) · DNS 있음 · 메일 없음 + + 되돌릴 수 없습니다. 백업: ~/.local/share/hostmover/backups/... (2.6GB, 완료) + + 계속하려면 도메인을 입력하세요: [ ] [ 취소 ] [ 삭제 ] +``` + +### 4-3. DB 공유 검사 — 가장 위험한 함정 + +**한 DB를 여러 도메인이 쓰고 있으면 도메인 하나 지우려다 다른 사이트를 죽인다.** +개발/스테이징 사이트가 운영 DB를 그대로 참조하는 경우가 실제로 흔하다. + +삭제 대상 DB 이름을 그 계정의 **모든 웹루트 설정파일에서 역검색**한다: + +```bash +# wp-config.php / db.config.php / dbconfig.php / .env 전부에서 DB 이름 등장 횟수 +grep -rl "$DB" /home/$USER/web/*/public_html/{wp-config.php,data/dbconfig.php} \ + /home/$USER/web/*/public_html/{files/,}config/db.config.php \ + /home/$USER/web/*/pythonapp/.env 2>/dev/null +``` + +2곳 이상에서 참조되면 **DB 삭제를 건너뛰고** 그 사실을 보고한다(파일/도메인만 삭제). +사용자가 그래도 지우겠다면 별도 확인을 한 번 더 받는다. + +### 4-4. 보호 대상 하드코딩 + +다음은 **입력 검증 단계에서 거부**한다. 실수로도 지워지면 서버가 죽는다. + +- `admin` (HestiaCP 관리자) +- 설정의 서버 SSH 계정 자신 (`Settings.ssh_user`) — 자기 발등 찍기 방지 +- `root`, `www-data`, `mysql`, `hestiaweb` 등 시스템 계정 +- `v-list-users` 결과에 없는 계정 (= HestiaCP 계정이 아님 → 손대지 않는다) + +기존 `is_safe_name` / `to_ascii_domain` 검증은 그대로 재사용한다(주입 방지). + +### 4-5. 삭제 후 검증 + 기록 + +삭제가 "됐다고 하는데 안 된" 경우를 잡는다. + +```bash +v-list-web-domains "$USER" | grep -q "$DOM" && echo "✗ 패널에 아직 남아 있음" +[ -d "/home/$USER/web/$DOM" ] && echo "✗ 디렉터리 잔존" +mysql -N -e "SHOW DATABASES LIKE '$DB'" | grep -q . && echo "✗ DB 잔존" +``` + +그리고 서버에 삭제 이력을 남긴다 — 나중에 "누가 이거 지웠지?"를 답할 수 있어야 한다. + +``` +/var/log/hostmover/deletions.log +2026-07-30 14:22:01 DELETE domain rokmc/example.com files=2.4GB db=rokmc_wp(180MB) backup=OK +``` + +--- + +## 5. 구현 계획 + +### ops.rs — 새 함수 5개 + +기존 `build_account_modules_delete` 의 검증·조립 패턴을 그대로 따른다. + +| 함수 | 성격 | 하는 일 | +|---|---|---| +| `build_domain_delete_probe(s, acct, domain)` | 읽기 | 지워질 것 목록 + 크기 + DB 공유 검사 | +| `build_domain_delete(s, acct, domain, drop_db)` | **파괴** | `v-delete-web-domain` → DNS → 메일 → (DB) → 검증 | +| `build_account_delete_probe(s, acct)` | 읽기 | 계정의 전체 도메인·DB·용량 | +| `build_account_backup(s, acct)` | 서버변경(안전) | `v-backup-user` | +| `build_account_delete(s, acct)` | **파괴** | `v-delete-user` → 검증 | + +`drop_db` 는 4-3 검사 결과에 따라 UI 에서 정해 넘긴다. + +DB 삭제는 `v-delete-database` 우선, 실패 시(패널에 등록 안 된 수동 생성 DB) `DROP DATABASE` +폴백 — 단 이 폴백은 사용자 확인을 받은 경우만. + +### app.rs — UI + +계정 관리 페이지(`AcctTab`)에 **`위험 작업` 탭 신규**. 사이트/모듈 탭과 분리해서, 실수로 +들어가지 않게 한다. + +- 사이트 탭의 `sel` 체크박스(이미 있음)로 도메인 선택 → 위험 작업 탭에서 대상 확인 +- `점검` → `백업` → `서버에서 완전 삭제` 3버튼 순차 (앞 단계 미완료면 다음 버튼 비활성) +- 계정 전체 삭제는 이 탭 맨 아래, 접힌 섹션(`CollapsingHeader`)으로 한 번 더 숨긴다 +- 삭제 성공 후 사이드바 로컬 기록도 지울지 **따로 묻는다** (자동 연동하지 않는다 — 3장의 경계 유지) + +### 테스트 + +`disk_monitor_jobs_valid_bash` 와 같은 방식으로: + +- 생성 스크립트 `bash -n` 문법 검증 +- 보호 계정(`admin`, `root`, ssh_user 자신) 거부 확인 +- 주입 시도 거부 (`../etc`, 따옴표 포함 도메인) +- `drop_db=false` 일 때 스크립트에 `v-delete-database` 가 **없어야** 함 +- shim(`v-*` 가짜 명령)으로 삭제 순서·검증 단계 실제 실행 + +--- + +## 6. 단계별 진행 제안 + +한 번에 다 만들지 말고 끊는다. **읽기 기능만 먼저 넣어 쓰면서 신뢰가 생긴 뒤** 파괴 기능을 붙인다. + +| 단계 | 내용 | 위험 | +|---|---|---| +| 1 | 점검(probe) 2개 + UI 표시만 | 없음 (읽기 전용) | +| 2 | 백업(`v-backup-user`) + 백업 목록 확인 | 낮음 | +| 3 | **도메인** 삭제 (DB 공유 검사 포함) | 높음 | +| 4 | **계정** 삭제 | 매우 높음 | + +3단계 전에 테스트 계정(`hmtest` + 더미 도메인)을 서버에 만들어 실제로 지워보고, 4-5 의 +검증이 실제로 통과하는지 확인한 뒤 실전에 쓰는 것을 권한다. + +--- + +## 7. 미확정 — 결정 필요 + +1. **삭제 후 사이드바 로컬 기록**: 자동 휴지통 이동 vs 매번 묻기. + → 지금은 "매번 묻기"로 계획했다. 자동이 편하지만 경계가 흐려진다. +2. **백업 강제 여부**: 백업 실패 시 삭제를 무조건 막을지, 사용자가 무시할 수 있게 할지. + → "무조건 막기"로 계획했다. 무시 옵션은 결국 눌리게 된다. +3. **`/backup` 용량**: `v-backup-user` 는 계정 크기만큼 쓴다. 디스크가 꽉 차 있으면 실패한다. + → 백업 전 `df` 확인을 넣는다. (디스크 점검 기능과 연계) +4. 여러 도메인 **일괄 삭제**를 허용할지. → 1차에서는 **한 번에 하나만**. 일괄은 사고 규모를 키운다. + +--- + +## 8. 관련 + +- 매일 디스크 점검(백업 공간 확인과 연계): [../../docs/disk-check.md](../../docs/disk-check.md) +- 일괄 업데이트(같은 계정/도메인 순회 패턴): [../../docs/bulk-update.md](../../docs/bulk-update.md) diff --git a/.gitignore b/.gitignore index 82e1817..35553ab 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,10 @@ store.json # 대용량 패키지 아티팩트 *.zip + +# Claude Code — plans/ 만 공유한다. +# settings.local.json 은 승인한 명령 전문이 그대로 저장되므로(비밀번호·토큰이 섞일 수 있다) +# 절대 커밋하지 않는다. worktrees/ 는 작업용 체크아웃 사본이다. +.claude/* +!.claude/plans/ +.omc/ From f9b4a9df0c89749c1375245324024ee005d822c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Thu, 30 Jul 2026 08:08:06 +0900 Subject: [PATCH 2/6] =?UTF-8?q?Codex=20=EC=9E=91=EC=97=85=20=EC=A7=80?= =?UTF-8?q?=EC=8B=9C=EC=84=9C:=20=EA=B3=84=EC=A0=95/=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20=EA=B0=9C=EB=B0=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=99=20=ED=99=95=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검토 문서(#8)에 남아 있던 미확정 4건을 닫고, 구현만 하면 되는 스펙으로 정리했다. 스펙 작성 자체가 판단이라 여기서 확정하고 구현은 Codex 가 맡는다. 확정 사항 삭제 후 사이드바 로컬 기록 → 건드리지 않는다(경계 유지, 로그 안내만) 백업 실패 시 → 삭제 차단, 무시 옵션 없음 백업 전 디스크 확인 → df 로 계정크기+1GB 확인, 부족하면 중단 일괄 삭제 → 한 번에 하나만 UI 위치 → 계정 관리의 신규 '위험 작업' 탭 조회 방식 → SSH (파일·DB 크기·공유검사는 API 로 못 한다) 문서에 담은 것 - 리포 컨텍스트: Job/eondcms_exec 실행 모델, 재사용 헬퍼 6개의 정확한 시그니처와 위치 - Phase 1~4 (점검 → 백업 → 도메인삭제 → 계정삭제), 각 DoD 와 커밋 단위 - 셸 스크립트 5개 전문 (그대로 옮겨 쓸 수 있는 수준) - 신규 App 필드 6개, 전용 확인 모달(타이핑 확인) 요구사항 - 테스트 케이스: 점검에 파괴명령 부재 강제, 웹루트 직접 rm 금지 강제, 보호계정·주입 거부, 원격 셸 2차 방어 존재 확인 - 흔한 함정 6개 (format! 중괄호, grep -qx, du 시점, DB 탐지 순서, set -e, 상태 리셋) 검증 스펙에 실은 셸 5개 전부 bash -n 통과. 도메인 점검 스크립트는 v-* shim + 가짜 사이트로 실제 실행해 3케이스 확인: 단독 DB → HM_DBSHARED=0 / 다른 도메인이 같은 DB 참조 → 1 + 경고 / 미검출 → 빈 값 이 과정에서 결함 하나를 잡아 보강: DB 미검출 분기에서 HM_DBSHARED 가 출력되지 않아 이전 점검 값이 남을 수 있었다(→ 다른 도메인 DB 삭제 위험). 모든 분기에서 두 줄 출력 + 점검 시작 시 UI 상태 리셋을 스펙에 명시. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01WxEiSzFMdeAzvohAQ39nyJ --- .../2026-07-30-account-domain-delete-SPEC.md | 785 ++++++++++++++++++ .../plans/2026-07-30-account-domain-delete.md | 6 +- 2 files changed, 790 insertions(+), 1 deletion(-) create mode 100644 .claude/plans/2026-07-30-account-domain-delete-SPEC.md diff --git a/.claude/plans/2026-07-30-account-domain-delete-SPEC.md b/.claude/plans/2026-07-30-account-domain-delete-SPEC.md new file mode 100644 index 0000000..3d8e60a --- /dev/null +++ b/.claude/plans/2026-07-30-account-domain-delete-SPEC.md @@ -0,0 +1,785 @@ +# 개발 스펙 — 계정/도메인 서버 삭제 (Codex 작업 지시서) + +작성 2026-07-30. **구현 담당: Codex.** 이 문서는 판단이 끝난 확정 스펙이다. +배경·근거는 [2026-07-30-account-domain-delete.md](2026-07-30-account-domain-delete.md) 참고(읽지 않아도 구현 가능). + +--- + +## 0. 작업 규칙 + +- **이 문서에 없는 판단은 하지 말고 질문한다.** 특히 "안전장치를 간소화해도 될까"류는 전부 금지. +- Phase 1 → 4 **순서대로** 구현하고 **Phase 단위로 커밋**한다. 한 커밋에 몰지 않는다. +- 각 Phase 의 `DoD`(완료 기준)를 통과해야 다음 Phase 로 간다. +- 빌드: `cargo build` / 테스트: `cargo test` / 릴리스: `cargo build --release` + (clippy 는 이 환경에 없다. 없으면 건너뛴다) +- 새 코드의 주석·UI 문자열은 **한국어**. 기존 코드 톤(간결한 설명체)을 따른다. +- 커밋 메시지도 한국어. 기존 이력 형식을 따른다. + +### 절대 규칙 (위반 시 작업 무효) + +1. **자격증명을 소스·테스트·예제에 절대 넣지 않는다.** 비밀번호는 기존 방식대로 + `SSHPASS` 환경변수 경유(`eondcms_exec` 가 처리). argv 에 노출 금지. +2. **`rm -rf` 로 웹루트를 직접 지우지 않는다.** 반드시 `v-delete-web-domain` 경유. + (파일만 지우면 HestiaCP 에 유령 도메인이 남고 nginx 설정도 남는다) +3. **테스트에서 실제 서버에 접속하지 않는다.** 스크립트 문자열 검증 + `bash -n` 까지만. +4. 기존 함수의 시그니처·동작을 바꾸지 않는다. 전부 **신규 추가**로 처리한다. + +--- + +## 1. 리포 컨텍스트 + +Rust + egui/eframe 0.31.1 데스크탑 앱. SSH 로 HestiaCP 서버를 관리한다. + +| 파일 | 역할 | +|---|---| +| `src/ops.rs` | 모든 원격 작업의 **셸 스크립트 조립**. `build_*` 함수가 `Job` 을 반환 | +| `src/app.rs` | egui UI. 버튼 → 플래그 → `build_*` 호출 → 확인 모달 또는 즉시 실행 | +| `src/model.rs` | `Settings`, `Site` 등 데이터 | + +### 실행 모델 + +```rust +pub struct Job { + pub title: String, + pub script: String, // 로컬에서 실행될 완성된 셸 명령 + pub sshpass: String, // SSHPASS 로 넘길 비밀번호 (argv 노출 방지) + pub env: Vec<(String, String)>, + pub note: String, // 확인 모달·로그에 보여줄 안내 +} +``` + +`build_*` 는 원격에서 돌 **raw 셸 문자열**을 만들고 마지막에 감싼다: + +```rust +let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); +// srv, raw, use_root=false, sudo=true +``` + +`sudo=true` → 원격에서 `sudo -S -p '' bash -s` 로 실행되므로 **raw 안은 root 권한**이다. +`raw` 는 단일 인용 heredoc 안에 들어가므로 로컬 셸 확장이 일어나지 않는다. + +### 재사용할 기존 헬퍼 (시그니처 그대로) + +```rust +fn ssh_admin_site(s: &Settings) -> Result // ops.rs:2490 +fn server_ssh_site(s: &Settings) -> Result<(Site, String), String> // ops.rs:1126 (Site, "user@host") +fn eondcms_exec(server: &Site, raw: &str, use_root: bool, sudo: bool) + -> (String, String, Vec<(String, String)>) // ops.rs:515 +fn sq(s: &str) -> String // ops.rs:161 셸 단일인용 이스케이프 +fn is_safe_name(s: &str) -> bool // ops.rs:2428 +fn to_ascii_domain(d: &str) -> String // ops.rs:249 한글도메인→퓨니코드 +``` + +`is_safe_name` 구현(이미 있음): 비어있지 않고 `..` 없고 `[A-Za-z0-9_.-]` 만. + +**`format!` 안의 raw 셸에서는 `{` `}` 를 `{{` `}}` 로 이스케이프해야 한다.** +셸 중괄호가 많으면 `format!` 을 쓰지 않는 `const` 로 빼는 것을 권한다 +(기존 `DISK_MONITOR_INSTALL_BODY` 가 그 패턴). + +### 참고할 기존 구현 + +- `build_account_modules_delete` (ops.rs) — 검증 → glob → 루프 → 카운트 보고. **가장 가까운 선례** +- `build_disk_history` (ops.rs) — 읽기 전용 조사 스크립트의 섹션 구성 방식 +- `build_local_backup` (ops.rs) — 웹루트+DB덤프를 로컬로 tar.gz. **Phase 2 에서 재사용** +- `CMS_DETECT` (ops.rs const) — CMS별 DB 이름 탐지. **Phase 1 의 DB 탐지에 그대로 활용** +- 계정 관리 UI: `app.rs` 의 `AcctTab` / 사이트 탭(`sel` 체크박스) / 모듈 탭(선택 삭제) + +--- + +## 2. 확정된 결정 (재논의 금지) + +| 항목 | 결정 | 이유 | +|---|---|---| +| 삭제 후 사이드바 로컬 기록 | **건드리지 않는다.** 로그로 안내만 | 로컬 기록(휴지통)과 서버 삭제의 경계를 흐리면 사고가 난다 | +| 백업 실패 시 | **삭제 차단.** 무시 옵션 없음 | 무시 옵션은 결국 눌린다 | +| 백업 전 디스크 확인 | `df` 로 여유 확인, 부족하면 중단 | `v-backup-user` 는 계정 크기만큼 쓴다 | +| 일괄 삭제 | **한 번에 하나만** | 사고 규모를 키우지 않는다 | +| 서버 삭제 UI 위치 | 계정 관리 페이지의 **신규 `위험 작업` 탭** | 사이드바에 두면 휴지통과 혼동된다 | +| 조회 방식 | SSH (HestiaCP API 아님) | 파일 크기·DB 크기·DB 공유 검사는 API 로 못 한다 | + +### 용어 구분 (UI 문구에 그대로 사용) + +- 사이드바 🗑 `삭제` = **로컬 기록만** 휴지통으로. 서버 무관. (기존 기능, 손대지 않는다) +- 계정 관리 `서버에서 완전 삭제` = **서버의 파일·DB·HestiaCP 등록 제거.** 되돌릴 수 없다. + +--- + +## 3. Phase 1 — 점검 (읽기 전용) + +지워질 것을 먼저 보여준다. **아무것도 변경하지 않으므로 확인 모달 없이 즉시 실행**한다. + +### 3-1. `ops.rs` 신규 함수 + +```rust +/// 도메인 삭제 사전 점검 (SSH, sudo, 읽기 전용). +/// 지워질 대상과 크기, DB 이름, 그리고 그 DB 를 다른 도메인이 함께 쓰는지 조사한다. +pub fn build_domain_delete_probe(s: &Settings, account: &str, domain: &str) -> Result + +/// 계정 삭제 사전 점검 (SSH, sudo, 읽기 전용). +pub fn build_account_delete_probe(s: &Settings, account: &str) -> Result +``` + +두 함수 공통 검증(맨 앞에서): + +```rust +let acct = account.trim(); +if !is_safe_name(acct) { return Err("계정 이름 형식 오류 (영숫자/._- 만)".into()); } +if is_protected_account(acct, s) { return Err(format!("보호 대상 계정입니다: {acct}")); } +``` + +도메인 쪽은 추가로: + +```rust +let da = to_ascii_domain(domain); +if !is_safe_name(&da) { return Err(format!("도메인 형식 오류: {domain}")); } +``` + +### 3-2. `ops.rs` 신규 헬퍼 — 보호 계정 + +```rust +/// 삭제하면 서버가 죽는 계정. 실수로도 지나가지 못하게 입력 검증 단계에서 막는다. +fn is_protected_account(acct: &str, s: &Settings) -> bool { + const SYSTEM: &[&str] = &[ + "admin", "root", "www-data", "mysql", "hestiaweb", "hestia", + "daemon", "bin", "sys", "nobody", "sshd", "systemd-network", + ]; + let a = acct.trim(); + if a.is_empty() { return true; } + if SYSTEM.iter().any(|p| p.eq_ignore_ascii_case(a)) { return true; } + // 설정의 서버 SSH 계정 자신 — 지우면 지금 쓰는 접속 경로가 사라진다 + if !s.ssh_user.trim().is_empty() && s.ssh_user.trim().eq_ignore_ascii_case(a) { return true; } + false +} +``` + +### 3-3. 도메인 점검 셸 (raw) + +`{u}` = 계정, `{d}` = 퓨니코드 도메인. 둘 다 `sq()` 로 감싸 주입한다. + +```sh +set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER={u} +DOMAIN={d} +WR="/home/$VUSER/web/$DOMAIN/public_html" +WEBDIR="/home/$VUSER/web/$DOMAIN" + +echo "===== 삭제 사전 점검: $VUSER / $DOMAIN =====" +echo + +echo "[1] HestiaCP 등록 상태" +if $VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + echo " ✓ 웹도메인 등록됨" +else + echo " ✗ HestiaCP 에 이 웹도메인이 없습니다 — 삭제할 것이 없거나 계정/도메인이 틀렸습니다" +fi +$VBIN/v-list-dns-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN" \ + && echo " · DNS 도메인 있음 (함께 삭제됩니다)" || echo " · DNS 도메인 없음" +$VBIN/v-list-mail-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN" \ + && echo " · 메일 도메인 있음 (메일함까지 함께 삭제됩니다)" || echo " · 메일 도메인 없음" +echo + +echo "[2] 파일" +if [ -d "$WEBDIR" ]; then + echo " 경로 $WEBDIR" + echo " 크기 $(du -sh "$WEBDIR" 2>/dev/null | cut -f1)" + echo " 파일수 $(find "$WEBDIR" -type f 2>/dev/null | wc -l)" +else + echo " (디렉터리 없음: $WEBDIR)" +fi +echo + +echo "[3] 데이터베이스" +DB="" +if [ -f "$WEBDIR/pythonapp/.env" ]; then + DB="$(grep -oP 'DATABASE_URL=.*/\K[^?[:space:]]+' "$WEBDIR/pythonapp/.env" 2>/dev/null | head -1)" + [ -z "$DB" ] && DB="$(grep -oP '^DB_NAME=\K.*' "$WEBDIR/pythonapp/.env" 2>/dev/null | head -1)" +fi +[ -z "$DB" ] && [ -f "$WR/wp-config.php" ] && DB="$(grep -oP "DB_NAME'?\s*,\s*'\K[^']+" "$WR/wp-config.php" 2>/dev/null | head -1)" +for CF in "$WR/files/config/db.config.php" "$WR/config/db.config.php"; do + [ -n "$DB" ] && break + [ -f "$CF" ] || continue + DB="$(grep -oP "'database'\s*=>\s*'\K[^']+" "$CF" 2>/dev/null | head -1)" + [ -z "$DB" ] && DB="$(grep -oP "db_database'?\s*[=,]\s*[\"']\K[^\"']+" "$CF" 2>/dev/null | head -1)" +done +[ -z "$DB" ] && [ -f "$WR/data/dbconfig.php" ] && DB="$(grep -oP "mysql_db'?\s*[,=]\s*[\"']\K[^\"']+" "$WR/data/dbconfig.php" 2>/dev/null | head -1)" + +if [ -z "$DB" ]; then + echo " DB 를 찾지 못했습니다 (설정파일 미검출) — DB 는 삭제되지 않습니다" + echo "HM_DB=" + echo "HM_DBSHARED=0" +else + SZ=$(mysql -N -B -e "SELECT IFNULL(ROUND(SUM(data_length+index_length)/1024/1024,1),0) FROM information_schema.tables WHERE table_schema='$DB'" 2>/dev/null) + EX=$(mysql -N -B -e "SHOW DATABASES LIKE '$DB'" 2>/dev/null | grep -c .) + echo " 이름 $DB 크기 ${SZ:-?}MB 실제존재 $([ "$EX" = 1 ] && echo 예 || echo '아니오(설정만 남음)')" + if $VBIN/v-list-databases "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DB"; then + echo " HestiaCP 등록 ✓ (v-delete-database 로 정상 삭제 가능)" + else + echo " HestiaCP 등록 ✗ — 패널 밖에서 수동 생성된 DB 입니다" + echo " → v-delete-database 로는 안 지워집니다. 이 경우 DB 는 건너뜁니다(수동 처리)" + fi + + echo + echo " [공유 검사] 이 DB 를 다른 도메인도 쓰고 있는지 (중요)" + HITS=0; SHARED="" + shopt -s nullglob + for OWR in /home/"$VUSER"/web/*/public_html; do + [ -d "$OWR" ] || continue + OD="$(basename "$(dirname "$OWR")")" + if grep -rqsF "$DB" "$OWR/wp-config.php" "$OWR/data/dbconfig.php" \ + "$OWR/config/db.config.php" "$OWR/files/config/db.config.php" \ + "$(dirname "$OWR")/pythonapp/.env" 2>/dev/null; then + HITS=$((HITS+1)) + [ "$OD" != "$DOMAIN" ] && SHARED="$SHARED $OD" + fi + done + if [ -n "$SHARED" ]; then + echo " ✗✗ 다른 도메인도 이 DB 를 참조합니다:$SHARED" + echo " → DB 를 지우면 그 사이트들이 죽습니다. DB 삭제를 건너뛰어야 합니다." + echo "HM_DBSHARED=1" + else + echo " ✓ 이 도메인만 사용 (참조 $HITS 곳)" + echo "HM_DBSHARED=0" + fi + echo "HM_DB=$DB" +fi +echo + +echo "[4] 백업 여유" +df -h /backup 2>/dev/null | tail -1 | sed 's/^/ /' || df -h / | tail -1 | sed 's/^/ /' +echo +echo "===== 점검 끝 — 삭제하려면 백업 후 '서버에서 완전 삭제' =====" +``` + +**`HM_DB=` / `HM_DBSHARED=` 줄의 목적**: UI 가 로그 출력에서 이 값을 파싱해 +삭제 단계에 넘긴다(§5-3). 형식을 바꾸지 말 것. + +두 줄은 **모든 분기에서 반드시 출력**된다(DB 미검출 시에도 `HM_DB=` + `HM_DBSHARED=0`). +한쪽만 출력되면 이전 점검의 값이 남아 **다른 도메인의 DB 를 지울 수 있다.** +UI 쪽도 점검을 시작할 때 `danger_db` / `danger_db_shared` 를 먼저 리셋한다(§3-5). + +### 3-4. 계정 점검 셸 (raw) + +```sh +set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER={u} + +echo "===== 계정 삭제 사전 점검: $VUSER =====" +echo +if ! $VBIN/v-list-users plain 2>/dev/null | awk '{print $1}' | grep -qx "$VUSER"; then + echo " ✗ HestiaCP 계정이 아닙니다: $VUSER — 중단하세요" + echo "===== 점검 끝 =====" + exit 0 +fi +echo "[1] 계정 요약" +$VBIN/v-list-user "$VUSER" plain 2>/dev/null | sed 's/^/ /' +echo +echo "[2] 웹 도메인 (전부 삭제됩니다)" +$VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print " "$1}' | sort +echo " 총 $($VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | grep -c .) 개" +echo +echo "[3] 데이터베이스 (전부 삭제됩니다)" +$VBIN/v-list-databases "$VUSER" plain 2>/dev/null | awk '{print " "$1}' | sort +echo +echo "[4] DNS / 메일 도메인" +echo " DNS $($VBIN/v-list-dns-domains "$VUSER" plain 2>/dev/null | grep -c .) 개" +echo " 메일 $($VBIN/v-list-mail-domains "$VUSER" plain 2>/dev/null | grep -c .) 개 (메일함 포함 삭제)" +echo +echo "[5] 디스크" +[ -d "/home/$VUSER" ] && echo " /home/$VUSER $(du -sh "/home/$VUSER" 2>/dev/null | cut -f1)" +echo +echo "[6] 백업 여유" +df -h /backup 2>/dev/null | tail -1 | sed 's/^/ /' || df -h / | tail -1 | sed 's/^/ /' +echo +echo "※ v-delete-user 는 도메인·DB·메일·크론·/home 을 모두 지웁니다. 되돌릴 수 없습니다." +echo "===== 점검 끝 =====" +``` + +### 3-5. UI — `위험 작업` 탭 신설 + +`app.rs`: + +1. `enum AcctTab { Sites, Modules, Notes }` → **`Danger` 추가** +2. 탭 라벨 배열에 `(AcctTab::Danger, " ⚠ 위험 작업 ")` 추가 +3. `App` 에 필드 추가: + +```rust +/// 위험작업 탭: 대상 도메인 (빈 문자열이면 계정 전체 작업만 가능) +danger_domain: String, +/// 확인 타이핑 입력값 +danger_confirm_text: String, +/// 점검 완료 여부 — 백업/삭제 버튼의 활성 조건 +danger_probed: bool, +/// 백업 완료 여부 — 삭제 버튼의 활성 조건 +danger_backed_up: bool, +/// 점검에서 파싱한 DB 이름 (빈 문자열 = DB 없음/미검출) +danger_db: String, +/// 점검에서 파싱한 DB 공유 여부 — true 면 DB 삭제를 막는다 +danger_db_shared: bool, +``` + +전부 `App::new()` 에서 `String::new()` / `false` 로 초기화. + +4. 탭 내용: + +``` +[대상] 도메인 콤보박스 (계정 관리 중인 계정의 도메인 목록에서 선택, "계정 전체" 항목 포함) + +── 1단계: 점검 ────────────────────────────── + [ 점검 (읽기 전용) ] ← 항상 활성 + 결과는 아래 로그창에 표시됩니다. + +── 2단계: 백업 ────────────────────────────── + [ 백업 ] ← danger_probed 일 때만 활성 + 도메인: 파일+DB를 로컬로 / 계정 전체: 서버의 v-backup-user + +── 3단계: 서버에서 완전 삭제 ──────────────── + ⚠ 되돌릴 수 없습니다. + 확인을 위해 <도메인 또는 계정명> 을 입력하세요: [ ] + [ 서버에서 완전 삭제 ] ← probed && backed_up && 입력일치 일 때만 활성 +``` + +- 3단계 버튼은 빨간색: `egui::Color32::from_rgb(200, 70, 70)` 계열 +- 계정 전체 삭제는 이 탭 맨 아래 `egui::CollapsingHeader::new("계정 전체 삭제")` + 안에 넣어 한 번 더 숨긴다(기본 닫힘) +- 대상(도메인/계정)이 바뀌면 `danger_probed`, `danger_backed_up`, + `danger_confirm_text`, `danger_db`, `danger_db_shared` 를 **모두 리셋**한다. + (다른 도메인의 점검 결과로 삭제되는 것을 막는다 — 반드시 구현) +- **점검 버튼을 누를 때도** 같은 5개를 먼저 리셋한 뒤 실행한다. + 같은 대상을 재점검했는데 두 번째 출력에 `HM_DB=` 가 없으면 첫 번째 값이 살아남는다. + +5. 점검은 확인 모달 없이 `ops::spawn` 으로 직접 실행(읽기 전용). 기존 패턴: + +```rust +match ops::build_domain_delete_probe(&self.store.settings, &acct, &dom) { + 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}")); } +} +``` + +### 3-6. Phase 1 테스트 + +`ops.rs` 의 `mod tests` 에 추가: + +```rust +#[test] +fn delete_probe_jobs_valid_bash_and_validation() { + let st = Settings { ssh_host: "1.2.3.4".into(), ssh_user: "tong".into(), ssh_pass: "pw".into(), ..Default::default() }; + for job in [ + build_domain_delete_probe(&st, "rokmc", "example.com").unwrap(), + build_account_delete_probe(&st, "rokmc").unwrap(), + ] { + let out = std::process::Command::new("bash").args(["-n", "-c", &job.script]).output().expect("bash"); + assert!(out.status.success(), "bash 오류({}):\n{}", job.title, String::from_utf8_lossy(&out.stderr)); + } + // 점검은 읽기 전용 — 삭제 명령이 절대 들어가면 안 된다 + for job in [ + build_domain_delete_probe(&st, "rokmc", "example.com").unwrap(), + build_account_delete_probe(&st, "rokmc").unwrap(), + ] { + for bad in ["v-delete-", "DROP DATABASE", "rm -rf"] { + assert!(!job.script.contains(bad), "점검에 파괴 명령 포함: {bad}"); + } + } + // 보호 계정 거부 + for bad in ["admin", "root", "www-data", "mysql", "hestiaweb", "tong"] { // tong = ssh_user 자신 + assert!(build_account_delete_probe(&st, bad).is_err(), "보호 계정 통과: {bad}"); + assert!(build_domain_delete_probe(&st, bad, "example.com").is_err(), "보호 계정 통과: {bad}"); + } + // 주입 거부 + assert!(build_account_delete_probe(&st, "../etc").is_err()); + assert!(build_domain_delete_probe(&st, "rokmc", "ex'ample.com").is_err()); + assert!(build_domain_delete_probe(&st, "rokmc", "../../etc").is_err()); +} +``` + +### DoD (Phase 1) + +- [ ] `cargo build` 경고 없음, `cargo test` 전부 통과 +- [ ] 위 테스트 통과 (특히 **점검 스크립트에 `v-delete-`/`DROP`/`rm -rf` 가 없음**) +- [ ] 위험 작업 탭에서 점검 버튼이 동작하고 로그에 섹션 [1]~[4] 가 출력됨 +- [ ] 대상 도메인을 바꾸면 점검/백업 완료 상태가 리셋됨 + +--- + +## 4. Phase 2 — 백업 + +### 4-1. `ops.rs` 신규 함수 + +```rust +/// 계정 전체 백업 (SSH, sudo). HestiaCP 표준 백업 — 메일·크론·DNS 까지 포함한다. +/// 도메인 단위 백업은 기존 build_local_backup 을 재사용하므로 신규 함수가 없다. +pub fn build_account_backup(s: &Settings, account: &str) -> Result +``` + +검증은 Phase 1 과 동일(`is_safe_name` + `is_protected_account`). + +```sh +set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER={u} +echo "===== 계정 백업: $VUSER =====" + +# 계정 크기 vs /backup 여유 — 백업 도중 디스크가 차면 서버 전체가 위험하다 +NEED=$(du -sm "/home/$VUSER" 2>/dev/null | cut -f1); [ -z "$NEED" ] && NEED=0 +BD=/backup; [ -d "$BD" ] || BD=/ +FREE=$(df -Pm "$BD" 2>/dev/null | awk 'NR==2{print $4}'); [ -z "$FREE" ] && FREE=0 +echo " 계정 크기 약 ${NEED}MB · $BD 여유 ${FREE}MB" +if [ "$FREE" -lt $((NEED + 1024)) ] 2>/dev/null; then + echo " ✗ 여유 공간이 부족합니다 (필요 ${NEED}MB + 여유분 1GB) — 백업을 중단합니다" + echo " 공간을 확보한 뒤 다시 시도하세요. 백업 없이 삭제하지 마십시오." + exit 1 +fi + +$VBIN/v-backup-user "$VUSER" +RC=$? +echo +if [ "$RC" = 0 ]; then + echo " ✓ 백업 완료" + ls -lh /backup/"$VUSER".*.tar 2>/dev/null | tail -3 | sed 's/^/ /' +else + echo " ✗ 백업 실패 (v-backup-user=$RC) — 삭제로 넘어가지 마십시오" + exit 1 +fi +echo "===== 백업 끝 =====" +``` + +### 4-2. UI + +- 대상이 **도메인**이면 → 기존 `ops::build_local_backup(&settings, &[(acct, dom)], &dest)` 호출 + (`dest` 는 기존 백업 경로 로직을 그대로 사용) +- 대상이 **계정 전체**면 → `build_account_backup` +- 성공(`last_ok == Some(true)`) 시 `danger_backed_up = true`. 실패 시 `false` 유지. + +### DoD (Phase 2) + +- [ ] 백업 실패 시 `danger_backed_up` 이 true 가 되지 않아 3단계 버튼이 계속 비활성 +- [ ] `bash -n` 통과, 보호 계정 거부 테스트 추가 + +--- + +## 5. Phase 3 — 도메인 삭제 (파괴적) + +### 5-1. `ops.rs` 신규 함수 + +```rust +/// 도메인 완전 삭제 (SSH, sudo, 되돌릴 수 없음 — 확인 모달 필수). +/// `db`: 함께 삭제할 DB 이름. 빈 문자열이면 DB 를 건드리지 않는다. +/// 호출 전에 백업이 완료되어 있어야 한다(UI 가 강제). +pub fn build_domain_delete(s: &Settings, account: &str, domain: &str, db: &str) -> Result +``` + +검증: + +```rust +let acct = account.trim(); +if !is_safe_name(acct) { return Err("계정 이름 형식 오류".into()); } +if is_protected_account(acct, s) { return Err(format!("보호 대상 계정입니다: {acct}")); } +let da = to_ascii_domain(domain); +if !is_safe_name(&da) { return Err(format!("도메인 형식 오류: {domain}")); } +let dbn = db.trim(); +if !dbn.is_empty() && !is_safe_name(dbn) { return Err(format!("DB 이름 형식 오류: {db}")); } +``` + +### 5-2. 셸 (raw) + +```sh +set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER={u} +DOMAIN={d} +DB={db} +LOG=/var/log/hostmover/deletions.log +mkdir -p /var/log/hostmover +TS=$(date '+%F %T') + +echo "===== 서버에서 완전 삭제: $VUSER / $DOMAIN =====" + +# 등록 확인 — 없으면 아무것도 하지 않는다 +if ! $VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + echo " ✗ HestiaCP 에 웹도메인이 없습니다 — 중단 (계정/도메인을 확인하세요)" + exit 1 +fi + +SZ=$(du -sh "/home/$VUSER/web/$DOMAIN" 2>/dev/null | cut -f1) +echo " 대상 파일 ${SZ:-?} · DB ${DB:-없음}" +echo + +echo "[1] 웹도메인 삭제 (파일·nginx/apache 설정·SSL 포함)" +$VBIN/v-delete-web-domain "$VUSER" "$DOMAIN" +RC=$? +if [ "$RC" != 0 ]; then + echo " ✗ 실패 (v-delete-web-domain=$RC) — 이후 단계를 중단합니다" + echo "$TS FAIL domain $VUSER/$DOMAIN v-delete-web-domain=$RC" >> "$LOG" + exit 1 +fi +echo " ✓ 완료" + +echo "[2] DNS 도메인" +if $VBIN/v-list-dns-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + $VBIN/v-delete-dns-domain "$VUSER" "$DOMAIN" && echo " ✓ 삭제" || echo " ✗ 실패(수동 확인 필요)" +else + echo " · 없음" +fi + +echo "[3] 메일 도메인" +if $VBIN/v-list-mail-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + $VBIN/v-delete-mail-domain "$VUSER" "$DOMAIN" && echo " ✓ 삭제" || echo " ✗ 실패(수동 확인 필요)" +else + echo " · 없음" +fi + +echo "[4] 데이터베이스" +if [ -z "$DB" ]; then + echo " · 건너뜀 (대상 없음 — 미검출이거나 다른 도메인과 공유 중)" +else + if $VBIN/v-list-databases "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DB"; then + $VBIN/v-delete-database "$VUSER" "$DB" && echo " ✓ $DB 삭제" || echo " ✗ $DB 삭제 실패(수동 확인 필요)" + else + echo " ✗ $DB 는 HestiaCP 에 등록되어 있지 않습니다 — 건너뜁니다" + echo " 패널 밖에서 만든 DB 는 자동 삭제하지 않습니다. 필요하면 직접 처리하세요." + fi +fi +echo + +echo "[5] 삭제 검증" +FAILED=0 +$VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN" \ + && { echo " ✗ 패널에 아직 남아 있음"; FAILED=1; } || echo " ✓ 패널에서 사라짐" +[ -d "/home/$VUSER/web/$DOMAIN" ] \ + && { echo " ✗ 디렉터리 잔존: /home/$VUSER/web/$DOMAIN"; FAILED=1; } || echo " ✓ 디렉터리 제거됨" +if [ -n "$DB" ]; then + if mysql -N -B -e "SHOW DATABASES LIKE '$DB'" 2>/dev/null | grep -q .; then + echo " ✗ DB 잔존: $DB"; FAILED=1 + else + echo " ✓ DB 제거됨" + fi +fi + +echo "$TS DELETE domain $VUSER/$DOMAIN files=${SZ:-?} db=${DB:-none} verify=$([ "$FAILED" = 0 ] && echo OK || echo INCOMPLETE)" >> "$LOG" +echo +if [ "$FAILED" = 0 ]; then + echo "===== 삭제 완료 =====" +else + echo "===== 삭제 불완전 — 위 ✗ 항목을 수동 확인하세요 =====" +fi +echo "※ hostmover 사이드바의 기록은 그대로 남아 있습니다. 필요하면 🗑 로 정리하세요." +echo "※ 이력: $LOG" +``` + +### 5-3. UI — DB 이름/공유 여부 전달 + +점검 로그에서 파싱한다. 로그 수신 지점(`self.log.push(...)` 가 일어나는 곳)에서: + +```rust +// 삭제 점검 출력에서 DB 정보를 뽑아 3단계로 넘긴다 (형식은 SPEC §3-3 고정) +if let Some(v) = line.strip_prefix("HM_DB=") { self.danger_db = v.trim().to_string(); } +if let Some(v) = line.strip_prefix("HM_DBSHARED=") { self.danger_db_shared = v.trim() == "1"; } +``` + +삭제 호출 시: + +```rust +// 공유 중이면 DB 를 절대 넘기지 않는다 (다른 사이트가 죽는다) +let db = if self.danger_db_shared { "" } else { self.danger_db.as_str() }; +ops::build_domain_delete(&self.store.settings, &acct, &dom, db) +``` + +UI 에 공유 상태를 눈에 보이게 표시한다: + +- 공유 아님: `DB rokmc_wp 도 함께 삭제됩니다` +- 공유 중: 주황색 `⚠ 이 DB 는 다른 도메인도 사용 중 — DB 는 삭제하지 않습니다` +- 미검출: `DB 를 찾지 못해 삭제하지 않습니다` + +### 5-4. 전용 확인 모달 + +기존 `eond_confirm_modal` 은 **재사용하지 않는다.** 제목이 "설치 작업 확인" 으로 고정이고 +예/아니오만 있어 파괴 작업에 부적합하다. + +`app.rs` 에 신규: + +```rust +/// 되돌릴 수 없는 삭제 전용 확인 모달. 대상 이름을 정확히 타이핑해야 실행이 열린다. +danger_confirm: Option<(ops::Job, String)>, // (작업, 타이핑해야 하는 문자열) + +fn danger_confirm_modal(&mut self, ctx: &egui::Context) { ... } +``` + +요구사항: + +- 창 제목 `⚠ 되돌릴 수 없는 삭제` +- 본문에 **서버 호스트**(`settings.ssh_host` 또는 `hestia_host`), 대상, `job.note` 표시 +- 빨간 경고문: `이 작업은 되돌릴 수 없습니다. 백업에서 복원하는 것만 가능합니다.` +- 타이핑 입력칸. `danger_confirm_text.trim() == 기대문자열` 일 때만 실행 버튼 활성 +- 취소 시 `danger_confirm = None`, `danger_confirm_text.clear()` +- 실행 시 기존 패턴대로 `ops::spawn` +- `update()` 에서 `eond_confirm_modal` 을 호출하는 곳 옆에 `danger_confirm_modal` 도 호출 + +### 5-5. Phase 3 테스트 + +```rust +#[test] +fn domain_delete_job_safety() { + let st = Settings { ssh_host: "1.2.3.4".into(), ssh_user: "tong".into(), ssh_pass: "pw".into(), ..Default::default() }; + let j = build_domain_delete(&st, "rokmc", "example.com", "rokmc_wp").unwrap(); + let out = std::process::Command::new("bash").args(["-n", "-c", &j.script]).output().expect("bash"); + assert!(out.status.success(), "bash 오류:\n{}", String::from_utf8_lossy(&out.stderr)); + // 정석 경로를 쓸 것 — 웹루트 직접 rm 금지 + assert!(j.script.contains("v-delete-web-domain")); + assert!(!j.script.contains("rm -rf /home"), "웹루트 직접 삭제 금지"); + assert!(j.script.contains("v-delete-database")); + // 검증·이력이 빠지지 않았는지 + assert!(j.script.contains("deletions.log")); + // DB 를 안 넘기면 DB 삭제 시도가 없어야 한다 + let j2 = build_domain_delete(&st, "rokmc", "example.com", "").unwrap(); + assert!(j2.script.contains("DB=''") || j2.script.contains("DB=\"\""), "빈 DB 주입 형식 확인"); + // 보호 계정·주입 거부 + for bad in ["admin", "root", "tong"] { + assert!(build_domain_delete(&st, bad, "example.com", "").is_err()); + } + assert!(build_domain_delete(&st, "rokmc", "example.com", "db'x").is_err()); + assert!(build_domain_delete(&st, "rokmc", "../etc", "").is_err()); +} +``` + +### DoD (Phase 3) + +- [ ] 위 테스트 통과 +- [ ] 백업 미완료 상태에서 3단계 버튼이 비활성 +- [ ] 타이핑이 정확히 일치할 때만 실행 버튼 활성 +- [ ] DB 공유 상태에서 삭제하면 스크립트에 DB 가 빈 값으로 전달됨(수동 확인) +- [ ] **테스트 계정으로 실제 1회 검증**(아래 §7) 전에는 실사용 금지 안내를 커밋 메시지에 남긴다 + +--- + +## 6. Phase 4 — 계정 삭제 (가장 위험) + +### 6-1. `ops.rs` + +```rust +/// 계정 완전 삭제 (SSH, sudo, 되돌릴 수 없음). 도메인·DB·메일·크론·/home 전부. +pub fn build_account_delete(s: &Settings, account: &str) -> Result +``` + +검증은 Phase 1 과 동일 + **`v-list-users` 에 있는지 원격에서 재확인**(셸 안에서). + +```sh +set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER={u} +LOG=/var/log/hostmover/deletions.log +mkdir -p /var/log/hostmover +TS=$(date '+%F %T') + +echo "===== 계정 완전 삭제: $VUSER =====" + +# 원격에서도 한 번 더 막는다 (Rust 검증을 우회한 경우 대비) +case "$VUSER" in + admin|root|www-data|mysql|hestia|hestiaweb|daemon|bin|sys|nobody) + echo " ✗ 보호 대상 계정입니다 — 중단"; exit 1 ;; +esac +if ! $VBIN/v-list-users plain 2>/dev/null | awk '{print $1}' | grep -qx "$VUSER"; then + echo " ✗ HestiaCP 계정이 아닙니다 — 중단"; exit 1 +fi + +ND=$($VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | grep -c .) +SZ=$(du -sh "/home/$VUSER" 2>/dev/null | cut -f1) +echo " 도메인 ${ND}개 · ${SZ:-?}" +echo + +echo "[1] v-delete-user 실행" +$VBIN/v-delete-user "$VUSER" +RC=$? +[ "$RC" = 0 ] && echo " ✓ 완료" || echo " ✗ 실패(v-delete-user=$RC)" +echo + +echo "[2] 삭제 검증" +FAILED=0 +$VBIN/v-list-users plain 2>/dev/null | awk '{print $1}' | grep -qx "$VUSER" \ + && { echo " ✗ 패널에 계정이 남아 있음"; FAILED=1; } || echo " ✓ 패널에서 사라짐" +[ -d "/home/$VUSER" ] \ + && { echo " ✗ /home/$VUSER 잔존"; FAILED=1; } || echo " ✓ /home 제거됨" +id "$VUSER" >/dev/null 2>&1 \ + && { echo " ✗ 시스템 사용자 잔존"; FAILED=1; } || echo " ✓ 시스템 사용자 제거됨" + +echo "$TS DELETE user $VUSER domains=$ND size=${SZ:-?} rc=$RC verify=$([ "$FAILED" = 0 ] && echo OK || echo INCOMPLETE)" >> "$LOG" +echo +[ "$FAILED" = 0 ] && echo "===== 삭제 완료 =====" || echo "===== 삭제 불완전 — 위 ✗ 를 수동 확인 =====" +echo "※ hostmover 사이드바 기록은 남아 있습니다." +echo "※ 이력: $LOG" +``` + +### 6-2. UI + +- `CollapsingHeader("계정 전체 삭제")` 안 (기본 닫힘) +- 타이핑 확인 문자열 = **계정명** +- 버튼 문구 `계정을 서버에서 완전 삭제` +- 백업(`v-backup-user`) 완료 필수 + +### 6-3. 테스트 + +Phase 3 과 동일 형태로: `bash -n`, `v-delete-user` 포함, `deletions.log` 포함, +보호 계정 전부 거부(`admin`/`root`/`mysql`/`hestiaweb`/`tong`), 주입 거부. +추가로 **원격 측 보호 case 문이 스크립트에 들어있는지** 확인: + +```rust +assert!(j.script.contains("보호 대상 계정입니다"), "원격 측 2차 방어 누락"); +``` + +### DoD (Phase 4) + +- [ ] 위 테스트 통과, 전체 `cargo test` 통과 +- [ ] 릴리스 빌드 경고 없음 +- [ ] README 또는 `docs/` 에 이 기능 문서 1개 추가 (`docs/account-delete.md`, + 내용: 두 종류 삭제의 차이 · 3단계 절차 · DB 공유 함정 · 복원 방법) + +--- + +## 7. 실서버 검증 절차 (사람이 수행 — Codex 는 하지 않는다) + +Phase 3 완료 후, 실사용 전에 반드시 한 번: + +1. HestiaCP 에 테스트 계정 `hmtest` + 도메인 `hmtest.example.com` 생성, WordPress 설치 +2. 위험 작업 탭에서 점검 → 백업 → 삭제 +3. 확인: 패널에서 도메인 사라짐 · `/home/hmtest/web/...` 없음 · DB 없음 · + `/var/log/hostmover/deletions.log` 에 기록 남음 · `/backup/hmtest.*.tar` 존재 +4. `v-restore-user` 로 복원이 되는지도 확인(백업이 실제로 쓸 수 있는지) + +--- + +## 8. 흔한 함정 (미리 알림) + +1. **`format!` 안의 셸 중괄호** — `${VAR}`, `awk '{print}'` 등이 전부 깨진다. + 중괄호가 많은 스크립트는 `const` 로 빼고 `head + BODY` 로 조립한다. +2. **`v-list-*` 의 `plain` 출력은 탭 구분**이고 첫 필드가 이름이다. `grep -qx` 로 + 정확히 일치 비교할 것 — `grep -q` 만 쓰면 `example.com` 이 `sub.example.com` 에도 걸린다. +3. **`du -sh` 는 파일이 지워진 뒤엔 못 쓴다.** 크기는 삭제 **전에** 구해 변수에 담는다. +4. **DB 탐지는 웹도메인 삭제 전에** 해야 한다(파일이 사라지면 설정을 못 읽는다). + 그래서 DB 이름은 점검 단계에서 파싱해 인자로 넘긴다. +5. `set -e` 를 쓰면 `grep -q` 실패에 스크립트가 죽는다. 조사/삭제 스크립트는 `set +e` 로 + 시작하고 실패를 직접 검사한다(기존 진단 함수들과 동일). +6. UI 상태 리셋 누락 — 대상을 바꿨는데 이전 점검 결과가 남아 있으면 **다른 도메인의 DB 를 + 지울 수 있다.** §3-5 의 리셋을 반드시 넣는다. + +--- + +## 9. 최종 완료 기준 + +- [ ] Phase 1~4 각 DoD 통과, Phase 별 커밋 4개 이상 +- [ ] `cargo test` 전부 통과 / `cargo build --release` 경고 없음 +- [ ] 점검 스크립트에 파괴 명령이 **없음**(테스트로 강제) +- [ ] 삭제 스크립트가 `v-delete-*` 를 쓰고 웹루트를 직접 `rm -rf` 하지 **않음**(테스트로 강제) +- [ ] 보호 계정·주입 거부가 Rust 검증과 원격 셸 양쪽에 존재 +- [ ] `docs/account-delete.md` 작성 +- [ ] PR 본문에 §7 실서버 검증이 **아직 수행되지 않았음**을 명시 diff --git a/.claude/plans/2026-07-30-account-domain-delete.md b/.claude/plans/2026-07-30-account-domain-delete.md index c8b1f99..f919eed 100644 --- a/.claude/plans/2026-07-30-account-domain-delete.md +++ b/.claude/plans/2026-07-30-account-domain-delete.md @@ -219,7 +219,11 @@ DB 삭제는 `v-delete-database` 우선, 실패 시(패널에 등록 안 된 수 --- -## 7. 미확정 — 결정 필요 +## 7. 미확정 → **결정 완료** + +아래 4건은 모두 결정되어 개발 스펙에 반영됐다: +**[2026-07-30-account-domain-delete-SPEC.md](2026-07-30-account-domain-delete-SPEC.md) §2** (Codex 작업 지시서). +결정 내용은 그 문서가 기준이며, 여기 적힌 내용은 판단 근거로만 남긴다. 1. **삭제 후 사이드바 로컬 기록**: 자동 휴지통 이동 vs 매번 묻기. → 지금은 "매번 묻기"로 계획했다. 자동이 편하지만 경계가 흐려진다. From 1d7af3cda191ae09d465f67aa647471c41b4d343 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Thu, 30 Jul 2026 09:09:44 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EA=B3=84=EC=A0=95=C2=B7=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=82=AD=EC=A0=9C=20=EC=82=AC=EC=A0=84=20=EC=A0=90?= =?UTF-8?q?=EA=B2=80=EA=B3=BC=20=EC=9C=84=ED=97=98=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=ED=83=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 160 +++++++++++++++++++++++++++++++++++++- src/ops.rs | 221 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 380 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 9de7dfa..7cbd589 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,6 +73,13 @@ enum AcctTab { Sites, Modules, Notes, + Danger, +} + +/// 위험 작업의 비동기 실행 종류. 일반 작업 성공과 삭제 준비 상태를 분리한다. +#[derive(Clone, Copy, PartialEq)] +enum DangerRun { + Probe, } /// 비번 변경 실행 경로 — 화면마다 접속 방식이 다르다 @@ -414,6 +421,20 @@ pub struct App { acct_mods_status: String, /// 고객 메모 탭: 새 메모 입력 버퍼 acct_note_input: String, + /// 위험작업 탭: 대상 도메인 (빈 문자열이면 계정 전체) + danger_domain: String, + /// 삭제 전 확인 타이핑 입력값 + danger_confirm_text: String, + /// 현재 대상을 점검했는지 + danger_probed: bool, + /// 현재 대상을 백업했는지 + danger_backed_up: bool, + /// 점검에서 감지한 DB 이름 + danger_db: String, + /// 점검에서 감지한 DB가 다른 도메인과 공유되는지 + danger_db_shared: bool, + /// 현재 실행 중인 위험 작업 단계 + danger_running: Option, /// WordPress 관리자 비번 변경 모달 (열려 있으면 Some) wp_pw_dlg: Option, /// 생성일(HestiaCP DATE)을 이미 조회한 계정 집합(세션 중 중복조회 방지) @@ -512,6 +533,13 @@ impl App { acct_mod_domain: String::new(), acct_mods_status: String::new(), acct_note_input: String::new(), + danger_domain: String::new(), + danger_confirm_text: String::new(), + danger_probed: false, + danger_backed_up: false, + danger_db: String::new(), + danger_db_shared: false, + danger_running: None, wp_pw_dlg: None, site_dates_req: std::collections::HashSet::new(), site_perms_req: std::collections::HashSet::new(), @@ -1294,7 +1322,16 @@ impl App { ops::spawn(job, self.tx.clone(), move || ctx2.request_repaint()); } - /// 🧩 계정 관리 페이지 — 사이트(공유 캐시 필터) / 모듈(선택 삭제) + /// 대상 변경·재점검 때 이전 대상의 점검/백업/DB 상태를 모두 폐기한다. + fn reset_danger_progress(&mut self) { + self.danger_probed = false; + self.danger_backed_up = false; + self.danger_confirm_text.clear(); + self.danger_db.clear(); + self.danger_db_shared = false; + } + + /// 🧩 계정 관리 페이지 — 사이트 / 모듈 / 메모 / 위험 작업 fn account_modules_page(&mut self, ctx: &egui::Context, ci: usize) { let acct = match self.store.customers.get(ci) { Some(c) => c.name.clone(), @@ -1318,11 +1355,13 @@ impl App { let mut do_perm = false; let mut do_permfix = false; let mut do_wp_pw = false; + let mut do_danger_probe: Option = None; let mut alias_loads: Vec<(String, String)> = Vec::new(); let mut sel_all_sites: Option = None; let mut select_all = None; let mut select_deprecated = false; let has_rows = self.all_sites.iter().any(|r| r.account == acct); + let danger_domain_before = self.danger_domain.clone(); let frame = egui::Frame::central_panel(&ctx.style()).inner_margin(egui::Margin::symmetric(14, 12)); egui::CentralPanel::default().frame(frame).show(ctx, |ui| { ui.horizontal(|ui| { @@ -1340,6 +1379,7 @@ impl App { (AcctTab::Sites, " 사이트 (CMS·버전·업데이트) ".to_string()), (AcctTab::Modules, " 모듈 (선택 삭제) ".to_string()), (AcctTab::Notes, notes_label), + (AcctTab::Danger, " ⚠ 위험 작업 ".to_string()), ] { if ui.selectable_label(self.acct_tab == t, label).clicked() { self.acct_tab = t; } } @@ -1492,8 +1532,89 @@ impl App { } } } + AcctTab::Danger => { + ui.colored_label( + egui::Color32::from_rgb(220, 150, 60), + "서버의 파일·DB·HestiaCP 등록을 제거하는 작업입니다. 사이드바의 로컬 기록 삭제와 다릅니다.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label("대상:"); + ui.add_enabled_ui(!running, |ui| { + let selected = if self.danger_domain.is_empty() { "계정 전체" } else { self.danger_domain.as_str() }; + egui::ComboBox::from_id_salt("danger_delete_target") + .selected_text(selected) + .show_ui(ui, |ui| { + ui.selectable_value(&mut self.danger_domain, String::new(), "계정 전체"); + for d in &dom_names { + ui.selectable_value(&mut self.danger_domain, d.clone(), d); + } + }); + }); + }); + ui.add_space(10.0); + + let render_probe = |ui: &mut egui::Ui, label: &str| { + ui.heading("1단계: 점검"); + ui.separator(); + ui.label("지워질 파일·DB·DNS·메일과 DB 공유 여부를 읽기 전용으로 조사합니다."); + ui.add_space(4.0); + if ui.add_enabled(!running, btn_primary(format!("{} 점검 (읽기 전용)", ph::MAGNIFYING_GLASS))) + .on_hover_text(label) + .clicked() + { + true + } else { + false + } + }; + + if self.danger_domain.is_empty() { + egui::CollapsingHeader::new("계정 전체 삭제") + .default_open(false) + .show(ui, |ui| { + ui.colored_label( + egui::Color32::from_rgb(220, 110, 90), + format!("{acct} 계정의 도메인·DB·메일·크론·/home 전체가 대상입니다."), + ); + if render_probe(ui, "계정 전체 삭제 전 조사") { + do_danger_probe = Some(String::new()); + } + ui.add_space(12.0); + ui.heading("2단계: 백업"); + ui.separator(); + ui.add_enabled(false, egui::Button::new("백업 (점검 완료 후 활성)")); + ui.add_space(12.0); + ui.heading("3단계: 서버에서 완전 삭제"); + ui.separator(); + ui.colored_label(egui::Color32::from_rgb(220, 90, 90), "⚠ 되돌릴 수 없습니다."); + ui.add_enabled(false, egui::Button::new("계정을 서버에서 완전 삭제")); + }); + } else { + if render_probe(ui, "선택 도메인 삭제 전 조사") { + do_danger_probe = Some(self.danger_domain.clone()); + } + if self.danger_probed { + ui.colored_label(egui::Color32::from_rgb(80, 180, 110), "✓ 현재 대상 점검 완료"); + } else { + ui.label(egui::RichText::new("결과는 아래 로그창에 표시됩니다.").weak()); + } + ui.add_space(12.0); + ui.heading("2단계: 백업"); + ui.separator(); + ui.add_enabled(false, egui::Button::new("백업 (점검 완료 후 활성)")); + ui.add_space(12.0); + ui.heading("3단계: 서버에서 완전 삭제"); + ui.separator(); + ui.colored_label(egui::Color32::from_rgb(220, 90, 90), "⚠ 되돌릴 수 없습니다."); + ui.add_enabled(false, egui::Button::new("서버에서 완전 삭제")); + } + } } }); + if self.danger_domain != danger_domain_before { + self.reset_danger_progress(); + } if let Some(v) = sel_all_sites { for r in self.all_sites.iter_mut().filter(|r| r.account == acct) { r.sel = v; } } if let Some(v) = select_all { for m in &mut self.acct_mods { m.2 = v; } } if select_deprecated { for m in &mut self.acct_mods { m.2 = is_deprecated_module(&m.0); } } @@ -1523,6 +1644,29 @@ impl App { .collect(); self.open_wp_pw_dialog(WpPwScope::Accounts, targets, "admin".to_string()); } + if let Some(domain) = do_danger_probe { + self.reset_danger_progress(); + let built = if domain.is_empty() { + ops::build_account_delete_probe(&self.store.settings, &acct) + } else { + ops::build_domain_delete_probe(&self.store.settings, &acct, &domain) + }; + match built { + Ok(job) => { + self.running = true; + self.last_ok = None; + self.danger_running = Some(DangerRun::Probe); + 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 do_scan { self.site_dates_req.remove(&acct); self.site_perms_req.remove(&acct); self.rescan_account_into_all(acct.clone(), ctx); } if do_load_mods { self.load_account_modules(ci, ctx); } if do_scan_sel { @@ -2100,6 +2244,14 @@ impl App { while let Ok(msg) = self.rx.try_recv() { match msg { LogMsg::Line(l) => { + if self.danger_running == Some(DangerRun::Probe) { + if let Some(v) = l.strip_prefix("HM_DB=") { + self.danger_db = v.trim().to_string(); + } + if let Some(v) = l.strip_prefix("HM_DBSHARED=") { + self.danger_db_shared = v.trim() == "1"; + } + } self.log.push(l); if self.log.len() > 1000 { let cut = self.log.len() - 1000; @@ -2110,6 +2262,10 @@ impl App { self.running = false; self.last_ok = Some(ok); self.status = if ok { "성공".into() } else { "실패 (로그 확인)".into() }; + if self.danger_running.take() == Some(DangerRun::Probe) { + self.danger_probed = ok; + self.danger_backed_up = false; + } // 업데이트 작업 완료 → 선택 사이트 자동 재스캔 예약 (성공 시) if ok && !self.pending_rescan.is_empty() { self.pending_rescan_ready = true; @@ -2609,6 +2765,8 @@ impl App { self.acct_mods_status.clear(); self.acct_mods_for = Some(ci); self.acct_note_input.clear(); + self.danger_domain.clear(); + self.reset_danger_progress(); self.view = MainView::AccountModules(ci); } diff --git a/src/ops.rs b/src/ops.rs index 7168d87..468475a 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -2463,6 +2463,187 @@ pub fn resolve_a(domain: &str) -> String { "-".into() } +/// 삭제하면 서버가 손상될 수 있는 계정. 입력 단계에서 먼저 막는다. +fn is_protected_account(acct: &str, s: &Settings) -> bool { + const SYSTEM: &[&str] = &[ + "admin", "root", "www-data", "mysql", "hestiaweb", "hestia", "daemon", "bin", "sys", + "nobody", "sshd", "systemd-network", + ]; + let a = acct.trim(); + if a.is_empty() { return true; } + if SYSTEM.iter().any(|p| p.eq_ignore_ascii_case(a)) { return true; } + !s.ssh_user.trim().is_empty() && s.ssh_user.trim().eq_ignore_ascii_case(a) +} + +const DOMAIN_DELETE_PROBE_BODY: &str = r#"set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER=__HM_USER__ +DOMAIN=__HM_DOMAIN__ +WR="/home/$VUSER/web/$DOMAIN/public_html" +WEBDIR="/home/$VUSER/web/$DOMAIN" + +echo "===== 삭제 사전 점검: $VUSER / $DOMAIN =====" +echo + +echo "[1] HestiaCP 등록 상태" +if $VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + echo " ✓ 웹도메인 등록됨" +else + echo " ✗ HestiaCP 에 이 웹도메인이 없습니다 — 삭제할 것이 없거나 계정/도메인이 틀렸습니다" +fi +$VBIN/v-list-dns-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN" \ + && echo " · DNS 도메인 있음 (함께 삭제됩니다)" || echo " · DNS 도메인 없음" +$VBIN/v-list-mail-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN" \ + && echo " · 메일 도메인 있음 (메일함까지 함께 삭제됩니다)" || echo " · 메일 도메인 없음" +echo + +echo "[2] 파일" +if [ -d "$WEBDIR" ]; then + echo " 경로 $WEBDIR" + echo " 크기 $(du -sh "$WEBDIR" 2>/dev/null | cut -f1)" + echo " 파일수 $(find "$WEBDIR" -type f 2>/dev/null | wc -l)" +else + echo " (디렉터리 없음: $WEBDIR)" +fi +echo + +echo "[3] 데이터베이스" +DB="" +if [ -f "$WEBDIR/pythonapp/.env" ]; then + DB="$(grep -oP 'DATABASE_URL=.*/\K[^?[:space:]]+' "$WEBDIR/pythonapp/.env" 2>/dev/null | head -1)" + [ -z "$DB" ] && DB="$(grep -oP '^DB_NAME=\K.*' "$WEBDIR/pythonapp/.env" 2>/dev/null | head -1)" +fi +[ -z "$DB" ] && [ -f "$WR/wp-config.php" ] && DB="$(grep -oP "DB_NAME'?\s*,\s*'\K[^']+" "$WR/wp-config.php" 2>/dev/null | head -1)" +for CF in "$WR/files/config/db.config.php" "$WR/config/db.config.php"; do + [ -n "$DB" ] && break + [ -f "$CF" ] || continue + DB="$(grep -oP "'database'\s*=>\s*'\K[^']+" "$CF" 2>/dev/null | head -1)" + [ -z "$DB" ] && DB="$(grep -oP "db_database'?\s*[=,]\s*[\"']\K[^\"']+" "$CF" 2>/dev/null | head -1)" +done +[ -z "$DB" ] && [ -f "$WR/data/dbconfig.php" ] && DB="$(grep -oP "mysql_db'?\s*[,=]\s*[\"']\K[^\"']+" "$WR/data/dbconfig.php" 2>/dev/null | head -1)" + +if [ -z "$DB" ]; then + echo " DB 를 찾지 못했습니다 (설정파일 미검출) — DB 는 삭제되지 않습니다" + echo "HM_DB=" + echo "HM_DBSHARED=0" +else + SZ=$(mysql -N -B -e "SELECT IFNULL(ROUND(SUM(data_length+index_length)/1024/1024,1),0) FROM information_schema.tables WHERE table_schema='$DB'" 2>/dev/null) + EX=$(mysql -N -B -e "SHOW DATABASES LIKE '$DB'" 2>/dev/null | grep -c .) + echo " 이름 $DB 크기 ${SZ:-?}MB 실제존재 $([ "$EX" = 1 ] && echo 예 || echo '아니오(설정만 남음)')" + if $VBIN/v-list-databases "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DB"; then + echo " HestiaCP 등록 ✓ (정상 삭제 가능)" + else + echo " HestiaCP 등록 ✗ — 패널 밖에서 수동 생성된 DB 입니다" + echo " → 패널 명령으로는 안 지워집니다. 이 경우 DB 는 건너뜁니다(수동 처리)" + fi + + echo + echo " [공유 검사] 이 DB 를 다른 도메인도 쓰고 있는지 (중요)" + HITS=0; SHARED="" + shopt -s nullglob + for OWR in /home/"$VUSER"/web/*/public_html; do + [ -d "$OWR" ] || continue + OD="$(basename "$(dirname "$OWR")")" + if grep -rqsF "$DB" "$OWR/wp-config.php" "$OWR/data/dbconfig.php" \ + "$OWR/config/db.config.php" "$OWR/files/config/db.config.php" \ + "$(dirname "$OWR")/pythonapp/.env" 2>/dev/null; then + HITS=$((HITS+1)) + [ "$OD" != "$DOMAIN" ] && SHARED="$SHARED $OD" + fi + done + if [ -n "$SHARED" ]; then + echo " ✗✗ 다른 도메인도 이 DB 를 참조합니다:$SHARED" + echo " → DB 를 지우면 그 사이트들이 죽습니다. DB 삭제를 건너뛰어야 합니다." + echo "HM_DBSHARED=1" + else + echo " ✓ 이 도메인만 사용 (참조 $HITS 곳)" + echo "HM_DBSHARED=0" + fi + echo "HM_DB=$DB" +fi +echo + +echo "[4] 백업 여유" +df -h /backup 2>/dev/null | tail -1 | sed 's/^/ /' || df -h / | tail -1 | sed 's/^/ /' +echo +echo "===== 점검 끝 — 삭제하려면 백업 후 '서버에서 완전 삭제' =====" +"#; + +const ACCOUNT_DELETE_PROBE_BODY: &str = r#"set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER=__HM_USER__ + +echo "===== 계정 삭제 사전 점검: $VUSER =====" +echo +if ! $VBIN/v-list-users plain 2>/dev/null | awk '{print $1}' | grep -qx "$VUSER"; then + echo " ✗ HestiaCP 계정이 아닙니다: $VUSER — 중단하세요" + echo "===== 점검 끝 =====" + exit 0 +fi +echo "[1] 계정 요약" +$VBIN/v-list-user "$VUSER" plain 2>/dev/null | sed 's/^/ /' +echo +echo "[2] 웹 도메인 (전부 삭제됩니다)" +$VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print " "$1}' | sort +echo " 총 $($VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | grep -c .) 개" +echo +echo "[3] 데이터베이스 (전부 삭제됩니다)" +$VBIN/v-list-databases "$VUSER" plain 2>/dev/null | awk '{print " "$1}' | sort +echo +echo "[4] DNS / 메일 도메인" +echo " DNS $($VBIN/v-list-dns-domains "$VUSER" plain 2>/dev/null | grep -c .) 개" +echo " 메일 $($VBIN/v-list-mail-domains "$VUSER" plain 2>/dev/null | grep -c .) 개 (메일함 포함 삭제)" +echo +echo "[5] 디스크" +[ -d "/home/$VUSER" ] && echo " /home/$VUSER $(du -sh "/home/$VUSER" 2>/dev/null | cut -f1)" +echo +echo "[6] 백업 여유" +df -h /backup 2>/dev/null | tail -1 | sed 's/^/ /' || df -h / | tail -1 | sed 's/^/ /' +echo +echo "※ 계정 완전 삭제는 도메인·DB·메일·크론·/home 을 모두 지웁니다. 되돌릴 수 없습니다." +echo "===== 점검 끝 =====" +"#; + +/// 도메인 삭제 사전 점검 (SSH, sudo, 읽기 전용). +pub fn build_domain_delete_probe(s: &Settings, account: &str, domain: &str) -> Result { + let acct = account.trim(); + if !is_safe_name(acct) { return Err("계정 이름 형식 오류 (영숫자/._- 만)".into()); } + if is_protected_account(acct, s) { return Err(format!("보호 대상 계정입니다: {acct}")); } + let da = to_ascii_domain(domain); + if !is_safe_name(&da) { return Err(format!("도메인 형식 오류: {domain}")); } + let srv = ssh_admin_site(s)?; + let raw = DOMAIN_DELETE_PROBE_BODY + .replace("__HM_USER__", &sq(acct)) + .replace("__HM_DOMAIN__", &sq(&da)); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: format!("도메인 삭제 사전 점검 — {acct}/{da}"), + script, + sshpass, + env, + note: format!("{acct}/{da} 파일·DB·DNS·메일·공유 여부 조사 (읽기 전용)"), + }) +} + +/// 계정 삭제 사전 점검 (SSH, sudo, 읽기 전용). +pub fn build_account_delete_probe(s: &Settings, account: &str) -> Result { + let acct = account.trim(); + if !is_safe_name(acct) { return Err("계정 이름 형식 오류 (영숫자/._- 만)".into()); } + if is_protected_account(acct, s) { return Err(format!("보호 대상 계정입니다: {acct}")); } + let srv = ssh_admin_site(s)?; + let raw = ACCOUNT_DELETE_PROBE_BODY.replace("__HM_USER__", &sq(acct)); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: format!("계정 삭제 사전 점검 — {acct}"), + script, + sshpass, + env, + note: format!("계정 {acct}의 도메인·DB·DNS·메일·디스크 조사 (읽기 전용)"), + }) +} + /// 선택 사이트의 파일+DB를 로컬로 백업 — 사이트당 tar(웹루트/앱) + mysqldump 를 한 .tar.gz 로 받아 dest 에 저장. pub fn build_local_backup(s: &Settings, pairs: &[(String, String)], dest: &str) -> Result { if pairs.is_empty() { return Err("백업할 사이트를 선택하세요".into()); } @@ -4359,6 +4540,46 @@ mod tests { assert!(job.script.contains("wp_pw_one \"$VUSER\" \"$DOMAIN\" \"$WEBROOT\""), "{}", job.script); } + #[test] + fn delete_probe_jobs_valid_bash_and_validation() { + let st = Settings { + ssh_host: "1.2.3.4".into(), + ssh_user: "tong".into(), + ssh_pass: "pw".into(), + ..Default::default() + }; + for job in [ + build_domain_delete_probe(&st, "rokmc", "example.com").unwrap(), + build_account_delete_probe(&st, "rokmc").unwrap(), + ] { + let out = std::process::Command::new("bash") + .args(["-n", "-c", &job.script]) + .output() + .expect("bash"); + assert!( + out.status.success(), + "bash 오류({}):\n{}", + job.title, + String::from_utf8_lossy(&out.stderr) + ); + } + for job in [ + build_domain_delete_probe(&st, "rokmc", "example.com").unwrap(), + build_account_delete_probe(&st, "rokmc").unwrap(), + ] { + for bad in ["v-delete-", "DROP DATABASE", "rm -rf"] { + assert!(!job.script.contains(bad), "점검에 파괴 명령 포함: {bad}"); + } + } + for bad in ["admin", "root", "www-data", "mysql", "hestiaweb", "tong"] { + assert!(build_account_delete_probe(&st, bad).is_err(), "보호 계정 통과: {bad}"); + assert!(build_domain_delete_probe(&st, bad, "example.com").is_err(), "보호 계정 통과: {bad}"); + } + assert!(build_account_delete_probe(&st, "../etc").is_err()); + assert!(build_domain_delete_probe(&st, "rokmc", "ex'ample.com").is_err()); + assert!(build_domain_delete_probe(&st, "rokmc", "../../etc").is_err()); + } + #[test] fn punycode_korean_domain() { let out = idna::domain_to_ascii("한국.kr").unwrap(); From 8e5c6241d03df2c43cc1b3cd01fd835497f03ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Thu, 30 Jul 2026 09:11:09 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=A0=84=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=C2=B7=EA=B3=84=EC=A0=95=20=EB=B0=B1=EC=97=85?= =?UTF-8?q?=20=EB=8B=A8=EA=B3=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 66 +++++++++++++++++++++++++++++++++++++++++++++++---- src/ops.rs | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 5 deletions(-) diff --git a/src/app.rs b/src/app.rs index 7cbd589..6a0b5fc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -80,6 +80,7 @@ enum AcctTab { #[derive(Clone, Copy, PartialEq)] enum DangerRun { Probe, + Backup, } /// 비번 변경 실행 경로 — 화면마다 접속 방식이 다르다 @@ -1356,6 +1357,7 @@ impl App { let mut do_permfix = false; let mut do_wp_pw = false; let mut do_danger_probe: Option = None; + let mut do_danger_backup: Option = None; let mut alias_loads: Vec<(String, String)> = Vec::new(); let mut sel_all_sites: Option = None; let mut select_all = None; @@ -1583,7 +1585,16 @@ impl App { ui.add_space(12.0); ui.heading("2단계: 백업"); ui.separator(); - ui.add_enabled(false, egui::Button::new("백업 (점검 완료 후 활성)")); + ui.label("HestiaCP 표준 계정 백업을 /backup에 만듭니다. 계정 크기 + 1GB 여유가 필요합니다."); + if ui.add_enabled( + !running && self.danger_probed, + egui::Button::new(format!("{} 계정 전체 백업", ph::DOWNLOAD_SIMPLE)), + ).clicked() { + do_danger_backup = Some(String::new()); + } + if self.danger_backed_up { + ui.colored_label(egui::Color32::from_rgb(80, 180, 110), "✓ 현재 계정 백업 완료"); + } ui.add_space(12.0); ui.heading("3단계: 서버에서 완전 삭제"); ui.separator(); @@ -1602,7 +1613,16 @@ impl App { ui.add_space(12.0); ui.heading("2단계: 백업"); ui.separator(); - ui.add_enabled(false, egui::Button::new("백업 (점검 완료 후 활성)")); + ui.label(format!("{}/{}의 파일과 DB를 로컬 백업 폴더에 저장합니다.", acct, self.danger_domain)); + if ui.add_enabled( + !running && self.danger_probed, + egui::Button::new(format!("{} 파일·DB 백업", ph::DOWNLOAD_SIMPLE)), + ).clicked() { + do_danger_backup = Some(self.danger_domain.clone()); + } + if self.danger_backed_up { + ui.colored_label(egui::Color32::from_rgb(80, 180, 110), "✓ 현재 도메인 백업 완료"); + } ui.add_space(12.0); ui.heading("3단계: 서버에서 완전 삭제"); ui.separator(); @@ -1667,6 +1687,36 @@ impl App { } } } + if let Some(domain) = do_danger_backup { + self.danger_backed_up = false; + let built = if domain.is_empty() { + ops::build_account_backup(&self.store.settings, &acct) + } else { + let dest = store::backups_root().join("sites"); + let dest_s = dest.to_string_lossy().to_string(); + ops::build_local_backup(&self.store.settings, &[(acct.clone(), domain)], &dest_s) + .map(|mut job| { + // 기존 백업 빌더는 부분 실패 수를 FAIL에 남긴다. 위험 작업에서는 하나라도 실패하면 실패 상태로 끝낸다. + job.script.push_str("\n[ \"$FAIL\" = 0 ]\n"); + job + }) + }; + match built { + Ok(job) => { + self.running = true; + self.last_ok = None; + self.danger_running = Some(DangerRun::Backup); + 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 do_scan { self.site_dates_req.remove(&acct); self.site_perms_req.remove(&acct); self.rescan_account_into_all(acct.clone(), ctx); } if do_load_mods { self.load_account_modules(ci, ctx); } if do_scan_sel { @@ -2262,9 +2312,15 @@ impl App { self.running = false; self.last_ok = Some(ok); self.status = if ok { "성공".into() } else { "실패 (로그 확인)".into() }; - if self.danger_running.take() == Some(DangerRun::Probe) { - self.danger_probed = ok; - self.danger_backed_up = false; + match self.danger_running.take() { + Some(DangerRun::Probe) => { + self.danger_probed = ok; + self.danger_backed_up = false; + } + Some(DangerRun::Backup) => { + self.danger_backed_up = ok; + } + None => {} } // 업데이트 작업 완료 → 선택 사이트 자동 재스캔 예약 (성공 시) if ok && !self.pending_rescan.is_empty() { diff --git a/src/ops.rs b/src/ops.rs index 468475a..452c057 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -2644,6 +2644,53 @@ pub fn build_account_delete_probe(s: &Settings, account: &str) -> Result/dev/null | cut -f1); [ -z "$NEED" ] && NEED=0 +BD=/backup; [ -d "$BD" ] || BD=/ +FREE=$(df -Pm "$BD" 2>/dev/null | awk 'NR==2{print $4}'); [ -z "$FREE" ] && FREE=0 +echo " 계정 크기 약 ${NEED}MB · $BD 여유 ${FREE}MB" +if [ "$FREE" -lt $((NEED + 1024)) ] 2>/dev/null; then + echo " ✗ 여유 공간이 부족합니다 (필요 ${NEED}MB + 여유분 1GB) — 백업을 중단합니다" + echo " 공간을 확보한 뒤 다시 시도하세요. 백업 없이 삭제하지 마십시오." + exit 1 +fi + +$VBIN/v-backup-user "$VUSER" +RC=$? +echo +if [ "$RC" = 0 ]; then + echo " ✓ 백업 완료" + ls -lh /backup/"$VUSER".*.tar 2>/dev/null | tail -3 | sed 's/^/ /' +else + echo " ✗ 백업 실패 (v-backup-user=$RC) — 삭제로 넘어가지 마십시오" + exit 1 +fi +echo "===== 백업 끝 =====" +"#; + +/// 계정 전체 백업 (SSH, sudo). 메일·크론·DNS까지 포함하는 HestiaCP 표준 백업. +pub fn build_account_backup(s: &Settings, account: &str) -> Result { + let acct = account.trim(); + if !is_safe_name(acct) { return Err("계정 이름 형식 오류 (영숫자/._- 만)".into()); } + if is_protected_account(acct, s) { return Err(format!("보호 대상 계정입니다: {acct}")); } + let srv = ssh_admin_site(s)?; + let raw = ACCOUNT_BACKUP_BODY.replace("__HM_USER__", &sq(acct)); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: format!("계정 전체 백업 — {acct}"), + script, + sshpass, + env, + note: format!("계정 {acct} → /backup HestiaCP 표준 백업 (여유 공간 1GB 확인)"), + }) +} + /// 선택 사이트의 파일+DB를 로컬로 백업 — 사이트당 tar(웹루트/앱) + mysqldump 를 한 .tar.gz 로 받아 dest 에 저장. pub fn build_local_backup(s: &Settings, pairs: &[(String, String)], dest: &str) -> Result { if pairs.is_empty() { return Err("백업할 사이트를 선택하세요".into()); } @@ -4580,6 +4627,28 @@ mod tests { assert!(build_domain_delete_probe(&st, "rokmc", "../../etc").is_err()); } + #[test] + fn account_backup_job_valid_bash_and_validation() { + let st = Settings { + ssh_host: "1.2.3.4".into(), + ssh_user: "tong".into(), + ssh_pass: "pw".into(), + ..Default::default() + }; + let job = build_account_backup(&st, "rokmc").unwrap(); + let out = std::process::Command::new("bash") + .args(["-n", "-c", &job.script]) + .output() + .expect("bash"); + assert!(out.status.success(), "bash 오류:\n{}", String::from_utf8_lossy(&out.stderr)); + assert!(job.script.contains("v-backup-user")); + assert!(job.script.contains("NEED + 1024"), "1GB 안전 여유 검사 누락"); + for bad in ["admin", "root", "mysql", "hestiaweb", "tong"] { + assert!(build_account_backup(&st, bad).is_err(), "보호 계정 통과: {bad}"); + } + assert!(build_account_backup(&st, "../etc").is_err()); + } + #[test] fn punycode_korean_domain() { let out = idna::domain_to_ascii("한국.kr").unwrap(); From 2d6c50f2a43941c0691b60f34980ffd0ad87e08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Thu, 30 Jul 2026 09:13:52 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=84=9C?= =?UTF-8?q?=EB=B2=84=20=EC=99=84=EC=A0=84=20=EC=82=AD=EC=A0=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(=EC=8B=A4=EC=84=9C=EB=B2=84=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EC=A0=84=20=EC=82=AC=EC=9A=A9=20=EA=B8=88=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app.rs | 109 ++++++++++++++++++++++++++++++++++++++- src/ops.rs | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 257 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 6a0b5fc..34b18b3 100644 --- a/src/app.rs +++ b/src/app.rs @@ -81,6 +81,7 @@ enum AcctTab { enum DangerRun { Probe, Backup, + Delete, } /// 비번 변경 실행 경로 — 화면마다 접속 방식이 다르다 @@ -436,6 +437,8 @@ pub struct App { danger_db_shared: bool, /// 현재 실행 중인 위험 작업 단계 danger_running: Option, + /// 되돌릴 수 없는 삭제 전용 확인 모달 (작업, 타이핑할 대상명) + danger_confirm: Option<(ops::Job, String)>, /// WordPress 관리자 비번 변경 모달 (열려 있으면 Some) wp_pw_dlg: Option, /// 생성일(HestiaCP DATE)을 이미 조회한 계정 집합(세션 중 중복조회 방지) @@ -541,6 +544,7 @@ impl App { danger_db: String::new(), danger_db_shared: false, danger_running: None, + danger_confirm: None, wp_pw_dlg: None, site_dates_req: std::collections::HashSet::new(), site_perms_req: std::collections::HashSet::new(), @@ -1330,6 +1334,7 @@ impl App { self.danger_confirm_text.clear(); self.danger_db.clear(); self.danger_db_shared = false; + self.danger_confirm = None; } /// 🧩 계정 관리 페이지 — 사이트 / 모듈 / 메모 / 위험 작업 @@ -1358,6 +1363,7 @@ impl App { let mut do_wp_pw = false; let mut do_danger_probe: Option = None; let mut do_danger_backup: Option = None; + let mut do_danger_delete: Option = None; let mut alias_loads: Vec<(String, String)> = Vec::new(); let mut sel_all_sites: Option = None; let mut select_all = None; @@ -1627,7 +1633,30 @@ impl App { ui.heading("3단계: 서버에서 완전 삭제"); ui.separator(); ui.colored_label(egui::Color32::from_rgb(220, 90, 90), "⚠ 되돌릴 수 없습니다."); - ui.add_enabled(false, egui::Button::new("서버에서 완전 삭제")); + if self.danger_db_shared { + ui.colored_label( + egui::Color32::from_rgb(220, 150, 60), + "⚠ 이 DB는 다른 도메인도 사용 중 — DB는 삭제하지 않습니다", + ); + } else if self.danger_db.is_empty() { + ui.label("DB를 찾지 못해 삭제하지 않습니다."); + } else { + ui.label(format!("DB {}도 함께 삭제됩니다.", self.danger_db)); + } + ui.horizontal(|ui| { + ui.label(format!("확인을 위해 {} 을(를) 입력하세요:", self.danger_domain)); + ui.add(egui::TextEdit::singleline(&mut self.danger_confirm_text).desired_width(220.0)); + }); + let armed = self.danger_probed + && self.danger_backed_up + && self.danger_confirm_text.trim() == self.danger_domain; + if ui.add_enabled( + !running && armed, + egui::Button::new(format!("{} 서버에서 완전 삭제", ph::WARNING)) + .fill(egui::Color32::from_rgb(200, 70, 70)), + ).clicked() { + do_danger_delete = Some(self.danger_domain.clone()); + } } } } @@ -1717,6 +1746,21 @@ impl App { } } } + if let Some(domain) = do_danger_delete { + // 공유 중이면 DB 이름을 절대 전달하지 않는다. + let db = if self.danger_db_shared { "" } else { self.danger_db.as_str() }; + match ops::build_domain_delete(&self.store.settings, &acct, &domain, db) { + Ok(job) => { + self.danger_confirm_text.clear(); + self.danger_confirm = Some((job, domain)); + } + Err(e) => { + self.last_ok = Some(false); + self.status = format!("삭제 준비 실패: {e}"); + self.log.push(format!("도메인 삭제: {e}")); + } + } + } if do_scan { self.site_dates_req.remove(&acct); self.site_perms_req.remove(&acct); self.rescan_account_into_all(acct.clone(), ctx); } if do_load_mods { self.load_account_modules(ci, ctx); } if do_scan_sel { @@ -2320,6 +2364,11 @@ impl App { Some(DangerRun::Backup) => { self.danger_backed_up = ok; } + Some(DangerRun::Delete) => { + if ok { + self.reset_danger_progress(); + } + } None => {} } // 업데이트 작업 완료 → 선택 사이트 자동 재스캔 예약 (성공 시) @@ -2477,6 +2526,9 @@ impl eframe::App for App { if self.eond_confirm.is_some() { self.eond_confirm_modal(ctx); } + if self.danger_confirm.is_some() { + self.danger_confirm_modal(ctx); + } if self.pending_delete.is_some() { self.delete_modal(ctx); } @@ -4056,6 +4108,61 @@ impl App { if !close { self.wp_pw_dlg = Some(d); } } + /// 되돌릴 수 없는 서버 삭제 전용 확인 모달. + fn danger_confirm_modal(&mut self, ctx: &egui::Context) { + let (title, note, expected) = match &self.danger_confirm { + Some((job, expected)) => (job.title.clone(), job.note.clone(), expected.clone()), + None => return, + }; + let host = if self.store.settings.ssh_host.trim().is_empty() { + self.store.settings.hestia_host.trim().to_string() + } else { + self.store.settings.ssh_host.trim().to_string() + }; + egui::Window::new("⚠ 되돌릴 수 없는 삭제") + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0)) + .show(ctx, |ui| { + ui.strong(title); + ui.label(format!("서버: {host}")); + ui.label(format!("대상: {expected}")); + ui.label(note); + ui.add_space(6.0); + ui.colored_label( + egui::Color32::from_rgb(220, 80, 80), + "이 작업은 되돌릴 수 없습니다. 백업에서 복원하는 것만 가능합니다.", + ); + ui.add_space(8.0); + ui.horizontal(|ui| { + ui.label(format!("{expected} 입력:")); + ui.add(egui::TextEdit::singleline(&mut self.danger_confirm_text).desired_width(240.0)); + }); + let armed = self.danger_confirm_text.trim() == expected; + ui.horizontal(|ui| { + if ui.add_enabled( + !self.running && armed, + egui::Button::new(format!("{} 완전 삭제 실행", ph::WARNING)) + .fill(egui::Color32::from_rgb(200, 70, 70)), + ).clicked() { + if let Some((job, _)) = self.danger_confirm.take() { + self.running = true; + self.last_ok = None; + self.danger_running = Some(DangerRun::Delete); + self.status = "서버 완전 삭제 실행 중...".into(); + self.danger_confirm_text.clear(); + let ctx2 = ctx.clone(); + ops::spawn(job, self.tx.clone(), move || ctx2.request_repaint()); + } + } + if ui.button("취소").clicked() { + self.danger_confirm = None; + self.danger_confirm_text.clear(); + } + }); + }); + } + fn eond_confirm_modal(&mut self, ctx: &egui::Context) { let (title, note) = match &self.eond_confirm { Some(j) => (j.title.clone(), j.note.clone()), diff --git a/src/ops.rs b/src/ops.rs index 452c057..2858049 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -2691,6 +2691,127 @@ pub fn build_account_backup(s: &Settings, account: &str) -> Result }) } +const DOMAIN_DELETE_BODY: &str = r#"set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER=__HM_USER__ +DOMAIN=__HM_DOMAIN__ +DB=__HM_DB__ +SSHUSER=__HM_SSH_USER__ +LOG=/var/log/hostmover/deletions.log +mkdir -p /var/log/hostmover +TS=$(date '+%F %T') + +echo "===== 서버에서 완전 삭제: $VUSER / $DOMAIN =====" + +# Rust 입력 검증을 우회해도 보호 계정에서는 실행되지 않는다. +case "$VUSER" in + admin|root|www-data|mysql|hestia|hestiaweb|daemon|bin|sys|nobody|sshd|systemd-network) + echo " ✗ 보호 대상 계정입니다 — 중단"; exit 1 ;; +esac +if [ -n "$SSHUSER" ] && [ "$VUSER" = "$SSHUSER" ]; then + echo " ✗ 보호 대상 계정입니다 — SSH 접속 계정 삭제 중단"; exit 1 +fi + +# 등록 확인 — 없으면 아무것도 하지 않는다. +if ! $VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + echo " ✗ HestiaCP 에 웹도메인이 없습니다 — 중단 (계정/도메인을 확인하세요)" + exit 1 +fi + +SZ=$(du -sh "/home/$VUSER/web/$DOMAIN" 2>/dev/null | cut -f1) +echo " 대상 파일 ${SZ:-?} · DB ${DB:-없음}" +echo + +echo "[1] 웹도메인 삭제 (파일·nginx/apache 설정·SSL 포함)" +$VBIN/v-delete-web-domain "$VUSER" "$DOMAIN" +RC=$? +if [ "$RC" != 0 ]; then + echo " ✗ 실패 (v-delete-web-domain=$RC) — 이후 단계를 중단합니다" + echo "$TS FAIL domain $VUSER/$DOMAIN v-delete-web-domain=$RC" >> "$LOG" + exit 1 +fi +echo " ✓ 완료" + +echo "[2] DNS 도메인" +if $VBIN/v-list-dns-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + $VBIN/v-delete-dns-domain "$VUSER" "$DOMAIN" && echo " ✓ 삭제" || echo " ✗ 실패(수동 확인 필요)" +else + echo " · 없음" +fi + +echo "[3] 메일 도메인" +if $VBIN/v-list-mail-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN"; then + $VBIN/v-delete-mail-domain "$VUSER" "$DOMAIN" && echo " ✓ 삭제" || echo " ✗ 실패(수동 확인 필요)" +else + echo " · 없음" +fi + +echo "[4] 데이터베이스" +if [ -z "$DB" ]; then + echo " · 건너뜀 (대상 없음 — 미검출이거나 다른 도메인과 공유 중)" +else + if $VBIN/v-list-databases "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DB"; then + $VBIN/v-delete-database "$VUSER" "$DB" && echo " ✓ $DB 삭제" || echo " ✗ $DB 삭제 실패(수동 확인 필요)" + else + echo " ✗ $DB 는 HestiaCP 에 등록되어 있지 않습니다 — 건너뜁니다" + echo " 패널 밖에서 만든 DB 는 자동 삭제하지 않습니다. 필요하면 직접 처리하세요." + fi +fi +echo + +echo "[5] 삭제 검증" +FAILED=0 +$VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | awk '{print $1}' | grep -qx "$DOMAIN" \ + && { echo " ✗ 패널에 아직 남아 있음"; FAILED=1; } || echo " ✓ 패널에서 사라짐" +[ -d "/home/$VUSER/web/$DOMAIN" ] \ + && { echo " ✗ 디렉터리 잔존: /home/$VUSER/web/$DOMAIN"; FAILED=1; } || echo " ✓ 디렉터리 제거됨" +if [ -n "$DB" ]; then + if mysql -N -B -e "SHOW DATABASES LIKE '$DB'" 2>/dev/null | grep -q .; then + echo " ✗ DB 잔존: $DB"; FAILED=1 + else + echo " ✓ DB 제거됨" + fi +fi + +echo "$TS DELETE domain $VUSER/$DOMAIN files=${SZ:-?} db=${DB:-none} verify=$([ "$FAILED" = 0 ] && echo OK || echo INCOMPLETE)" >> "$LOG" +echo +if [ "$FAILED" = 0 ]; then + echo "===== 삭제 완료 =====" +else + echo "===== 삭제 불완전 — 위 ✗ 항목을 수동 확인하세요 =====" +fi +echo "※ hostmover 사이드바의 기록은 그대로 남아 있습니다. 필요하면 🗑 로 정리하세요." +echo "※ 이력: $LOG" +[ "$FAILED" = 0 ] +"#; + +/// 도메인 완전 삭제 (SSH, sudo, 되돌릴 수 없음 — 확인 모달 필수). +/// `db`가 비어 있으면 DB는 건드리지 않는다. +pub fn build_domain_delete(s: &Settings, account: &str, domain: &str, db: &str) -> Result { + let acct = account.trim(); + if !is_safe_name(acct) { return Err("계정 이름 형식 오류".into()); } + if is_protected_account(acct, s) { return Err(format!("보호 대상 계정입니다: {acct}")); } + let da = to_ascii_domain(domain); + if !is_safe_name(&da) { return Err(format!("도메인 형식 오류: {domain}")); } + let dbn = db.trim(); + if !dbn.is_empty() && !is_safe_name(dbn) { return Err(format!("DB 이름 형식 오류: {db}")); } + let srv = ssh_admin_site(s)?; + let raw = DOMAIN_DELETE_BODY + .replace("__HM_USER__", &sq(acct)) + .replace("__HM_DOMAIN__", &sq(&da)) + .replace("__HM_DB__", &sq(dbn)) + .replace("__HM_SSH_USER__", &sq(s.ssh_user.trim())); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: format!("도메인 서버 완전 삭제 — {acct}/{da}"), + script, + sshpass, + env, + note: format!("{acct}/{da} 파일·패널 등록·DNS·메일{} 제거", if dbn.is_empty() { "" } else { "·DB" }), + }) +} + /// 선택 사이트의 파일+DB를 로컬로 백업 — 사이트당 tar(웹루트/앱) + mysqldump 를 한 .tar.gz 로 받아 dest 에 저장. pub fn build_local_backup(s: &Settings, pairs: &[(String, String)], dest: &str) -> Result { if pairs.is_empty() { return Err("백업할 사이트를 선택하세요".into()); } @@ -4649,6 +4770,34 @@ mod tests { assert!(build_account_backup(&st, "../etc").is_err()); } + #[test] + fn domain_delete_job_safety() { + let st = Settings { + ssh_host: "1.2.3.4".into(), + ssh_user: "tong".into(), + ssh_pass: "pw".into(), + ..Default::default() + }; + let job = build_domain_delete(&st, "rokmc", "example.com", "rokmc_wp").unwrap(); + let out = std::process::Command::new("bash") + .args(["-n", "-c", &job.script]) + .output() + .expect("bash"); + assert!(out.status.success(), "bash 오류:\n{}", String::from_utf8_lossy(&out.stderr)); + assert!(job.script.contains("v-delete-web-domain")); + assert!(!job.script.contains("rm -rf /home"), "웹루트 직접 삭제 금지"); + assert!(job.script.contains("v-delete-database")); + assert!(job.script.contains("deletions.log")); + assert!(job.script.contains("보호 대상 계정입니다"), "원격 측 2차 방어 누락"); + let without_db = build_domain_delete(&st, "rokmc", "example.com", "").unwrap(); + assert!(without_db.script.contains("DB=''"), "빈 DB 주입 형식 확인"); + for bad in ["admin", "root", "tong"] { + assert!(build_domain_delete(&st, bad, "example.com", "").is_err()); + } + assert!(build_domain_delete(&st, "rokmc", "example.com", "db'x").is_err()); + assert!(build_domain_delete(&st, "rokmc", "../etc", "").is_err()); + } + #[test] fn punycode_korean_domain() { let out = idna::domain_to_ascii("한국.kr").unwrap(); From cf5e2dde9e29cba7d68b4fec5551322d6c72bc2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A0=95=EB=82=99=ED=9B=88?= Date: Thu, 30 Jul 2026 09:17:49 +0900 Subject: [PATCH 6/6] =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=20=EC=82=AD=EC=A0=9C=EC=99=80=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=20=EC=95=88=EB=82=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/account-delete.md | 46 ++++++++++++++++++++ src/app.rs | 30 ++++++++++++- src/ops.rs | 95 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 docs/account-delete.md diff --git a/docs/account-delete.md b/docs/account-delete.md new file mode 100644 index 0000000..e5c1de9 --- /dev/null +++ b/docs/account-delete.md @@ -0,0 +1,46 @@ +# 계정·도메인 서버 삭제 + +계정 관리의 `위험 작업` 탭은 HestiaCP 서버에서 도메인 또는 계정을 완전히 제거한다. +사이드바의 휴지통과는 목적과 결과가 다르므로 혼동하면 안 된다. + +| 기능 | 제거 범위 | 복구 | +|---|---|---| +| 사이드바 `삭제` | hostmover 로컬 기록만 휴지통으로 이동 | 휴지통에서 복원 | +| `서버에서 완전 삭제` | 서버 파일과 HestiaCP 등록, 선택 대상의 DB·DNS·메일 | 백업에서만 복원 | + +서버 삭제 후에도 사이드바의 로컬 기록은 그대로 남는다. 필요할 때 별도로 휴지통에 넣는다. + +## 삭제 절차 + +한 번에 한 도메인 또는 한 계정만 처리한다. + +1. `점검 (읽기 전용)`을 실행해 등록 상태, 파일 크기, DB, DNS·메일과 백업 공간을 확인한다. +2. `백업`을 실행한다. 도메인은 파일과 DB를 hostmover 로컬 백업 폴더에 저장하고, 계정 전체는 + HestiaCP `v-backup-user`로 `/backup`에 저장한다. 계정 백업은 계정 크기보다 1GB 이상 여유가 + 있어야 시작된다. 백업이 실패하면 삭제 버튼은 열리지 않는다. +3. 대상 이름을 정확히 입력하고 `서버에서 완전 삭제`를 누른다. 전용 확인 창에서 서버와 대상을 + 다시 확인한 뒤 대상 이름을 한 번 더 입력해야 실행할 수 있다. + +삭제는 웹루트를 직접 지우지 않고 HestiaCP의 `v-delete-web-domain` 또는 `v-delete-user`를 +사용한다. 결과와 사후 검증은 서버의 `/var/log/hostmover/deletions.log`에도 남는다. + +## DB 공유 주의 + +도메인 점검은 감지한 DB 이름을 같은 계정의 다른 웹루트 설정 파일에서도 찾는다. 다른 도메인이 +같은 DB를 참조하면 주황색 경고를 표시하고 DB 삭제를 자동으로 건너뛴다. 해당 DB를 임의로 직접 +삭제하면 다른 사이트가 함께 중단될 수 있으므로, 참조 관계를 정리하기 전에는 삭제하지 않는다. + +설정 파일에서 DB를 찾지 못했거나 DB가 HestiaCP에 등록되어 있지 않은 경우에도 DB는 자동 삭제하지 +않는다. 패널 밖에서 만든 DB는 별도로 확인하고 수동 처리한다. + +## 복원 + +도메인 백업은 hostmover의 설정 `백업/복원` 화면과 백업 파일을 이용해 복원한다. 계정 전체 백업은 +HestiaCP 서버에서 백업 파일을 확인한 뒤 다음 표준 명령으로 복원한다. + +```bash +/usr/local/hestia/bin/v-restore-user <계정명> <백업파일명> +``` + +복원 전에는 같은 이름의 계정·도메인·DB가 이미 존재하는지 확인한다. 실제 운영 계정에 사용하기 +전에 테스트 계정으로 점검 → 백업 → 삭제 → 복원의 전 과정을 반드시 검증한다. diff --git a/src/app.rs b/src/app.rs index 34b18b3..a1c9875 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1364,6 +1364,7 @@ impl App { let mut do_danger_probe: Option = None; let mut do_danger_backup: Option = None; let mut do_danger_delete: Option = None; + let mut do_danger_delete_account = false; let mut alias_loads: Vec<(String, String)> = Vec::new(); let mut sel_all_sites: Option = None; let mut select_all = None; @@ -1605,7 +1606,21 @@ impl App { ui.heading("3단계: 서버에서 완전 삭제"); ui.separator(); ui.colored_label(egui::Color32::from_rgb(220, 90, 90), "⚠ 되돌릴 수 없습니다."); - ui.add_enabled(false, egui::Button::new("계정을 서버에서 완전 삭제")); + ui.label("도메인·DB·메일·DNS·크론·/home 전체를 제거합니다."); + ui.horizontal(|ui| { + ui.label(format!("확인을 위해 {acct} 을(를) 입력하세요:")); + ui.add(egui::TextEdit::singleline(&mut self.danger_confirm_text).desired_width(220.0)); + }); + let armed = self.danger_probed + && self.danger_backed_up + && self.danger_confirm_text.trim() == acct; + if ui.add_enabled( + !running && armed, + egui::Button::new(format!("{} 계정을 서버에서 완전 삭제", ph::WARNING)) + .fill(egui::Color32::from_rgb(200, 70, 70)), + ).clicked() { + do_danger_delete_account = true; + } }); } else { if render_probe(ui, "선택 도메인 삭제 전 조사") { @@ -1761,6 +1776,19 @@ impl App { } } } + if do_danger_delete_account { + match ops::build_account_delete(&self.store.settings, &acct) { + Ok(job) => { + self.danger_confirm_text.clear(); + self.danger_confirm = Some((job, acct.clone())); + } + Err(e) => { + self.last_ok = Some(false); + self.status = format!("삭제 준비 실패: {e}"); + self.log.push(format!("계정 삭제: {e}")); + } + } + } if do_scan { self.site_dates_req.remove(&acct); self.site_perms_req.remove(&acct); self.rescan_account_into_all(acct.clone(), ctx); } if do_load_mods { self.load_account_modules(ci, ctx); } if do_scan_sel { diff --git a/src/ops.rs b/src/ops.rs index 2858049..5b93362 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -2812,6 +2812,77 @@ pub fn build_domain_delete(s: &Settings, account: &str, domain: &str, db: &str) }) } +const ACCOUNT_DELETE_BODY: &str = r#"set +e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin:/usr/sbin:/sbin" +VBIN=/usr/local/hestia/bin +VUSER=__HM_USER__ +SSHUSER=__HM_SSH_USER__ +LOG=/var/log/hostmover/deletions.log +mkdir -p /var/log/hostmover +TS=$(date '+%F %T') + +echo "===== 계정 완전 삭제: $VUSER =====" + +# Rust 검증을 우회해도 원격에서 다시 차단한다. +case "$VUSER" in + admin|root|www-data|mysql|hestia|hestiaweb|daemon|bin|sys|nobody|sshd|systemd-network) + echo " ✗ 보호 대상 계정입니다 — 중단"; exit 1 ;; +esac +if [ -n "$SSHUSER" ] && [ "$VUSER" = "$SSHUSER" ]; then + echo " ✗ 보호 대상 계정입니다 — SSH 접속 계정 삭제 중단"; exit 1 +fi +if ! $VBIN/v-list-users plain 2>/dev/null | awk '{print $1}' | grep -qx "$VUSER"; then + echo " ✗ HestiaCP 계정이 아닙니다 — 중단"; exit 1 +fi + +ND=$($VBIN/v-list-web-domains "$VUSER" plain 2>/dev/null | grep -c .) +SZ=$(du -sh "/home/$VUSER" 2>/dev/null | cut -f1) +echo " 도메인 ${ND}개 · ${SZ:-?}" +echo + +echo "[1] v-delete-user 실행" +$VBIN/v-delete-user "$VUSER" +RC=$? +[ "$RC" = 0 ] && echo " ✓ 완료" || echo " ✗ 실패(v-delete-user=$RC)" +echo + +echo "[2] 삭제 검증" +FAILED=0 +[ "$RC" = 0 ] || FAILED=1 +$VBIN/v-list-users plain 2>/dev/null | awk '{print $1}' | grep -qx "$VUSER" \ + && { echo " ✗ 패널에 계정이 남아 있음"; FAILED=1; } || echo " ✓ 패널에서 사라짐" +[ -d "/home/$VUSER" ] \ + && { echo " ✗ /home/$VUSER 잔존"; FAILED=1; } || echo " ✓ /home 제거됨" +id "$VUSER" >/dev/null 2>&1 \ + && { echo " ✗ 시스템 사용자 잔존"; FAILED=1; } || echo " ✓ 시스템 사용자 제거됨" + +echo "$TS DELETE user $VUSER domains=$ND size=${SZ:-?} rc=$RC verify=$([ "$FAILED" = 0 ] && echo OK || echo INCOMPLETE)" >> "$LOG" +echo +[ "$FAILED" = 0 ] && echo "===== 삭제 완료 =====" || echo "===== 삭제 불완전 — 위 ✗ 를 수동 확인 =====" +echo "※ hostmover 사이드바 기록은 남아 있습니다." +echo "※ 이력: $LOG" +[ "$FAILED" = 0 ] +"#; + +/// 계정 완전 삭제 (SSH, sudo, 되돌릴 수 없음). 도메인·DB·메일·크론·/home 전체가 대상이다. +pub fn build_account_delete(s: &Settings, account: &str) -> Result { + let acct = account.trim(); + if !is_safe_name(acct) { return Err("계정 이름 형식 오류 (영숫자/._- 만)".into()); } + if is_protected_account(acct, s) { return Err(format!("보호 대상 계정입니다: {acct}")); } + let srv = ssh_admin_site(s)?; + let raw = ACCOUNT_DELETE_BODY + .replace("__HM_USER__", &sq(acct)) + .replace("__HM_SSH_USER__", &sq(s.ssh_user.trim())); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: format!("계정 서버 완전 삭제 — {acct}"), + script, + sshpass, + env, + note: format!("계정 {acct}의 도메인·DB·메일·DNS·크론·/home 전체 제거"), + }) +} + /// 선택 사이트의 파일+DB를 로컬로 백업 — 사이트당 tar(웹루트/앱) + mysqldump 를 한 .tar.gz 로 받아 dest 에 저장. pub fn build_local_backup(s: &Settings, pairs: &[(String, String)], dest: &str) -> Result { if pairs.is_empty() { return Err("백업할 사이트를 선택하세요".into()); } @@ -4798,6 +4869,30 @@ mod tests { assert!(build_domain_delete(&st, "rokmc", "../etc", "").is_err()); } + #[test] + fn account_delete_job_safety() { + let st = Settings { + ssh_host: "1.2.3.4".into(), + ssh_user: "tong".into(), + ssh_pass: "pw".into(), + ..Default::default() + }; + let job = build_account_delete(&st, "rokmc").unwrap(); + let out = std::process::Command::new("bash") + .args(["-n", "-c", &job.script]) + .output() + .expect("bash"); + assert!(out.status.success(), "bash 오류:\n{}", String::from_utf8_lossy(&out.stderr)); + assert!(job.script.contains("v-delete-user")); + assert!(job.script.contains("deletions.log")); + assert!(job.script.contains("보호 대상 계정입니다"), "원격 측 2차 방어 누락"); + assert!(job.script.contains("v-list-users"), "원격 HestiaCP 계정 확인 누락"); + for bad in ["admin", "root", "mysql", "hestiaweb", "tong"] { + assert!(build_account_delete(&st, bad).is_err(), "보호 계정 통과: {bad}"); + } + assert!(build_account_delete(&st, "../etc").is_err()); + } + #[test] fn punycode_korean_domain() { let out = idna::domain_to_ascii("한국.kr").unwrap();