修复数据库维护凭据与不完整备份误报并同步首次初始化部署说明
This commit is contained in:
parent
5509a611f0
commit
f3cce10664
@ -38,6 +38,9 @@ type backupManifestFile struct {
|
||||
}
|
||||
|
||||
func createBackup(ctx context.Context, cfg config.Config, configPath, output string) error {
|
||||
if err := validateBackupDriver(cfg.Database.Driver); err != nil {
|
||||
return err
|
||||
}
|
||||
output = filepath.Clean(output)
|
||||
if err := ensureBackupDestination(output); err != nil {
|
||||
return err
|
||||
@ -207,6 +210,9 @@ func sqliteDatabasePath(dsn string) (string, bool, error) {
|
||||
}
|
||||
|
||||
func validateRestoreCompatibility(current, backup config.Config) error {
|
||||
if err := validateBackupDriver(backup.Database.Driver); err != nil {
|
||||
return err
|
||||
}
|
||||
if current.Database.Driver != backup.Database.Driver {
|
||||
return fmt.Errorf("备份数据库类型(%s)与当前配置数据库类型(%s)不一致,拒绝恢复", backup.Database.Driver, current.Database.Driver)
|
||||
}
|
||||
@ -248,7 +254,7 @@ func validateBackupSecretStore(ctx context.Context, directory string, cfg config
|
||||
if secretsExists {
|
||||
return errors.New("敏感配置包含 secrets.json,但缺少 master.key")
|
||||
}
|
||||
if cfg.SetupReady {
|
||||
if cfg.SetupReady || strings.TrimSpace(cfg.Database.CredentialRef) != "" {
|
||||
return errors.New("已初始化配置缺少 master.key")
|
||||
}
|
||||
return nil
|
||||
@ -268,7 +274,7 @@ func validateBackupSecretStore(ctx context.Context, directory string, cfg config
|
||||
}
|
||||
available[item.Reference] = struct{}{}
|
||||
}
|
||||
for _, reference := range []string{cfg.DNS.CredentialRef, cfg.Notification.HTTPCredentialRef, cfg.Notification.SMTPCredentialRef} {
|
||||
for _, reference := range []string{cfg.Database.CredentialRef, cfg.DNS.CredentialRef, cfg.Notification.HTTPCredentialRef, cfg.Notification.SMTPCredentialRef} {
|
||||
reference = strings.TrimSpace(reference)
|
||||
if reference == "" {
|
||||
continue
|
||||
|
||||
40
backend/internal/cli/database_maintenance.go
Normal file
40
backend/internal/cli/database_maintenance.go
Normal file
@ -0,0 +1,40 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/database"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||||
)
|
||||
|
||||
func validateBackupDriver(driver string) error {
|
||||
switch driver {
|
||||
case "sqlite", "mysql":
|
||||
return nil
|
||||
case "postgres":
|
||||
return errors.New("PostgreSQL 完整备份与恢复尚未实现,拒绝生成或恢复不含数据库的备份;请使用数据库原生工具并单独保护配置、加密凭据和证书")
|
||||
default:
|
||||
return errors.New("当前数据库类型不支持备份与恢复")
|
||||
}
|
||||
}
|
||||
|
||||
// 返回值仅供本次维护操作连接数据库,禁止写入配置、备份清单或日志。
|
||||
func resolveMaintenanceDatabase(ctx context.Context, cfg config.DatabaseConfig, directory string) (config.DatabaseConfig, error) {
|
||||
if err := database.ValidateConfig(cfg); err != nil {
|
||||
return config.DatabaseConfig{}, errors.New("数据库维护配置无效,请检查数据库类型与加密凭据引用")
|
||||
}
|
||||
if cfg.Driver == "sqlite" {
|
||||
return cfg, nil
|
||||
}
|
||||
store, err := secretstore.Open(directory)
|
||||
if err != nil {
|
||||
return config.DatabaseConfig{}, errors.New("无法打开数据库维护凭据存储,请检查目录及主密钥权限")
|
||||
}
|
||||
resolved, err := database.ResolveConfig(ctx, cfg, store)
|
||||
if err != nil {
|
||||
return config.DatabaseConfig{}, errors.New("无法读取数据库维护账号,请检查加密凭据及主密钥")
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
130
backend/internal/cli/database_maintenance_test.go
Normal file
130
backend/internal/cli/database_maintenance_test.go
Normal file
@ -0,0 +1,130 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-sql-driver/mysql"
|
||||
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
|
||||
)
|
||||
|
||||
func TestUnsupportedBackupDriverFailsBeforeFilesystemChanges(t *testing.T) {
|
||||
for _, driver := range []string{"postgres", "unknown", ""} {
|
||||
t.Run(driver, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
cfg := config.Config{Database: config.DatabaseConfig{Driver: driver}}
|
||||
output := filepath.Join(root, "not-created", "backup")
|
||||
err := createBackup(t.Context(), cfg, filepath.Join(root, "scert.json"), output)
|
||||
if err == nil || !strings.Contains(err.Error(), "备份") {
|
||||
t.Fatal("不支持的数据库不能报告备份成功")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(output)); !os.IsNotExist(err) {
|
||||
t.Fatal("拒绝备份前不应创建输出目录")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresRestoreFailsBeforeReplacingExistingConfig(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
input := filepath.Join(root, "input")
|
||||
if err := os.Mkdir(input, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Default(root)
|
||||
// 显式覆盖路径,避免 Linux 测试使用正式安装目录。
|
||||
cfg.Paths = config.PathsConfig{
|
||||
Install: root, Config: filepath.Join(root, "conf"), Data: filepath.Join(root, "data"),
|
||||
Certificate: filepath.Join(root, "ssl"), Log: filepath.Join(root, "log"), Backup: filepath.Join(root, "backups"),
|
||||
}
|
||||
cfg.Database = config.DatabaseConfig{
|
||||
Driver: "postgres", DSN: "postgres://db.example.test:5432/scert?sslmode=verify-full",
|
||||
CredentialRef: "secret://database/restore-test",
|
||||
MaxOpenConns: 4, MaxIdleConns: 2, ConnMaxLifetimeMins: 30,
|
||||
}
|
||||
if err := cfg.Save(filepath.Join(input, "scert.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := writeBackupManifest(input); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
currentPath := filepath.Join(root, "current.json")
|
||||
const original = "preserve-current-config"
|
||||
if err := os.WriteFile(currentPath, []byte(original), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
previous := configPath
|
||||
configPath = currentPath
|
||||
t.Cleanup(func() { configPath = previous })
|
||||
command := newRestoreCommand()
|
||||
var output strings.Builder
|
||||
command.SetOut(&output)
|
||||
command.SetErr(&output)
|
||||
command.SetArgs([]string{"--input", input})
|
||||
if err := command.Execute(); err == nil || !strings.Contains(err.Error(), "PostgreSQL 完整备份与恢复尚未实现") {
|
||||
t.Fatal("未支持的 PostgreSQL 恢复没有明确拒绝")
|
||||
}
|
||||
content, err := os.ReadFile(currentPath)
|
||||
if err != nil || string(content) != original {
|
||||
t.Fatal("拒绝恢复时改变了现有配置")
|
||||
}
|
||||
if _, err := os.Stat(cfg.Paths.Data); !os.IsNotExist(err) {
|
||||
t.Fatal("拒绝恢复前不应创建锁目录或数据库")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaintenanceResolvesEncryptedMySQLCredentialsOnlyInMemory(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
store, err := secretstore.Open(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.DatabaseConfig{
|
||||
Driver: "mysql", DSN: "tcp(db.example.test:3306)/scert?tls=true",
|
||||
CredentialRef: "secret://database/maintenance-test",
|
||||
}
|
||||
const password = "maintenance-test-password"
|
||||
if err := store.Put(t.Context(), cfg.CredentialRef, map[string]string{"username": "backup-user", "password": password}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := os.ReadFile(filepath.Join(directory, "secrets.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := resolveMaintenanceDatabase(t.Context(), cfg, directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parsed, err := mysql.ParseDSN(resolved.DSN)
|
||||
if err != nil || parsed.User != "backup-user" || parsed.Passwd != password || parsed.TLSConfig != "true" {
|
||||
t.Fatal("维护连接没有正确补全加密凭据或保留 TLS 设置")
|
||||
}
|
||||
if strings.Contains(cfg.DSN, "backup-user") || strings.Contains(cfg.DSN, password) {
|
||||
t.Fatal("原始配置被写入数据库账号密码")
|
||||
}
|
||||
after, err := os.ReadFile(filepath.Join(directory, "secrets.json"))
|
||||
if err != nil || !bytes.Equal(before, after) || bytes.Contains(after, []byte(password)) {
|
||||
t.Fatal("维护连接改变了敏感文件或泄露明文密码")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackupValidatesDatabaseCredentialReference(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := createTestSecretStore(directory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Config{Database: config.DatabaseConfig{CredentialRef: "secret://database/missing"}}
|
||||
if err := validateBackupSecretStore(t.Context(), directory, cfg); err == nil {
|
||||
t.Fatal("备份不能忽略缺失的数据库加密凭据")
|
||||
}
|
||||
if _, err := resolveMaintenanceDatabase(t.Context(), config.DatabaseConfig{
|
||||
Driver: "mysql", DSN: "tcp(db.example.test:3306)/scert?tls=true", CredentialRef: cfg.Database.CredentialRef,
|
||||
}, directory); err == nil || strings.Contains(err.Error(), cfg.Database.CredentialRef) {
|
||||
t.Fatal("维护连接缺失凭据时必须失败且不泄露完整引用")
|
||||
}
|
||||
}
|
||||
@ -17,7 +17,15 @@ import (
|
||||
|
||||
// backupMySQL 将 MySQL 的表结构和数据导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。
|
||||
func backupMySQL(ctx context.Context, cfg config.Config, output string) error {
|
||||
db, err := database.Open(ctx, cfg.Database)
|
||||
resolved, err := resolveMaintenanceDatabase(ctx, cfg.Database, cfg.Paths.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return backupMySQLDatabase(ctx, resolved, output)
|
||||
}
|
||||
|
||||
func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output string) error {
|
||||
db, err := database.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开 MySQL 备份连接失败:%w", err)
|
||||
}
|
||||
@ -84,7 +92,7 @@ func backupMySQL(ctx context.Context, cfg config.Config, output string) error {
|
||||
|
||||
// createMySQLRestoreBackup 在恢复前保留目标数据库快照,供失败时回滚使用。
|
||||
// 快照目录不会在恢复成功后自动删除,便于管理员在需要时继续排查或手动恢复。
|
||||
func createMySQLRestoreBackup(ctx context.Context, cfg config.Config) (string, error) {
|
||||
func createMySQLRestoreBackup(ctx context.Context, cfg config.Config, connection config.DatabaseConfig) (string, error) {
|
||||
backupRoot := filepath.Clean(strings.TrimSpace(cfg.Paths.Backup))
|
||||
if backupRoot == "." || backupRoot == "" {
|
||||
return "", fmt.Errorf("MySQL 恢复前备份目录不能为空")
|
||||
@ -107,7 +115,7 @@ func createMySQLRestoreBackup(ctx context.Context, cfg config.Config) (string, e
|
||||
return "", fmt.Errorf("创建 MySQL 恢复快照目录失败:%w", err)
|
||||
}
|
||||
rollbackPath := filepath.Join(rollbackDirectory, "scert.sql")
|
||||
if err := backupMySQL(ctx, cfg, rollbackPath); err != nil {
|
||||
if err := backupMySQLDatabase(ctx, connection, rollbackPath); err != nil {
|
||||
return "", fmt.Errorf("创建 MySQL 恢复前快照失败:%w", err)
|
||||
}
|
||||
return rollbackPath, nil
|
||||
@ -225,12 +233,12 @@ func mysqlStringLiteral(value string) string {
|
||||
}
|
||||
|
||||
// restoreMySQL 执行 Scert 生成的 MySQL SQL 备份。
|
||||
func restoreMySQL(ctx context.Context, cfg config.Config, input string) error {
|
||||
func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string) error {
|
||||
script, err := os.ReadFile(input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取 MySQL 备份文件失败:%w", err)
|
||||
}
|
||||
db, err := database.Open(ctx, cfg.Database)
|
||||
db, err := database.Open(ctx, cfg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("打开 MySQL 恢复连接失败:%w", err)
|
||||
}
|
||||
|
||||
@ -369,6 +369,9 @@ func newRestoreCommand() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("读取备份配置失败:%w", err)
|
||||
}
|
||||
if err := validateBackupDriver(cfg.Database.Driver); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateBackupSecretStore(cmd.Context(), input, cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -444,6 +447,20 @@ func newRestoreCommand() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
// 当前账号用于恢复前快照、导入和回滚,不能因替换配置或主密钥而丢失。
|
||||
// 无当前配置时才使用已校验的备份凭据;连接信息始终仅保留在内存。
|
||||
var mysqlConnection config.DatabaseConfig
|
||||
if cfg.Database.Driver == "mysql" {
|
||||
connectionConfig, secretDirectory := cfg.Database, input
|
||||
if currentConfigErr == nil {
|
||||
connectionConfig, secretDirectory = currentConfig.Database, currentConfig.Paths.Config
|
||||
}
|
||||
mysqlConnection, err = resolveMaintenanceDatabase(cmd.Context(), connectionConfig, secretDirectory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
configBackupPath := ""
|
||||
if isRegularFile(currentPath) {
|
||||
configBackupPath = currentPath + ".before-restore-" + time.Now().UTC().Format("20060102-150405.000000000")
|
||||
@ -462,7 +479,7 @@ func newRestoreCommand() *cobra.Command {
|
||||
|
||||
mysqlBackupPath := ""
|
||||
if cfg.Database.Driver == "mysql" {
|
||||
mysqlBackupPath, err = createMySQLRestoreBackup(cmd.Context(), cfg)
|
||||
mysqlBackupPath, err = createMySQLRestoreBackup(cmd.Context(), cfg, mysqlConnection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -474,7 +491,7 @@ func newRestoreCommand() *cobra.Command {
|
||||
rollbackCore := func() error {
|
||||
var rollbackErr error
|
||||
if mysqlRestoreAttempted && mysqlBackupPath != "" {
|
||||
rollbackErr = errors.Join(rollbackErr, restoreMySQL(cmd.Context(), cfg, mysqlBackupPath))
|
||||
rollbackErr = errors.Join(rollbackErr, restoreMySQL(cmd.Context(), mysqlConnection, mysqlBackupPath))
|
||||
}
|
||||
if sqliteRestoreAttempted {
|
||||
if databaseBackupPath != "" {
|
||||
@ -505,7 +522,7 @@ func newRestoreCommand() *cobra.Command {
|
||||
}
|
||||
if cfg.Database.Driver == "mysql" {
|
||||
mysqlRestoreAttempted = true
|
||||
if err := restoreMySQL(cmd.Context(), cfg, mysqlSource); err != nil {
|
||||
if err := restoreMySQL(cmd.Context(), mysqlConnection, mysqlSource); err != nil {
|
||||
return errors.Join(fmt.Errorf("恢复 MySQL 数据库失败:%w", err), rollbackCore())
|
||||
}
|
||||
}
|
||||
|
||||
@ -65,7 +65,22 @@ sudo systemctl start Scert
|
||||
|
||||
## 安装后流程
|
||||
|
||||
安装完成后先运行初始化流程,填写超级管理员、邮件、DNS API、端口和证书路径配置。初始化流程负责 DNS API 验证、Web 证书申请、部署校验和主服务端口防火墙处理。
|
||||
安装完成后先启动主服务,再通过服务器 IP 访问独立 HTTPS 初始化页面,无需 Linux 图形界面:
|
||||
|
||||
```bash
|
||||
sudo systemctl enable Scert
|
||||
sudo systemctl start Scert
|
||||
sudo systemctl status Scert --no-pager -l
|
||||
sudo cat /run/scert/bootstrap-url
|
||||
```
|
||||
|
||||
默认临时监听端口是 `8080`,须由管理员预先在主机防火墙和云安全组中对受信任的管理来源放行。云 NAT 环境应在启动前通过 systemd 的 `SCERT_BOOTSTRAP_PUBLIC_IP` 设置可访问的公网 IP;它只影响临时地址和证书,不会创建网络规则。
|
||||
|
||||
在浏览器打开受保护文件内的完整 HTTPS 链接。链接片段是短期一次性令牌,不要存入普通文档、日志或截图。临时证书为自签名,不能作为公共 CA 信任或生产 TLS 通过的证据;当前版本的可信证书指纹核对流程仍待完善,只能先在受控管理网络验证。
|
||||
|
||||
向导提供 SQLite、MySQL、PostgreSQL 三种存储选项,外部数据库强制 TLS 证书及主机名校验。数据库和阿里云 DNS 验证通过后可提交管理员、通知及正式域名/IP 配置;完成后临时服务关闭,自动加载正式配置并跳转正式 HTTPS 地址。IP 管理证书目前仍为自签名。
|
||||
|
||||
当前初始化仍调用本机防火墙管理器,非特权 systemd 下的权限失败处理尚待实机验收;不能为通过初始化而把服务改成 root。反向代理的回环监听配置应在 IP 初始化结束后再应用。具体顺序和限制见 `doc/生产部署与运维说明.md` 第 4.1 节。
|
||||
|
||||
`Scert-admin` 默认保持停止状态。确有密码重置需求时,仅使用:
|
||||
|
||||
@ -205,6 +220,8 @@ server {
|
||||
|
||||
完整备份包含配置、主密钥、加密敏感配置、SQLite 一致性快照或 MySQL 导出、证书目录以及带 SHA-256 的 `manifest.json`。备份目录必须由 `Scert` 用户控制并保持 `0700`,不得放入 Web 根目录或公开共享目录。
|
||||
|
||||
PostgreSQL 的内置完整备份恢复尚未实现,`backup`、`restore` 会明确拒绝,不会遗漏数据库却报告成功。三库初始化可用不等于三库运维能力已完整验收;选择 PostgreSQL 时仍须补齐原生数据库备份、配置/凭据/证书保护及恢复演练,当前不能声明这一能力已具备生产准入。
|
||||
|
||||
备份和恢复都会获取跨进程维护锁;如果主服务仍在运行会直接拒绝。运维时仍必须显式停服,避免外部工具或尚未纳入锁协议的操作修改证书目录:
|
||||
|
||||
```bash
|
||||
|
||||
@ -88,15 +88,43 @@ chmod +x install-linux.sh scert scert-admin
|
||||
sudo ./install-linux.sh --source-dir . --certificate-dir /ssl
|
||||
```
|
||||
|
||||
安装器不会自动启动服务,也不会创建公网防火墙规则。完成初始化并核对配置后执行:
|
||||
安装器不会自动启动服务,也不会创建公网防火墙或云安全组规则。首次安装还没有正式配置时,应先启动服务,再通过独立 Bootstrap HTTPS 页面初始化;不需要 Linux 图形界面。
|
||||
|
||||
默认初始化监听 `0.0.0.0:8080`。管理员应预先在主机防火墙和云安全组中只对受信任的管理来源放行该端口。如果已有端口占用,先设置 `SCERT_SERVER_PORT` 使用空闲端口。云主机公网 IP 不在本机网卡上时,先使用 `sudo systemctl edit Scert` 写入以下配置(示例 IP 必须替换为实际管理机 IP):
|
||||
|
||||
```ini
|
||||
[Service]
|
||||
Environment="SCERT_BOOTSTRAP_PUBLIC_IP=203.0.113.20"
|
||||
```
|
||||
|
||||
`SCERT_BOOTSTRAP_PUBLIC_IP` 只决定临时初始化地址和证书的 IP,不会改变监听地址、云安全组或最终管理地址。命令行运行时对应参数是 `scert serve --bootstrap-public-ip <服务器公网IP>`。修改 systemd 配置后执行:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable Scert
|
||||
sudo systemctl start Scert
|
||||
sudo systemctl status Scert --no-pager -l
|
||||
sudo cat /run/scert/bootstrap-url
|
||||
```
|
||||
|
||||
通过可信运维通道读取并在浏览器打开完整 HTTPS 地址,不能只复制 IP 部分。链接片段包含短期一次性令牌;不要复制到日志、截图、工单或普通文档。地址文件仅允许服务用户和管理员读取,完成初始化或服务退出后会清理;令牌或会话过期时重启尚未初始化的服务再读取新地址,不要删除正式配置强制重新初始化。
|
||||
|
||||
临时 HTTPS 使用自签名证书,不代表公共 CA 信任。当前版本尚缺少可由可信运维通道直接对照的证书指纹,首次证书身份核对流程仍是发布前待完善项;不得把“浏览器忽略证书警告”当作生产 TLS 验收。仅在受控管理网络完成开发验证。
|
||||
|
||||
向导依次配置管理员、存储、阿里云 RAM 与域名验证、通知和正式管理入口:
|
||||
|
||||
- SQLite 使用本机文件;MySQL/PostgreSQL 使用预先创建的专属数据库及最小权限账号,不能用生产共享库做初始化测试。
|
||||
- MySQL 必须启用 TLS 并校验服务端证书及主机名,当前使用系统信任库;PostgreSQL 使用 `sslmode=verify-full`,可指定服务端可读的根证书绝对路径。
|
||||
- 数据库和 DNS 验证成功后保存的是短期验证状态;修改输入或状态过期需重新验证,不允许手填内部凭据引用。
|
||||
- 正式入口可选择域名或 IP;域名证书需要真实 DNS/ACME 验证,IP 入口当前为自签名证书。默认 ACME staging 证书也不受公共信任,不能当作 production 证书。
|
||||
- 保存成功后,临时初始化服务撤销会话并关闭,再自动加载正式配置启动服务,页面转到返回的正式 HTTPS 地址,无需手工重启切换。
|
||||
|
||||
若首次端口与正式端口不同,应由管理员预先准备正式端口规则,确认切换后关闭不再需要的初始化端口。服务不会代替管理员修改云安全组。当前初始化仍调用本机防火墙管理器,其在最小权限 systemd 下的行为尚待实机验收;若发生权限错误,不得将整个 Scert 服务改成 root 或取消加固。
|
||||
|
||||
反向代理方案应在初始化完成后再应用第 5 节的回环监听配置;若预先设置 `SCERT_SERVER_HOST=127.0.0.1`,远程浏览器将无法直接访问 IP 初始化端口。`SCERT_SERVER_PORT` 等环境变量会持续覆盖向导保存的配置,应与最终选项一致。
|
||||
|
||||
上述是新版源码的预期安装流程,不代表本轮已构建发布包或完成 Linux systemd 实机验收。
|
||||
|
||||
### 4.2 升级
|
||||
|
||||
安装器禁止在 `Scert.service` 或 `Scert-admin.service` 运行时覆盖程序。升级顺序:
|
||||
@ -166,7 +194,7 @@ curl -k --fail --silent --show-error https://127.0.0.1:9004/readyz
|
||||
| `secret_store_unavailable` | 检查 `master.key`、`secrets.json` 和配置目录权限。 |
|
||||
| `credential_unavailable` | 重新录入真实 DNS、HTTP 通知或 SMTP 通知凭据;禁止写入假 Token 绕过。 |
|
||||
| `tls_material_unavailable` | 检查内部 TLS 证书和私钥路径、文件类型与权限。 |
|
||||
| `database_unavailable` | 检查 SQLite 文件、MySQL 连通性、账户权限和连接限制。 |
|
||||
| `database_unavailable` | 检查 SQLite 文件、MySQL/PostgreSQL 连通性、加密凭据、TLS、账户权限和连接限制。 |
|
||||
|
||||
`/healthz` 返回正常而 `/readyz` 返回 `503` 时,服务只能用于故障排查,不能接收生产流量。
|
||||
|
||||
@ -202,6 +230,8 @@ proxy_ssl_verify_depth 3;
|
||||
|
||||
### 7.1 备份内容
|
||||
|
||||
当前 Scert 内置完整备份/恢复仅实现 SQLite 和 MySQL。PostgreSQL 初始化及业务存储接入不代表备份功能已实现;选择 PostgreSQL 时,内置命令明确拒绝,避免遗漏数据库后仍报告备份成功。完整 PostgreSQL 备份恢复及真实演练未完成前,不符合三数据库完整生产验收。
|
||||
|
||||
完整备份包含:
|
||||
|
||||
- `scert.json`
|
||||
@ -213,6 +243,8 @@ proxy_ssl_verify_depth 3;
|
||||
|
||||
SQLite 使用 `VACUUM INTO` 创建一致性快照并执行 `PRAGMA quick_check`。清单记录相对路径、权限、大小和 SHA-256。日志和数据库审计记录默认保留 30 天;文件日志每天最多写入 5 MiB,超过后停止文件写入但保留标准输出日志。
|
||||
|
||||
MySQL 账号和密码从加密数据库凭据引用读取,只在维护连接期间存在于内存,不写回普通配置或备份清单。恢复前固定当前可用账号用于快照、导入和失败回滚;数据库服务端账号本身的密码轮换仍需单独管理,恢复旧配置不能自动恢复数据库账户密码。
|
||||
|
||||
### 7.2 停服备份
|
||||
|
||||
```bash
|
||||
@ -273,7 +305,7 @@ sudo systemctl start Scert
|
||||
- systemd 服务状态、重启次数和退出码。
|
||||
- 证书剩余有效期;默认续期阈值为 6 天,3 天进入紧急状态。
|
||||
- 证书申请、部署、校验和通知任务失败数量。
|
||||
- 文件系统剩余空间、SQLite/MySQL 可用性和备份最后成功时间。
|
||||
- 文件系统剩余空间、SQLite/MySQL/PostgreSQL 可用性和备份最后成功时间。
|
||||
- Nginx 5xx、上游 TLS 校验失败和 SSH 主机指纹变化。
|
||||
|
||||
常用命令:
|
||||
@ -294,7 +326,9 @@ sudo -u Scert /scert/scert --config /scert/conf/scert.json status
|
||||
- 真实 DNS Provider API 的最小权限凭据与在线写入/清理验证。
|
||||
- 合法域名的 Let’s Encrypt Production 申请、续期和速率限制处理。
|
||||
- MySQL 5.7 与 8.0 的真实备份、恢复和失败回滚。
|
||||
- PostgreSQL 完整备份恢复开发及真实数据库连接、迁移、权限、业务与恢复演练。
|
||||
- 新 IP 初始化在最小权限 systemd 下的防火墙行为、临时证书身份核对、浏览器页面及正式入口切换。
|
||||
- Windows Service 安装、升级、开机启动、停止和 ACL。
|
||||
- 公网生产 Nginx、真实证书链和最终业务访问。
|
||||
|
||||
这些外部项未完成时,可以批准 Linux + SQLite 的受控试运行,但不得把未验证能力写成已通过。
|
||||
这些外部项未完成时不能声明完整生产准入。本轮尚未生成新版 Linux 发布包,也未完成新初始化流程的 Linux 实机和浏览器验收;Linux + SQLite 的受控试运行同样需要当前版本的目标主机验证,不得复用旧版本结论。
|
||||
|
||||
@ -25,12 +25,19 @@
|
||||
- [✓] 显式关闭通知重试不再在配置重新加载时变回三次重试。
|
||||
- [✓] 正式管理地址支持 IPv4、IPv6、默认及非默认 HTTPS 端口;代理模式不输出内部监听端口。
|
||||
- [✓] 初始化提交测试夹具显式隔离所有可写目录,避免 Linux 默认路径指向 `/scert` 或 `/ssl`。
|
||||
- [ ] CLI 启动状态机:未初始化进入独立 Bootstrap HTTPS,已初始化进入正式应用,损坏配置失败关闭。
|
||||
- [ ] 接入 `--bootstrap-public-ip`、`SCERT_BOOTSTRAP_PUBLIC_IP` 和 `/run/scert/bootstrap-url`。
|
||||
- [ ] 正式应用构造函数拒绝未初始化配置。
|
||||
- [ ] 前端初始化向导迁移至 Bootstrap API,加入令牌兑换、三数据库选择及验证、通知对象和完成跳转。
|
||||
- [ ] 新启动和前端链路验证通过后,停止注册旧初始化路由,保留旧源文件。
|
||||
- [ ] 新版 Linux 发布包、桌面浏览器和真实数据库整链路验收。
|
||||
- [✓] CLI 启动状态机:未初始化进入独立 Bootstrap HTTPS,已初始化进入正式应用,损坏配置失败关闭。
|
||||
- [✓] 接入 `--bootstrap-public-ip`、`SCERT_BOOTSTRAP_PUBLIC_IP` 和 `/run/scert/bootstrap-url`;systemd 为临时地址提供受保护运行目录。
|
||||
- [✓] 正式应用构造函数拒绝未初始化配置,正式监听器不再提供未初始化明文 HTTP。
|
||||
- [✓] 前端初始化向导迁移至 Bootstrap API,加入令牌兑换、三数据库选择及验证、通知对象和完成跳转。
|
||||
- [✓] 正式服务不再注册旧初始化写接口,仅保留状态查询;未初始化桌面端保留本机向导和旧源文件。
|
||||
- [✓] 桌面端已取得的处理器在配置保存后自动激活正式服务,无需重启窗口即可登录;并发切换、幂等关闭、取消在途初始化和损坏配置均有回归测试。
|
||||
- [✓] 初始化写请求拒绝跨源访问;前端最终提交重新校验所有步骤和令牌有效期,避免响应乱序、计时缓存及重复提交绕过。
|
||||
- [✓] 临时 HTTPS、SQLite 初始化、正式 HTTPS 同端口切换、管理员登录和会话可用性通过隔离集成测试。
|
||||
- [✓] 复审三库接入后的备份恢复兼容性:MySQL 接入加密凭据,PostgreSQL 未实现完整备份时明确拒绝,禁止漏备份却报告成功;内存凭据与拒绝操作的回归测试通过,不代表真实数据库备份恢复已验收。
|
||||
- [!] PostgreSQL 完整备份恢复尚未实现,不能宣称三种数据库的全部运维能力均已完成。
|
||||
- [!] 新版 Linux 发布包和桌面浏览器验收:本地独立服务启动及发布脚本执行此前被策略拒绝,未绕过限制。
|
||||
- [ ] 真实 MySQL/PostgreSQL 的连接、迁移、权限及完整业务链路验收。
|
||||
- [ ] Linux 最小权限 systemd 下的初始化、防火墙及管理入口切换验收。
|
||||
- [ ] 在明确授权范围内进行真实 DNS、ACME、HTTPS 和远程部署验收。
|
||||
|
||||
## 本轮验证
|
||||
@ -38,19 +45,25 @@
|
||||
在 Windows 开发工作区执行并通过:
|
||||
|
||||
```text
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go test -race ./internal/bootstrap ./internal/config ./internal/setup
|
||||
go test -race ./...
|
||||
go test -p 1 -count=1 -timeout=4m ./...
|
||||
go test -race -p 1 -count=1 -timeout=5m ./...
|
||||
go vet -p=1 ./...
|
||||
go mod verify
|
||||
git diff --check
|
||||
```
|
||||
|
||||
另外,设置 `GOOS=linux`、`GOARCH=amd64`、`CGO_ENABLED=0` 后执行 `go build ./...` 通过,仅验证 Linux 目标源码可构建,没有生成或部署本轮 Linux 发布包。
|
||||
桌面模块的 Go 测试、竞态检测、`go vet`、依赖校验及 `go mod tidy -diff` 通过;桌面端依赖已同步后端的 PostgreSQL 驱动等间接依赖。
|
||||
|
||||
前端 `npm.cmd test` 共 38 项通过,覆盖初始化令牌兑换、三数据库表单、完整向导提交、通知映射、过期令牌、错误响应及 DNS 等待状态。前端生产构建通过,产物写入新的 `.runtime/frontend-bootstrap-20260916-2030`,未清理旧产物;生产依赖审计未报告漏洞。
|
||||
|
||||
另外,设置 `GOOS=linux`、`GOARCH=amd64`、`CGO_ENABLED=0` 后执行 `go build -p 1 ./...` 通过,仅验证 Linux 目标源码可构建,没有生成或部署本轮 Linux 发布包。2026-09-16 20:35 已收齐新增维护检查后的全量测试、竞态检测、静态检查和 Linux 交叉构建结果,均通过。
|
||||
|
||||
测试使用独立临时目录和隔离 SQLite 数据库;阿里云验证、证书签发和防火墙由测试替身模拟,没有修改真实 DNS 或主机防火墙。
|
||||
|
||||
MySQL/PostgreSQL 参数、验证状态和凭据处理有自动化覆盖,但这不等于本轮完成两个真实数据库服务的连接、迁移和权限验收。HTTP 接口测试不等于浏览器验收。通知配置测试不等于实际邮件投递。
|
||||
|
||||
HTTPS 集成测试只连接本机回环地址,严格校验证书及名称,不使用跳过 TLS 校验。临时初始化结束后,在同一端口启动正式服务并验证登录、会话、管理员接口和 `/readyz`。这里的就绪结果仅证明隔离夹具中的配置、SQLite 和加密凭据可用,不代表真实 RAM 权限、证书信任或远程生产就绪。
|
||||
|
||||
## 关键设计与续接注意事项
|
||||
|
||||
### 配置提交边界
|
||||
@ -67,16 +80,24 @@ MySQL/PostgreSQL 参数、验证状态和凭据处理有自动化覆盖,但这
|
||||
|
||||
完成请求通过可选 `notification` 对象传递通知配置。省略对象保留原有配置,提供对象按完整表单应用;客户端不能指定 `http_credential_ref` 或 `smtp_credential_ref`。HTTP 支持 Bearer、HMAC-SHA256 和账号会话认证;SMTP 密码单独存储。
|
||||
|
||||
前端迁移时应将现有通知表单映射到该对象,而不是移除通知步骤。不要把客户端输入或底层错误写入公开响应。
|
||||
前端现已将原通知表单映射到该对象,通知步骤保留。不要把客户端输入或底层错误写入公开响应。
|
||||
|
||||
### 三库运维边界
|
||||
|
||||
新增数据库加密引用后,MySQL 维护操作必须在内存中解密账号再建立连接,不能直接使用无账号的持久化 DSN。恢复前应固定可用连接,避免配置和主密钥替换过程中失去快照或回滚连接。备份校验必须包括数据库凭据引用。
|
||||
|
||||
PostgreSQL 当前可选择初始化,但原完整备份实现只有 SQLite/MySQL。本轮先让 PostgreSQL 的 `backup`、`restore` 明确失败,在创建输出或替换目标前阻止不完整备份被当作成功。完整实现及真实恢复演练仍是生产准入阻塞,不能把这一防护当作备份功能开发完成。
|
||||
|
||||
### 验收与操作边界
|
||||
|
||||
- 当前新路由尚未接入 `scert serve`,现有启动行为和旧前端没有因为上述模块测试通过而自动完成迁移。
|
||||
- 新启动链路已接入 `scert serve`,但独立服务的实际页面渲染和新版本 Linux 部署仍未验收。
|
||||
- IP 管理入口目前使用自签名证书,不能表述为已获公共 CA 信任。
|
||||
- Bootstrap 临时证书仅保存在进程内;现有日志尚未提供可从可信运维通道对照的证书指纹,正式发布前应补齐首次证书身份核对流程。
|
||||
- 当前初始化仍调用防火墙管理器。systemd 使用非特权 `Scert` 用户且不授予网络管理能力;存在 UFW/firewalld 时必须验证只读检查和权限失败处理,不能要求把整个服务改成 root 来通过初始化。
|
||||
- 本轮未连接远程主机,未操作真实 DNS,未清空既有数据库,未修改 `pcux.cn` 及其证书。
|
||||
- `47.108.78.12` 用于项目部署;`8.137.23.95` 用于推送测试,两者不可混用。
|
||||
- 历史未跟踪补丁、测试脚本、二进制和 `dist/` 产物保持不动,不纳入提交。
|
||||
|
||||
## 下一步
|
||||
|
||||
先实现可注入依赖的启动状态机及隔离 HTTPS 服务,并补充启动、关闭、配置损坏和完成切换测试。之后迁移前端,再做真实浏览器与 Linux 发布验收;不要提前停用旧路由导致现有向导不可访问。
|
||||
优先补齐 PostgreSQL 完整备份恢复、初始化证书身份核对及非特权防火墙处理;在允许启动服务和执行发布脚本的环境中完成浏览器与 Linux 发布验收。真实数据库和 DNS/ACME 测试必须使用专属测试数据与已授权域名,并明确测试范围,不能清空生产数据库或触碰禁止修改的域名。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user