收口超级管理员本机重置入口

关闭历史命令行参数入口,更新 Windows 安装器、Linux 运维说明和整改清单,保持密码重置仅通过本机受保护终端执行。
This commit is contained in:
xuans 2026-10-04 13:48:06 +08:00
parent 55c500c1a8
commit b95262f6c0
5 changed files with 22 additions and 89 deletions

View File

@ -1,67 +1,4 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/admin"
"git.pcux.cn/xuans/Scert/backend/internal/config"
)
func writeSessionInfo(writer io.Writer, session admin.Session) {
fmt.Fprintln(writer, "Scert-admin 已启用临时密码重置入口,不监听独立网络端口。")
fmt.Fprintln(writer, "重置地址:", session.State.ResetURL)
fmt.Fprintln(writer, "一次性校验码已写入受保护的临时文件:", session.State.CodePath)
fmt.Fprintln(writer, "请使用 root 或 Scert 用户读取该文件;密码重置完成后文件会自动销毁。")
fmt.Fprintln(writer, "停止 Scert-admin 后入口将立即关闭;异常退出时入口会在租约过期后自动关闭。")
}
func legacyMain() {
baseDir := flag.String("base-dir", "", "Windows 默认安装根目录")
configPath := flag.String("config", "", "配置文件路径")
codeOutput := flag.String("code-output", "", "一次性校验码文件路径")
flag.Parse()
path := *configPath
if path == "" {
path = filepath.Join(config.DefaultPaths(*baseDir).Config, "scert.json")
}
cfg, err := config.LoadWithEnv(path)
if err != nil {
fmt.Fprintln(os.Stderr, "读取配置失败:", err)
os.Exit(1)
}
controller := admin.NewWithCodePath(cfg, *codeOutput)
session, err := controller.Start()
if err != nil {
fmt.Fprintln(os.Stderr, "启用密码重置入口失败:", err)
os.Exit(1)
}
writeSessionInfo(os.Stdout, session)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ticker := time.NewTicker(admin.RefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
if err := controller.Stop(); err != nil {
fmt.Fprintln(os.Stderr, "关闭密码重置入口失败:", err)
os.Exit(1)
}
fmt.Println("Scert-admin 已关闭密码重置入口。")
return
case <-ticker.C:
if err := controller.Refresh(); err != nil {
fmt.Fprintln(os.Stderr, "续期密码重置入口租约失败:", err)
_ = controller.Stop()
os.Exit(1)
}
}
}
}
// 旧版参数入口保留文件以兼容历史构建路径,实际程序入口位于 entry.go。
// Scert-admin 只允许无参数的本机受保护终端,不再保留公网重置租约和校验码文件流程。

View File

