From d18670c67f2802d63b9ec8f8dea06406ed8c0759 Mon Sep 17 00:00:00 2001 From: Miao Date: Sun, 27 Sep 2026 18:29:47 +0800 Subject: [PATCH 1/4] Close production E2E acceptance gaps --- .github/workflows/ci.yml | 68 ++++++++++++---- README.md | 4 +- docker-compose.yml | 11 +-- docs/DEPLOY.md | 29 ++++++- internal/actions/actions_test.go | 4 +- internal/actions/exec.go | 18 ++++- internal/api/server_test.go | 34 ++++++++ internal/api/session.go | 12 ++- internal/app/app_test.go | 14 ++++ internal/app/execlog.go | 36 +++++++-- internal/app/plans.go | 17 +++- internal/webui/static/app.js | 22 ++++-- scripts/browser_smoke.py | 128 +++++++++++++++++++++++++------ 13 files changed, 322 insertions(+), 75 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4b8e795..458c581 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,37 +88,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 '"setup":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 browser setup and password login + curl -kfsS -H 'X-Miao: 1' https://127.0.0.1:18443/api/auth/state | grep -q '"setup":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 .' + sha256sum /tmp/miaopanel-backup/miaopanel.tar.gz > /tmp/miaopanel-backup/miaopanel.tar.gz.sha256 + 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 e7e2981..fcf8c86 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 分钟保险」自动恢复。 完整设计见 [docs/DESIGN.md](docs/DESIGN.md)。 @@ -93,7 +93,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 b875755..7ffe2c8 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 2ceb1aa..b8be68f 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -19,7 +19,7 @@ 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` 里,删除容器不会丢;**删除这个卷就全没了**。 升级:`git pull && docker compose up -d --build`。 @@ -37,12 +37,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 .' +sha256sum backups/miaopanel-backup.tar.gz > backups/miaopanel-backup.tar.gz.sha256 docker compose start miaopanel ``` 在新机器或**新的、空的数据卷**里恢复。把备份放在新项目目录的 `backups/` 下,先不要运行 `docker compose up`: ```bash +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' @@ -54,10 +56,11 @@ systemd 版同样先停服务,再将整个数据目录打包: ```bash sudo systemctl stop miaopanel sudo sh -c 'umask 077; tar -C /var/lib/miaopanel -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 chown -R miaopanel:miaopanel /var/lib/miaopanel`,然后启动服务。恢复演练要检查原账号能登录、服务器列表和密钥可用、执行日志仍在。不要把含密钥的备份提交到 GitHub。 +在新机器的空数据目录恢复时,先安装好服务但不要启动,先用 `sha256sum -c` 检查备份,再解包,运行 `sudo chown -R miaopanel:miaopanel /var/lib/miaopanel`,然后启动服务。恢复演练要检查原账号和两步验证能登录、服务器列表和密钥可用、执行日志及回滚信息仍在。至少保留一份不在原服务器磁盘上的备份。不要把含密钥的备份提交到 GitHub。 ## 方式二:直接运行程序(systemd) @@ -85,6 +88,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; @@ -99,9 +103,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`。 +直接运行程序且不想用反向代理时,也可以让 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 执行日志和审计记录一致。不要用生产资源做首次演练。 | + +把日期、版本或提交号、代理地址、测试资源、日志 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 9f68ebc..34d494d 100644 --- a/internal/actions/exec.go +++ b/internal/actions/exec.go @@ -367,17 +367,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 cfc07e5..6538c76 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/execlog.go b/internal/app/execlog.go index 0a4a585..ca71785 100644 --- a/internal/app/execlog.go +++ b/internal/app/execlog.go @@ -57,6 +57,13 @@ 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 { @@ -64,11 +71,12 @@ 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 { 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". @@ -215,20 +223,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 { @@ -250,6 +268,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/plans.go b/internal/app/plans.go index b4414f0..96ad1e8 100644 --- a/internal/app/plans.go +++ b/internal/app/plans.go @@ -400,11 +400,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: sv.Name, 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) { @@ -414,8 +419,16 @@ 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() + 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 } diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js index e10f889..877d032 100644 --- a/internal/webui/static/app.js +++ b/internal/webui/static/app.js @@ -4282,13 +4282,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; } @@ -4302,6 +4302,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'); } } @@ -4461,7 +4462,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 }; }, @@ -4478,8 +4479,13 @@ const AccountPanel = {
两步验证 {{ acct.totp ? '已开启:登录时还要输入手机 App 里的验证码' : '未开启。建议开启:就算密码泄露,别人也登录不了' }} - +
+
输入密码开启 + + +
+
{{ tf.error }}
两步验证二维码
@@ -4488,15 +4494,15 @@ const AccountPanel = {

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

-
+

{{ tf.error }}

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