diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffb4e07..f1ae2a1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,37 +170,75 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v7 + - name: Validate the production Compose file + run: docker compose config --quiet - name: Build the image run: docker build --build-arg VERSION="${RELEASE:-dev-${GITHUB_SHA::7}}" -t miaopanel:ci . - - name: Start it and check the login page answers + - name: Start it behind an exact trusted HTTPS proxy run: | - docker run -d --name miaopanel -p 127.0.0.1:18765:18765 -v miaopanel-ci-data:/data \ - -e MIAO_TRUSTED_PROXIES=172.16.0.0/12 miaopanel:ci - for i in $(seq 1 20); do curl -fsS -H 'X-Miao: 1' http://127.0.0.1:18765/api/auth/state && break; sleep 1; done + mkdir -p /tmp/miaopanel-proxy + openssl req -x509 -newkey rsa:2048 -nodes -days 1 \ + -keyout /tmp/miaopanel-proxy/key.pem -out /tmp/miaopanel-proxy/cert.pem \ + -subj '/CN=127.0.0.1' -addext 'subjectAltName=IP:127.0.0.1' + cat > /tmp/miaopanel-proxy/default.conf <<'EOF' + server { + listen 443 ssl; + ssl_certificate /etc/nginx/tls/cert.pem; + ssl_certificate_key /etc/nginx/tls/key.pem; + location / { + proxy_pass http://172.30.0.20:18765; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + } + } + EOF + docker network create --subnet 172.30.0.0/24 miaopanel-ci + docker run -d --name miaopanel --network miaopanel-ci --ip 172.30.0.20 \ + -v miaopanel-ci-data:/data -e MIAO_TRUSTED_PROXIES=172.30.0.10 miaopanel:ci + docker run -d --name miaopanel-proxy --network miaopanel-ci --ip 172.30.0.10 \ + -p 127.0.0.1:18443:443 -v /tmp/miaopanel-proxy/default.conf:/etc/nginx/conf.d/default.conf:ro \ + -v /tmp/miaopanel-proxy:/etc/nginx/tls:ro nginx:alpine + for i in $(seq 1 20); do curl -kfsS -H 'X-Miao: 1' https://127.0.0.1:18443/api/auth/state && break; sleep 1; done docker logs miaopanel 2>&1 | grep -q '初始化码' - curl -fsS -H 'X-Miao: 1' http://127.0.0.1:18765/api/auth/state | grep -q '"install":true' - curl -fsS -H 'X-Miao: 1' -H 'X-Forwarded-Proto: https' \ - http://127.0.0.1:18765/api/auth/state | grep -q '"https":true' - - name: Check the install wizard and password login in a browser + curl -kfsS -H 'X-Miao: 1' https://127.0.0.1:18443/api/auth/state | grep -q '"install":true' + curl -kfsS -H 'X-Miao: 1' https://127.0.0.1:18443/api/auth/state | grep -q '"https":true' + - name: Check HTTPS setup, secure cookie, password, 2FA, and browser scrolling + env: + MIAO_TEST_BASE_URL: https://127.0.0.1:18443 + MIAO_TEST_ENABLE_TOTP: "1" + MIAO_TEST_TOTP_FILE: /tmp/miaopanel-totp run: python3 scripts/browser_smoke.py - - name: Restore a stopped data volume + - name: Restore a checked backup into a new volume run: | + docker exec miaopanel sha256sum /data/secrets.json | cut -d' ' -f1 > /tmp/miaopanel-secrets.sha256 docker stop miaopanel mkdir -p /tmp/miaopanel-backup docker run --rm --user 0 --volumes-from miaopanel -v /tmp/miaopanel-backup:/backup \ --entrypoint sh miaopanel:ci -c 'umask 077; tar -C /data -czf /backup/miaopanel.tar.gz .' + sudo sha256sum /tmp/miaopanel-backup/miaopanel.tar.gz > /tmp/miaopanel-backup/miaopanel.tar.gz.sha256 + sudo sha256sum -c /tmp/miaopanel-backup/miaopanel.tar.gz.sha256 docker volume create miaopanel-ci-restored docker run --rm --user 0 -v miaopanel-ci-restored:/data -v /tmp/miaopanel-backup:/backup:ro \ - --entrypoint sh miaopanel:ci -c 'tar -C /data -xzf /backup/miaopanel.tar.gz' + --entrypoint sh miaopanel:ci -c 'test -z "$(ls -A /data)" && tar -C /data -xzf /backup/miaopanel.tar.gz' docker rm miaopanel - docker run -d --name miaopanel-restored -p 127.0.0.1:18765:18765 \ - -v miaopanel-ci-restored:/data -e MIAO_TRUSTED_PROXIES=172.16.0.0/12 miaopanel:ci + docker run -d --name miaopanel-restored --network miaopanel-ci --ip 172.30.0.20 \ + -v miaopanel-ci-restored:/data -e MIAO_TRUSTED_PROXIES=172.30.0.10 miaopanel:ci for i in $(seq 1 20); do - if curl -fsS -H 'X-Miao: 1' http://127.0.0.1:18765/api/auth/state | grep -q '"setup":false'; then exit 0; fi + if curl -kfsS -H 'X-Miao: 1' https://127.0.0.1:18443/api/auth/state | grep -q '"setup":false'; then break; fi sleep 1 done - docker logs miaopanel-restored - exit 1 + curl -kfsS -H 'X-Miao: 1' https://127.0.0.1:18443/api/auth/state | grep -q '"setup":false' + test "$(docker exec miaopanel-restored sha256sum /data/secrets.json | cut -d' ' -f1)" = "$(cat /tmp/miaopanel-secrets.sha256)" + test "$(docker exec miaopanel-restored stat -c %a /data/secrets.json)" = 600 + - name: Check restored account, 2FA, server record, key file, and secure cookie in a browser + env: + MIAO_TEST_BASE_URL: https://127.0.0.1:18443 + MIAO_TEST_MODE: restored + MIAO_TEST_TOTP_FILE: /tmp/miaopanel-totp + run: python3 scripts/browser_smoke.py release: if: (github.ref_type == 'tag' && startsWith(github.ref_name, 'v')) || (github.event_name == 'workflow_dispatch' && startsWith(inputs.version, 'v')) diff --git a/README.md b/README.md index 3f73d4c..a5b6f16 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ - **访问统计**:「今天下午流量为什么涨了?」→ 基于 EO 数据分析自动归因(Top IP / URL / UA / 地区)。 - **服务器状态**:CPU、内存、磁盘、带宽、到期时间一屏总览;「现在是什么在吃内存?」直接问。 - **AI 排障**:「网站打不开」→ 按 DNS → EO → 网络 → 主机 → 应用逐层取证,给出结论、证据和修复方案。 -- **优化建议,同意后自动执行**:先用数据判断是否真有问题,再给出具体改法;你同意后自动执行,失败自动回滚,事后汇报效果。1Panel、宝塔上的修改走面板自己的 API,面板里看得到、不会被覆盖。 +- **优化建议,同意后自动执行**:先用数据判断是否真有问题,再给出具体改法;你同意后自动执行。支持保护的单个步骤失败时会恢复该步骤已经做出的改动;清单里此前成功的步骤保留,可在日志中手动回滚。1Panel、宝塔上的修改走面板自己的 API,面板里看得到、不会被覆盖。 - **看不懂命令也能安全使用**:AI 只能提交计划;模板覆盖不到时可以开启受限的 AI 自由命令,由系统替你检查命令、试运行、做快照,并设置「5 分钟保险」自动恢复。 打开就是**总览**:服务器、网站、证书、安全各一张卡片,下面是「需要你处理」的事(没封禁的高风险 IP、快到期的证书、等你确认的清单、服务器的问题),以及今天的访问和最近的变更。侧边栏按「服务器 · 网站 · 云服务」分组,服务器前面的小圆点表示状态;AI 给的清单、提醒和日报都在「待处理」里。**AI 侧栏**在任何页面都能打开(右下角「问 AI」,终端和文件页在标题栏右边,或者按 Ctrl+J / ⌘J),窗口够宽时页面会让出位置,它知道你正在看哪个页面、哪个网站,不用跳走。点侧边栏的服务器打开它的工作台,分「概览 · 网站 · 应用 · 安全与备份 · 执行记录 · 连接设置」:概览里有 CPU、内存、磁盘和流量包的圆环,体检发现的问题旁边就是「加 swap」「清理磁盘」这样的按钮(交给 AI 先查再出清单),还有这台服务器的网站、占内存最多的程序和最近的修改。按 Ctrl+K / ⌘K 可以搜服务器、网站和页面,输入「终端 blog」直接打开终端。 @@ -120,7 +120,7 @@ AI 能看存储桶的设置和文件列表,也能生成修改设置的清单 **对话记录**:和 AI 的对话会自动保存,关掉程序再打开也在,还能接着问;在 AI 助手页左侧的「对话记录」里切换或删除。 -**日志和回滚**:「日志 → 执行日志」里能查到 Miao Panel 在服务器上执行过的每一条命令(包括 AI 的只读检查)和结果;改过的东西可以在这里一键回滚。能回滚的修改还会在服务器的备份目录里留一个 `rollback.sh`,就算电脑上的 Miao Panel 不在了,也能在服务器上用 root 执行它恢复。 +**日志和回滚**:「日志 → 执行日志」里能查到 Miao Panel 在服务器上执行过的每一条命令(包括 AI 的只读检查)和结果;标明可以回滚的修改可在这里一键回滚。由服务器脚本执行并产生本地备份的修改,还会在服务器的备份目录里留一个 `rollback.sh`,就算电脑上的 Miao Panel 不在了,也能在服务器上用 root 执行它恢复;1Panel 和云端 API 操作需通过 Miao Panel 回滚。 **1Panel 用户**:在 1Panel「面板设置 → API 接口」开启 API,IP 白名单填 `127.0.0.1`,把密钥粘贴到 Miao Panel 服务器页「连接设置」里的「1Panel 接口」并点「测试」。这样 PHP、MySQL 的修改会走 1Panel 自己的接口,面板里看得到、不会被覆盖。 diff --git a/docker-compose.yml b/docker-compose.yml index fadcc86..735586d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,17 +6,14 @@ services: image: miaopanel:latest container_name: miaopanel restart: unless-stopped - ports: - # 只让本机的反向代理(1Panel、宝塔、Nginx、Caddy)访问,由它提供 HTTPS。 - # 一定要直接从外面访问的话改成 "18765:18765",并且只通过 HTTPS 使用。 - - "127.0.0.1:18765:18765" + # Linux 服务器上直接使用主机网络,让本机反向代理的来源保持为 127.0.0.1, + # 不需要把整个 Docker 私网段列为可信代理。 + network_mode: host volumes: - miaopanel-data:/data # 数据库和密钥都在这里,请定期备份 environment: TZ: Asia/Shanghai # 日报按这个时区生成 - # 端口仅绑定主机回环地址;容器里看到的主机代理连接来自 Docker 网桥。 - # 如改成向公网发布端口,须去掉此项,并用 --tls-cert/--tls-key 提供 HTTPS。 - MIAO_TRUSTED_PROXIES: ${MIAO_TRUSTED_PROXIES:-172.16.0.0/12} + MIAO_LISTEN: 127.0.0.1:18765 volumes: miaopanel-data: diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index f000200..7b8c6be 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -60,9 +60,9 @@ docker compose up -d --build docker logs miaopanel # 找到「初始化码」 ``` -默认只在本机的 `127.0.0.1:18765` 上监听,需要再配一个带 HTTPS 的反向代理(见下文)才能从外面访问。 +Docker Compose 在 Linux 服务器上使用主机网络,程序只监听本机的 `127.0.0.1:18765`,需要再配一个带 HTTPS 的反向代理(见下文)才能从外面访问。 数据(数据库和密钥)在 Docker 卷 `miaopanel-data` 里,删除容器不会丢;**删除这个卷就全没了**。 -在安装向导里选 MySQL 时,地址不能填 `127.0.0.1`(那是容器自己),要填 MySQL 所在机器的内网 IP,或者同一个 Docker 网络里 MySQL 容器的名字。 +默认主机网络下,安装向导中的 `127.0.0.1` 指宿主机;MySQL 在宿主机监听或已向宿主机发布端口时可使用这个地址。远程数据库填其内网 IP;主机网络不使用 Docker 网桥网络内的容器名称解析。 升级:在「设置 → 版本和诊断」点「更新到最新版本」,会从 GitHub 下载新程序、核对校验值后自动重启。新程序放在数据卷的 `/data/bin` 里,重建容器也还在;以后换用更新的镜像(`git pull && docker compose up -d --build`)时自动用较新的那个。有新版本时总览也会提醒(每天问一次 GitHub,可以关掉)。 @@ -107,12 +107,14 @@ docker compose stop miaopanel docker compose run --rm --no-deps --user 0 \ -v "$PWD/backups:/backup" --entrypoint sh miaopanel \ -c 'umask 077; tar -C /data -czf /backup/miaopanel-backup.tar.gz .' +sudo sha256sum backups/miaopanel-backup.tar.gz > backups/miaopanel-backup.tar.gz.sha256 docker compose start miaopanel ``` 在新机器或**新的、空的数据卷**里恢复。把备份放在新项目目录的 `backups/` 下,先不要运行 `docker compose up`: ```bash +sudo sha256sum -c backups/miaopanel-backup.tar.gz.sha256 docker compose run --rm --no-deps --user 0 \ -v "$PWD/backups:/backup:ro" --entrypoint sh miaopanel \ -c 'test -z "$(ls -A /data)" || { echo "数据卷必须为空"; exit 1; }; tar -C /data -xzf /backup/miaopanel-backup.tar.gz' @@ -121,6 +123,17 @@ docker compose up -d 恢复演练要检查原账号能登录、服务器列表和密钥可用、执行日志仍在。 +systemd 安装包的数据目录是 `/opt/miaopanel/data`;手动配置的旧服务可能是 `/var/lib/miaopanel`,备份前先核对服务配置。 + +```bash +sudo systemctl stop miaopanel +sudo sh -c 'umask 077; tar -C /opt/miaopanel/data -czf /root/miaopanel-backup.tar.gz .' +sudo sh -c 'cd /root && sha256sum miaopanel-backup.tar.gz > miaopanel-backup.tar.gz.sha256' +sudo systemctl start miaopanel +``` + +在新机器恢复时,安装包会自动启动服务,安装后先运行 `sudo systemctl stop miaopanel`。核对服务的数据目录,把自动生成的初始目录保留到其他位置,再准备同一路径的空目录。先用 `sha256sum -c` 检查备份,再解包到这个空目录;安装包默认路径需运行 `sudo chown -R miaopanel:miaopanel /opt/miaopanel/data`,然后启动服务。手动安装请替换为服务实际使用的数据目录。恢复演练要检查原账号和两步验证能登录、服务器列表和密钥可用、执行日志及回滚信息仍在。至少保留一份不在原服务器磁盘上的备份。不要把含密钥的备份提交到 GitHub。 + ## 方式三:手动配置 systemd 安装包(方式一)做的就是这些,一般不需要手动配置。想自己控制每一步的话: @@ -147,6 +160,7 @@ Miao Panel 本身只说 HTTP,由前面的网站服务提供 HTTPS 证书。登 ```nginx proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_buffering off; proxy_request_buffering off; @@ -161,9 +175,26 @@ client_max_body_size 0; 这几行的作用:`X-Real-IP` 让登录失败按访客 IP 限制;`X-Forwarded-Proto` 让 Miao Panel 知道是 HTTPS; 关闭缓冲让 AI 的回答和终端输出实时显示;超时时间让终端能长时间开着;`client_max_body_size 0` 让「文件」和「存储」页能上传大文件。 -直接运行程序时,默认只信任来自本机回环地址的反向代理。Docker Compose 的端口只绑定主机回环地址,但容器里看到的主机代理连接来自 Docker 网桥,因此 `docker-compose.yml` 已信任常见的 Docker 网桥网段。如果你的网桥不在该网段,或反向代理从另一台机器连接,设置 `MIAO_TRUSTED_PROXIES` 为代理的 IP(或尽量小的 CIDR),多个地址用逗号分隔;例如 `MIAO_TRUSTED_PROXIES=172.20.0.5`。如果把 Docker 端口改成对公网开放,必须移除 Compose 里的网桥信任配置,并用程序自带的 HTTPS。不要让代理原样转发客户端提供的 `X-Real-IP` 和 `X-Forwarded-Proto`。 +默认只信任来自本机回环地址的反向代理,Docker Compose 也通过主机网络保持这个边界。如果反向代理在另一台机器,设置 `MIAO_TRUSTED_PROXIES` 为代理的准确 IP(或尽量小的 CIDR),多个地址用逗号分隔;例如 `MIAO_TRUSTED_PROXIES=172.20.0.5`。代理必须覆盖客户端传来的 `X-Real-IP` 和 `X-Forwarded-Proto`,并覆盖或追加 `X-Forwarded-For`;不要把这些请求头原样转发。 + +直接运行程序且不想用反向代理时,也可以让 Miao Panel 自己提供 HTTPS:`miaopanel serve --listen 0.0.0.0:443 --tls-cert 证书.pem --tls-key 私钥.pem`。Docker 部署请保持默认的本机监听和 HTTPS 反向代理,不要直接发布应用端口。 + +## 上线验收 + +仓库 CI 会在 Docker 中自动检查:精确到单个 IP 的可信反向代理、HTTPS 登录和安全 Cookie、开启并再次使用两步验证登录,以及停机备份到空卷后恢复账号、两步验证、服务器记录和密钥文件。单元测试还会用本地模拟 SSH 检查执行日志、服务器端 `rollback.sh` 和一键回滚。它们不能代替下面的真实环境验收。 + +首次上线或更换代理、数据目录、运行用户后,在一台可丢弃的测试服务器上完成以下检查: + +| 项目 | 真实环境通过标准 | +|---|---| +| 反向代理与 HTTPS | 公网 HTTP 会跳转到 HTTPS,浏览器证书链有效;`/api/auth/state` 返回 `"https":true`。从外网伪造 `X-Real-IP`、`X-Forwarded-For`、`X-Forwarded-Proto` 后故意登录失败,操作记录仍显示测试机的真实公网 IP,且 Cookie 带 `Secure`、`HttpOnly`、`SameSite=Strict`。 | +| 登录与两步验证 | 开启两步验证前必须再次输入当前密码;退出后用密码加 TOTP 能登录,错误 TOTP 不能登录;服务器上的 `reset-password` 能恢复账号并退出已有会话。 | +| SSH 与权限 | 用计划中的实际账号(包括非 root 加 `sudo -n`)测试连接、识别环境;确认主机密钥后重连成功。需要 AI 自由命令时,另测 `systemd-run` 和五分钟保险。 | +| 执行日志与回滚 | 在可丢弃服务器上执行一个标明可回滚的脚本操作。执行日志应先出现“运行中”再给出完整命令和结果;服务器备份目录应有 `rollback.sh`。点回滚后确认状态确实恢复、日志互相引用,文件改名为 `rollback.sh.done`,再次回滚被拒绝。 | +| 备份与恢复 | 先产生一条可回滚执行日志并保存一个可用的 SSH 密钥,再按上文停机备份。恢复到另一台机器或新空卷后,用原账号和 TOTP 登录,测试 SSH 密钥、服务器列表、执行日志和回滚信息;校验和与 `secrets.json` 的 `0600` 权限必须保持。 | +| 面板与云资源 | 如实际使用 1Panel、宝塔或腾讯云,在专用测试站点/实例上各执行一次可逆变更并回滚;核对面板/云控制台的最终状态、Miao Panel 执行日志和审计记录一致。不要用生产资源做首次演练。 | -不想用反向代理,也可以让 Miao Panel 自己提供 HTTPS:`miaopanel serve --listen 0.0.0.0:443 --tls-cert 证书.pem --tls-key 私钥.pem`。 +把日期、版本或提交号、代理地址、测试资源、日志 ID、备份校验和和结果记入上线记录。任何一项未通过,都不要把该版本当作已完成生产验收。 ## 第一次打开:安装向导 diff --git a/internal/actions/actions_test.go b/internal/actions/actions_test.go index 73fbd75..2f70754 100644 --- a/internal/actions/actions_test.go +++ b/internal/actions/actions_test.go @@ -159,7 +159,9 @@ func TestRunScriptDetachedApplyAndUndo(t *testing.T) { if undone.Status != StatusUndone || !strings.Contains(strings.Join(undone.Log, "\n"), "undo token=["+tricky+"]") { t.Fatalf("undo: %+v", undone) } - RetireRollbackFile(ctx, env, out.RollbackFile) + if _, err := RetireRollbackFile(ctx, env, out.RollbackFile); err != nil { + t.Fatal(err) + } if _, err := os.Stat(out.RollbackFile + ".done"); err != nil { t.Fatalf("rollback file not retired: %v", err) } diff --git a/internal/actions/exec.go b/internal/actions/exec.go index a9db706..31f4471 100644 --- a/internal/actions/exec.go +++ b/internal/actions/exec.go @@ -383,17 +383,27 @@ func writeRollbackFile(ctx context.Context, env *Env, sudo string, r Resolved, d // RetireRollbackFile renames a rollback file after Miao Panel has rolled // the change back, so nobody runs it a second time by mistake. -func RetireRollbackFile(ctx context.Context, env *Env, path string) string { +func RetireRollbackFile(ctx context.Context, env *Env, path string) (string, error) { if path == "" { - return "" + return "", nil } sudo := "" if env.User != "root" { sudo = "sudo -n " } cmd := sudo + "mv -f " + shq(path) + " " + shq(path+".done") - _, _ = env.SSH.Run(ctx, cmd, "", 1024) - return cmd + res, err := env.SSH.Run(ctx, cmd, "", 1024) + if err != nil || res.ExitCode != 0 { + detail := strings.TrimSpace(res.Stderr) + if err != nil { + detail = strings.TrimSpace(err.Error() + " " + detail) + } + if detail == "" { + detail = fmt.Sprintf("exit code %d", res.ExitCode) + } + return cmd, fmt.Errorf("回滚已完成,但旧回滚文件未能改名:%s", detail) + } + return cmd, nil } func statusForExit(raw string) (string, error) { diff --git a/internal/api/server_test.go b/internal/api/server_test.go index f4038ff..9d4b646 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/base32" "encoding/json" "net/http" "net/http/httptest" @@ -146,6 +147,39 @@ func TestWebEditionLogin(t *testing.T) { } } +func TestEnablingTOTPRequiresPassword(t *testing.T) { + s, au := newWebServer(t) + code, _ := au.SetupCode() + w := call{method: "POST", path: "/api/auth/setup", body: `{"code":"` + code + `","name":"admin","password":"correct horse"}`}.do(s) + cookie := sessionCookie(w).Value + post := func(method, path, body string) *httptest.ResponseRecorder { + t.Helper() + return call{method: method, path: path, body: body, cookie: cookie, remote: "203.0.113.5:1"}.do(s) + } + if w := post("POST", "/api/account/totp", `{"password":"wrong password"}`); w.Code != http.StatusBadRequest { + t.Fatalf("begin with wrong password: %d %s", w.Code, w.Body) + } + w = post("POST", "/api/account/totp", `{"password":"correct horse"}`) + var setup struct{ Secret string } + if err := json.Unmarshal(w.Body.Bytes(), &setup); err != nil || setup.Secret == "" { + t.Fatalf("begin: %d %s (%v)", w.Code, w.Body, err) + } + key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(setup.Secret) + if err != nil { + t.Fatal(err) + } + totp := auth.Code(key, time.Now().Unix()/30) + if w := post("PUT", "/api/account/totp", `{"password":"wrong password","code":"`+totp+`"}`); w.Code != http.StatusBadRequest { + t.Fatalf("enable with wrong password: %d %s", w.Code, w.Body) + } + if w := post("GET", "/api/account", ""); w.Code != http.StatusOK || strings.Contains(w.Body.String(), `"totp":true`) { + t.Fatalf("enabled without password: %d %s", w.Code, w.Body) + } + if w := post("PUT", "/api/account/totp", `{"password":"correct horse","code":"`+totp+`"}`); w.Code != http.StatusOK { + t.Fatalf("enable: %d %s", w.Code, w.Body) + } +} + func TestDesktopHasNoAccounts(t *testing.T) { s := newServer(t) w := do(s, "GET", "/api/auth/state", "127.0.0.1:18765", "", true) diff --git a/internal/api/session.go b/internal/api/session.go index ff4913f..d0b0ca3 100644 --- a/internal/api/session.go +++ b/internal/api/session.go @@ -280,6 +280,13 @@ func (s *Server) beginTOTP(_ http.ResponseWriter, r *http.Request) (any, error) if err != nil { return nil, err } + var req struct{ Password string } + if err := decode(r, &req); err != nil { + return nil, err + } + if err := s.auth.Confirm(u.ID, s.clientIP(r), req.Password); err != nil { + return nil, err + } return s.auth.BeginTOTP(u.ID) } @@ -288,10 +295,13 @@ func (s *Server) enableTOTP(_ http.ResponseWriter, r *http.Request) (any, error) if err != nil { return nil, err } - var req struct{ Code string } + var req struct{ Code, Password string } if err := decode(r, &req); err != nil { return nil, err } + if err := s.auth.Confirm(u.ID, s.clientIP(r), req.Password); err != nil { + return nil, err + } return map[string]bool{"ok": true}, s.auth.EnableTOTP(u.ID, tok, req.Code) } diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 73b069c..d8d0d72 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -383,6 +383,20 @@ func TestExecLogAndRollback(t *testing.T) { } } +func TestChangeDoesNotStartWithoutExecutionLog(t *testing.T) { + st, err := store.OpenMemory() + if err != nil { + t.Fatal(err) + } + a := New(st, secrets.OpenFile(t.TempDir())) + if err := st.Close(); err != nil { + t.Fatal(err) + } + if _, err := a.startChange(store.ExecLog{Kind: store.ExecChange, Title: "must be logged"}); err == nil { + t.Fatal("change started after the execution log store was closed") + } +} + func TestUndoPlanRevertsEverything(t *testing.T) { t.Cleanup(actions.UseTestScripts(t.TempDir(), func(string) (string, error) { return fakeSwap, nil })) a := newApp(t) diff --git a/internal/app/exec_failure_test.go b/internal/app/exec_failure_test.go new file mode 100644 index 0000000..2474736 --- /dev/null +++ b/internal/app/exec_failure_test.go @@ -0,0 +1,107 @@ +package app + +import ( + "context" + "database/sql" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/bocmiao/CloudConsoleWithAI/internal/actions" + "github.com/bocmiao/CloudConsoleWithAI/internal/core" + "github.com/bocmiao/CloudConsoleWithAI/internal/secrets" + "github.com/bocmiao/CloudConsoleWithAI/internal/sshx/sshtest" + "github.com/bocmiao/CloudConsoleWithAI/internal/store" +) + +func TestFinishActionInvalidatesCachesOnLogFailure(t *testing.T) { + for _, capability := range []string{"cloud.renew.set", "aliyun.renew.set"} { + t.Run(capability, func(t *testing.T) { + a := newApp(t) + e, err := a.startChange(store.ExecLog{Kind: store.ExecChange, Capability: capability}) + if err != nil { + t.Fatal(err) + } + before := a.pages.generation() + if err := a.Store.Close(); err != nil { + t.Fatal(err) + } + if err := a.finishAction(&e, actions.Outcome{Status: actions.StatusDone}); err == nil { + t.Fatal("lost execution result was not reported") + } + if a.pages.generation() == before { + t.Fatal("pages still show pre-change state") + } + if capability == "cloud.renew.set" && !a.cloud.stale.Load() { + t.Fatal("Tencent list not invalidated") + } + if capability == "aliyun.renew.set" && !a.aliCloud.stale.Load() { + t.Fatal("Aliyun list not invalidated") + } + }) + } +} + +func TestStepRollbackAfterExecutionLogUpdateFails(t *testing.T) { + // Reject only outcome updates. The actual operation and plan persistence + // still work, reproducing a partial database failure rather than a closed DB. + dir := t.TempDir() + st, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + db, err := sql.Open("sqlite", filepath.Join(dir, store.DBFile)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { db.Close() }) + _, err = db.Exec(`CREATE TRIGGER reject_change_outcome BEFORE UPDATE ON exec_logs + WHEN OLD.kind = 'change' BEGIN SELECT RAISE(FAIL, 'injected outcome failure'); END`) + if err != nil { + t.Fatal(err) + } + a := New(st, secrets.OpenFile(t.TempDir())) + t.Cleanup(actions.UseTestScripts(t.TempDir(), func(string) (string, error) { return fakeSwap, nil })) + srv := sshtest.Start(t, "root", "pw") + sv := addTestServer(t, a, srv, "pw") + ctx := context.Background() + if _, _, err := a.Discover(ctx, sv.ID, nil); err != nil { + t.Fatal(err) + } + step := map[string]any{"capability": "swap.set", "summary": "test", "params": map[string]any{"size_gb": 1}} + args, _ := json.Marshal(map[string]any{"server_id": sv.ID, "title": "test", "reason": "test", "steps": []map[string]any{step, step}}) + collector := &planCollector{} + if _, err := a.toolProposePlan(context.WithValue(ctx, planCollectorKey{}, collector), args); err != nil { + t.Fatal(err) + } + id := collector.ids[0] + if _, err := a.ExecutePlan(id, []int{0, 1}); err != nil { + t.Fatal(err) + } + v := waitPlan(t, a, id) + first := v.StepList[0] + if v.Status != core.PlanPartial || first.Status != actions.StatusDone || first.LogID != 0 || first.RollbackFile == "" || first.BackupDir == "" || v.StepList[1].Status != "skipped" { + t.Fatalf("incomplete fallback data or continued execution: %+v", v) + } + if _, err := os.Stat(first.RollbackFile); err != nil { + t.Fatal(err) + } + if _, err := a.UndoStep(ctx, id, 0); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(first.RollbackFile + ".done"); err != nil { + t.Fatalf("old rollback file not retired: %v", err) + } + if _, err := a.UndoStep(ctx, id, 0); err == nil { + t.Fatal("step rolled back twice") + } +} + +func TestPrepareStepsRejectsSuppliedRecoveryPaths(t *testing.T) { + steps := prepareSteps([]core.Step{{Capability: "swap.set", Params: map[string]any{"size_gb": 1}, BackupDir: "/untrusted", RollbackFile: "/untrusted/file"}}, "linux") + if steps[0].BackupDir != "" || steps[0].RollbackFile != "" { + t.Fatal("plan input supplied trusted recovery paths") + } +} diff --git a/internal/app/execlog.go b/internal/app/execlog.go index a721f16..4a86920 100644 --- a/internal/app/execlog.go +++ b/internal/app/execlog.go @@ -57,11 +57,23 @@ func (a *App) startExec(e store.ExecLog) store.ExecLog { return e } +// startChange is fail-closed: no server or cloud mutation may start unless +// its durable execution record exists first. +func (a *App) startChange(e store.ExecLog) (store.ExecLog, error) { + e.Status = store.ExecRunning + return a.Store.AddExec(e) +} + func (a *App) finishExec(e *store.ExecLog, status, output string) { e.Status, e.Output, e.FinishedAt = status, clip(output), now() if e.ID > 0 { _ = a.Store.UpdateExec(*e) } + a.invalidateExecCaches(e) +} + +// Invalidate even if saving failed: the operation may already have changed resources. +func (a *App) invalidateExecCaches(e *store.ExecLog) { if e.Kind != store.ExecRead { a.PagesChanged() // the pages show what it changed // A cloud server changed: its list is read again too. @@ -74,11 +86,13 @@ func (a *App) finishExec(e *store.ExecLog, status, output string) { } } -func (a *App) finishAction(e *store.ExecLog, out actions.Outcome) { +func (a *App) finishAction(e *store.ExecLog, out actions.Outcome) error { + defer a.invalidateExecCaches(e) e.Commands = strings.Join(out.Commands, "\n") e.ScriptName, e.Script = out.ScriptName, out.Script e.Undo, e.BackupDir, e.RollbackFile = out.Undo, out.BackupDir, out.RollbackFile - a.finishExec(e, out.Status, strings.Join(out.Log, "\n")) + e.Status, e.Output, e.FinishedAt = out.Status, clip(strings.Join(out.Log, "\n")), now() + return a.Store.UpdateExec(*e) } // paramText shows a step's parameters compactly, e.g. "size_gb=2". @@ -228,20 +242,30 @@ func (a *App) rollback(ctx context.Context, e *store.ExecLog) (store.ExecLog, er defer func() { env.SSH.Close() }() } - rb := a.startExec(store.ExecLog{ + rb, err := a.startChange(store.ExecLog{ ServerID: sv.ID, ServerName: sv.Name, Adapter: e.Adapter, Origin: OriginUser, Kind: store.ExecRollback, Title: "回滚:" + e.Title, Capability: e.Capability, Params: e.Params, Via: e.Via, PlanID: e.PlanID, StepIdx: e.StepIdx, UndoOf: e.ID, }) + if err != nil { + return store.ExecLog{}, userErr("无法写入回滚日志,已停止,服务器没有被修改:%v", err) + } out := actions.Undo(ctx, env, r, e.Undo) + var retireErr error if out.Status == actions.StatusUndone && e.RollbackFile != "" { - cmd := actions.RetireRollbackFile(ctx, env, e.RollbackFile) + cmd, err := actions.RetireRollbackFile(ctx, env, e.RollbackFile) out.Commands = append(out.Commands, "# 把服务器上的回滚文件改名,避免被再次执行", cmd) + if err != nil { + retireErr = err + out.Log = append(out.Log, err.Error()) + } } - a.finishAction(&rb, out) + logErr := a.finishAction(&rb, out) if out.Status == actions.StatusUndone && e.ID > 0 { e.UndoneBy = rb.ID - _ = a.Store.UpdateExec(*e) + if err := a.Store.UpdateExec(*e); err != nil && logErr == nil { + logErr = err + } } if e.PlanID > 0 { if v, err := a.Plan(e.PlanID); err == nil && e.StepIdx >= 0 && e.StepIdx < len(v.StepList) && v.StepList[e.StepIdx].LogID == e.ID { @@ -263,6 +287,12 @@ func (a *App) rollback(ctx context.Context, e *store.ExecLog) (store.ExecLog, er if out.Status != actions.StatusUndone { return rb, userErr("回滚没有成功:%s", strings.Join(out.Log, ";")) } + if logErr != nil { + return rb, userErr("回滚已经完成,但执行日志未能完整保存,请检查数据库和服务器上的回滚文件:%v", logErr) + } + if retireErr != nil { + return rb, userErr("%v;请确认服务器上的旧 rollback.sh,避免重复执行", retireErr) + } return rb, nil } diff --git a/internal/app/pages_test.go b/internal/app/pages_test.go index 83fca2b..3566f08 100644 --- a/internal/app/pages_test.go +++ b/internal/app/pages_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/bocmiao/CloudConsoleWithAI/internal/actions" "github.com/bocmiao/CloudConsoleWithAI/internal/store" ) @@ -100,7 +101,9 @@ func TestPreloadPages(t *testing.T) { } // A change (a checklist step finishing) shows the new state. e := a.startExec(store.ExecLog{ServerID: sv.ID, ServerName: sv.Name, Kind: store.ExecChange, Title: "test"}) - a.finishExec(&e, "ok", "") + if err := a.finishAction(&e, actions.Outcome{Status: actions.StatusDone}); err != nil { + t.Fatal(err) + } v, _, _ = a.WebsitesPage(ctx, sv.ID, PageLatest) if len(v.Servers[0].Sites) != 3 { t.Fatalf("after a change: %+v", v.Servers[0].Sites) diff --git a/internal/app/plans.go b/internal/app/plans.go index 7cb3b08..0d5b993 100644 --- a/internal/app/plans.go +++ b/internal/app/plans.go @@ -79,6 +79,7 @@ func prepareSteps(steps []core.Step, adapter string) []core.Step { s := &steps[i] s.Risk = core.RiskOf(s.Capability) s.Executable, s.Blocked, s.Status, s.Log, s.Undo, s.LogID = false, "", "", nil, nil, 0 + s.BackupDir, s.RollbackFile = "", "" r, err := actions.Resolve(s.Capability, s.Params, adapter) var ne *actions.ErrNotExecutable switch { @@ -476,11 +477,16 @@ func (a *App) runPlan(planID, serverID int64, who string) { if pt := paramText(r.Values); pt != "" { title += "(" + pt + ")" } - entry := a.startExec(store.ExecLog{ + entry, err := a.startChange(store.ExecLog{ ServerID: sv.ID, ServerName: serverNameFor(sv, st.Capability), Adapter: sv.Adapter, Origin: OriginPlan, Kind: store.ExecChange, Title: title, Note: st.Summary, Capability: st.Capability, Params: st.Params, Via: r.Impl.Via, Reversible: r.Cap.Reversible, PlanID: v.ID, StepIdx: i, }) + if err != nil { + st.Status, st.Log, ok = actions.StatusFailed, []string{"无法写入执行日志,已停止,这一步没有修改服务器:" + err.Error()}, false + _ = a.savePlan(&v) + continue + } st.Status, st.LogID = "running", entry.ID _ = a.savePlan(&v) out := actions.Apply(ctx, env, r, func(log []string) { @@ -490,8 +496,17 @@ func (a *App) runPlan(planID, serverID int64, who string) { if who == "user" && out.Status == actions.StatusDone && (st.Capability == "eo.ip.block" || st.Capability == "eo.ip.unblock") { a.forgetAutoBlocked(out.Result["zone"], strings.Split(r.Values["ips"], ",")) } - a.finishAction(&entry, out) + logErr := a.finishAction(&entry, out) st.Status, st.Log, st.Undo, st.FinishedAt = out.Status, out.Log, out.Undo, now() + st.BackupDir, st.RollbackFile = out.BackupDir, out.RollbackFile + if logErr != nil { + // The plan still carries the undo data, so let UndoStep use it + // instead of the incomplete execution-log row. Stop the rest of + // the plan because its durable audit trail is no longer healthy. + st.LogID = 0 + st.Log = append(st.Log, "执行结果未能写入执行日志,请检查数据库;本清单仍保留回滚数据:"+logErr.Error()) + ok = false + } if out.Status != actions.StatusDone { ok = false } @@ -541,11 +556,12 @@ func (a *App) UndoStep(ctx context.Context, planID int64, idx int) (PlanView, er return v, err } } else { - // Run before the execution log existed: the step has what we need. + // Older runs or a failed log update: the step keeps the recovery data. e = store.ExecLog{ ServerID: sv.ID, ServerName: serverNameFor(sv, st.Capability), Adapter: sv.Adapter, Kind: store.ExecChange, Title: st.Title, Capability: st.Capability, Params: st.Params, Via: st.Via, Status: st.Status, Undo: st.Undo, Reversible: st.Reversible, PlanID: planID, StepIdx: idx, + BackupDir: st.BackupDir, RollbackFile: st.RollbackFile, } } if e.UndoneBy > 0 { diff --git a/internal/core/plan.go b/internal/core/plan.go index 28a37c7..1f0907a 100644 --- a/internal/core/plan.go +++ b/internal/core/plan.go @@ -31,11 +31,13 @@ type Step struct { Reversible bool `json:"reversible"` // Execution state. - Status string `json:"status,omitempty"` // queued, running, done, refused, rolled_back, failed, skipped, undone - Log []string `json:"log,omitempty"` - Undo map[string]string `json:"undo,omitempty"` - FinishedAt string `json:"finishedAt,omitempty"` - LogID int64 `json:"logId,omitempty"` // execution log entry of the latest run + Status string `json:"status,omitempty"` // queued, running, done, refused, rolled_back, failed, skipped, undone + Log []string `json:"log,omitempty"` + Undo map[string]string `json:"undo,omitempty"` + FinishedAt string `json:"finishedAt,omitempty"` + BackupDir string `json:"backupDir,omitempty"` + RollbackFile string `json:"rollbackFile,omitempty"` + LogID int64 `json:"logId,omitempty"` // execution log entry of the latest run // Free is how Miao Panel vetted an AI-written command; set by the // system when the plan is saved, never taken from the AI. diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js index ef44ee6..1eacd5c 100644 --- a/internal/webui/static/app.js +++ b/internal/webui/static/app.js @@ -7209,13 +7209,13 @@ const AccountPanel = { } async function beginTOTP() { tf.error = ''; tf.busy = true; - try { tf.setup = await api('POST', '/api/account/totp'); tf.code = ''; } catch (e) { notify(e.message, 'error'); } finally { tf.busy = false; } + try { tf.setup = await api('POST', '/api/account/totp', { password: tf.password }); tf.code = ''; } catch (e) { tf.error = e.message; } finally { tf.busy = false; } } async function enableTOTP() { tf.error = ''; tf.busy = true; try { - await api('PUT', '/api/account/totp', { code: tf.code }); - tf.setup = null; + await api('PUT', '/api/account/totp', { code: tf.code, password: tf.password }); + Object.assign(tf, { setup: null, off: false, password: '', code: '' }); notify('两步验证已开启,以后登录要输入 App 里的验证码'); load(); } catch (e) { tf.error = e.message; } finally { tf.busy = false; } @@ -7229,6 +7229,7 @@ const AccountPanel = { load(); } catch (e) { tf.error = e.message; } finally { tf.busy = false; } } + function cancelTOTP() { Object.assign(tf, { setup: null, off: false, password: '', code: '', error: '' }); } async function endSession(s) { try { await api('DELETE', `/api/account/sessions/${s.key}`); notify('已让这个设备退出登录'); load(); } catch (e) { notify(e.message, 'error'); } } @@ -7388,7 +7389,7 @@ const AccountPanel = { }); const SMS_REGIONS = [['ap-guangzhou', '广州'], ['ap-beijing', '北京'], ['ap-nanjing', '南京']]; - return { acct, pw, tf, savePassword, beginTOTP, enableTOTP, disableTOTP, endSession, logout, when, uaText, + return { acct, pw, tf, savePassword, beginTOTP, enableTOTP, disableTOTP, cancelTOTP, endSession, logout, when, uaText, methodBusy, setMethod, methodNote, canOn, bd, openBind, bindSend, bindConfirm, unbind, scope, scopeOff, setScope, ml, openMail, mailPreset, mailRun, sm, openSMS, smsRun, smsFilled, mailText, smsText, SMS_REGIONS }; }, @@ -7405,8 +7406,13 @@ const AccountPanel = {
两步验证 {{ acct.totp ? '已开启:登录时还要输入手机 App 里的验证码' : '未开启。建议开启:就算密码泄露,别人也登录不了' }} - +
+
输入密码开启 + + +
+
{{ tf.error }}
两步验证二维码
@@ -7415,15 +7421,15 @@ const AccountPanel = {

2. 输入 App 显示的 6 位验证码:

-
+

{{ tf.error }}

-
输入密码关闭 +
输入密码关闭
-
{{ tf.error }}
+
{{ tf.error }}