@ -1,27 +1,19 @@
package main
import (
"bytes"
"context"
"strings"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/admin"
"git.pcux.cn/xuans/Scert/backend/internal/adminconsole"
)
func TestSessionInfoDoesNotExposeResetCode(t *testing.T) {
const secretCode = "ABCDE-12345-FGHIJ"
var output bytes.Buffer
writeSessionInfo(&output, admin.Session{
State: admin.State{
ResetURL: "https://scert.example.test/reset",
CodePath: "/run/scert-admin/reset-code",
},
Code: secretCode,
})
if strings.Contains(output.String(), secretCode) {
t.Fatal("命令行输出不得包含一次性校验码")
func TestAdminRejectsLegacyArguments(t *testing.T) {
err := adminconsole.Run(context.Background(), []string{"--config", "scert.json"})
if err == nil {
t.Fatal("Scert-admin 不得接受历史命令行参数")
}
if !strings.Contains(output.String(), "/run/scert-admin/reset-code") {
t.Fatal("命令行输出必须提示受保护校验码文件路径")
if !strings.Contains(err.Error(), "不接受参数") {
t.Fatalf("错误信息未说明参数入口已关闭:%v", err)
}
}

View File

@ -221,9 +221,8 @@ foreach ($directory in @($data, $configDirectory, $databaseDirectory, $logDirect
Set-DirectoryAcl -Path $docsDirectory -Mode read
$configurationPath = Join-Path $configDirectory "scert.json"
$resetCodePath = Join-Path $runtimeDirectory "reset-code"
$mainBinaryPath = '"{0}" --config "{1}" serve' -f $installedScert, $configurationPath
$adminBinaryPath = '"{0}" --config "{1}" --code-output "{2}"' -f $installedAdmin, $configurationPath, $resetCodePath
$adminBinaryPath = '"{0}"' -f $installedAdmin
Install-OrUpdateService `
-Name $ServiceName `
@ -235,7 +234,7 @@ Install-OrUpdateService `
Install-OrUpdateService `
-Name $AdminServiceName `
-DisplayName "Scert-admin 临时密码重置服务" `
-Description "仅在管理员手动启动时临时激活 Scert 密码重置入口" `
-Description "仅在管理员手动启动时打开本机受保护管理终端" `
-BinaryPath $adminBinaryPath `
-StartMode demand
@ -254,7 +253,7 @@ $metadata | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $metadataPath -En
Write-Host "Scert Windows 文件安装和服务注册完成。"
Write-Host "核心程序:$installedScert"
Write-Host "密码重置程序:$installedAdmin"
Write-Host "本机管理程序:$installedAdmin"
Write-Host "配置目录:$configDirectory"
Write-Host "数据目录:$databaseDirectory"
Write-Host "日志目录:$logDirectory"
@ -262,5 +261,5 @@ Write-Host "备份目录:$backupDirectory"
Write-Host "证书目录:$certificate"
Write-Host "安装记录:$metadataPath"
Write-Host "Scert 主服务已注册为自动启动,但本安装器没有启动或重启服务。"
Write-Host "Scert-admin 已注册为手动启动,本安装器不会启动它,也不会创建公网防火墙规则。"
Write-Host "Scert-admin 已注册为手动启动,仅通过提升权限的本机终端运行;本安装器不会启动它,也不会创建公网防火墙规则。"
Write-Host "首次初始化时请使用配置文件路径:$configurationPath"

View File

@ -29,11 +29,11 @@ systemctl daemon-reload
## 日常服务控制
核心服务的启停应由初始化和运维流程根据配置状态执行。核心服务固定注册 `/reset` 路由,但未检测到有效的 `Scert-admin` 激活租约时统一返回 `404`。`Scert-admin` 不监听独立端口,也不能作为核心服务的自动启动依赖。
核心服务不注册公网密码重置路由,`/reset` 和 `/password-reset` 只显示安全阻断提示,不查询账户、不发送验证码、不接收新密码,也不创建公网重置会话。`Scert-admin` 不监听独立端口,也不能作为核心服务的自动启动依赖。
密码重置地址根据初始化时配置的 Web 管理域名或公网 IP 自动生成,始终使用 `https://域名或IP/reset`,不在公开地址中暴露主服务监听端口。云安全组和本机防火墙都不需要额外放行密码重置端口。
超级管理员重置只能由部署主机上的运维人员在提升权限的本机终端中显式启动 `Scert-admin`,通过受保护的 Unix Socket 或 Windows Named Pipe 与核心服务通信。验证码由系统通知账户发送到已绑定邮箱,新密码只在本机终端输入,不写入参数、环境变量、文件或服务日志。
`/reset` 页面只有在 `Scert-admin` 显式启动且租约有效时开放。提交时必须提供一次性校验码、CSRF 校验值和两次一致的新密码;校验码成功消费后立即失效,密码摘要使用 bcrypt 保存。正式生产部署仍需完成核心服务真实 HTTPS 终止、受保护校验码呈现和目标平台实机验收。
云安全组和本机防火墙不需要为密码重置额外放行端口;公网入口继续只使用 Nginx 的 80/443。完成操作后停止 `Scert-admin`,本机重置会话随即关闭。
## 默认目录

View File

@ -11,6 +11,11 @@
本项未开放公网端口,未操作第二台服务器,未创建辅助验收脚本,未读取或记录任何密码、验证码或邮件密钥。Windows 环境不在本轮范围。
- [x] `Scert-admin` 旧版参数入口已退出生产构建路径,不再接受 `--config`、`--code-output` 或其他参数;Windows 安装器已改为注册无参数的本机管理程序。
- [x] Linux 运维说明已删除公网 `/reset` 激活租约、重置地址和校验码文件描述,明确 Unix Socket/Windows Named Pipe、本机终端和系统通知账户边界。
- [x] `go test ./...` 全量回归通过,`git diff --check` 通过;本次改动尚未完成第一台实机部署验收。
- [~] 待提交本轮安装器、CLI 旧入口和运维说明收口,并重新打包部署第一台后验证 Web 阻断页面、本机邮件验证码和新密码登录闭环。
## 2026年10月4日11:07 权限回归与第一台终态复核
- [✓] 邮件通知配置继续仅允许超管读写;前端直接调用设置入口也拒绝普通管理员和用户,并清除未保存密码。新增角色矩阵、降权、停用和删除会话回归通过,拒绝请求没有配置或秘密仓库副作用。