From a21240109a0e67fbd64e5c7f3178cd97cb3c9306 Mon Sep 17 00:00:00 2001 From: xuans Date: Sun, 20 Sep 2026 02:01:06 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8F=91=E5=B8=83=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E6=9C=AC=E6=9C=BA=E8=B7=AF=E5=BE=84=E6=B3=84=E6=BC=8F?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E9=BD=90=E4=BA=A7=E7=89=A9=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/README.md | 4 +- backend/cmd/scert-release/main.go | 14 +- backend/internal/admin/installer_test.go | 7 +- backend/internal/admin/server_test.go | 6 +- backend/internal/admin/service_units_test.go | 7 +- .../internal/releasearchive/archive_test.go | 6 +- backend/internal/releasearchive/verify.go | 150 +++++++++++++++++ .../internal/releasearchive/verify_test.go | 154 ++++++++++++++++++ build/README.md | 11 +- build/build-release.ps1 | 68 +++++++- doc/Linux生产流程测试记录.md | 32 ++++ doc/PostgreSQL备份恢复说明.md | 2 +- frontend/vite.config.js | 3 + 13 files changed, 436 insertions(+), 28 deletions(-) create mode 100644 backend/internal/releasearchive/verify.go create mode 100644 backend/internal/releasearchive/verify_test.go diff --git a/backend/README.md b/backend/README.md index b333e33..ef0eb21 100644 --- a/backend/README.md +++ b/backend/README.md @@ -83,7 +83,7 @@ SCERT_SERVER_HTTPS_MODE=proxy ```powershell go fmt ./... go test ./... -go build -o dist/scert.exe ./cmd/scert +go build -trimpath -o dist/scert.exe ./cmd/scert ``` Linux amd64 交叉编译: @@ -91,7 +91,7 @@ Linux amd64 交叉编译: ```powershell $env:GOOS = 'linux' $env:GOARCH = 'amd64' -go build -o dist/scert-linux-amd64 ./cmd/scert +go build -trimpath -o dist/scert-linux-amd64 ./cmd/scert Remove-Item Env:GOOS Remove-Item Env:GOARCH ``` diff --git a/backend/cmd/scert-release/main.go b/backend/cmd/scert-release/main.go index db08814..b378df7 100644 --- a/backend/cmd/scert-release/main.go +++ b/backend/cmd/scert-release/main.go @@ -21,13 +21,25 @@ func (items *stringList) Set(value string) error { } func main() { - var executables stringList + var executables, binaries, forbiddenPaths stringList source := flag.String("source", "", "发布源目录") output := flag.String("output", "", "发布归档路径") format := flag.String("format", "", "归档格式:tar.gz 或 zip") + verifyOnly := flag.Bool("verify-only", false, "仅检查路径裁剪和本机路径残留") flag.Var(&executables, "executable", "需要写入 0755 权限的相对路径,可重复指定") + flag.Var(&binaries, "binary", "需要检查路径裁剪的二进制相对路径,可重复指定") + flag.Var(&forbiddenPaths, "forbid-path", "不得写入产物的本机构建路径,可重复指定") flag.Parse() + if *verifyOnly { + if err := releasearchive.Verify(*source, binaries, forbiddenPaths); err != nil { + fmt.Fprintln(os.Stderr, "发布路径检查失败:"+err.Error()) + os.Exit(1) + } + fmt.Println("发布路径检查通过") + return + } + if err := releasearchive.Create(releasearchive.Options{ Source: *source, Destination: *output, Format: releasearchive.Format(*format), Executables: executables, }); err != nil { diff --git a/backend/internal/admin/installer_test.go b/backend/internal/admin/installer_test.go index f1740a2..1746f3a 100644 --- a/backend/internal/admin/installer_test.go +++ b/backend/internal/admin/installer_test.go @@ -5,17 +5,12 @@ import ( "os" "path/filepath" "regexp" - "runtime" "strings" "testing" ) func TestLinuxInstallerPolicy(t *testing.T) { - _, sourceFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("无法定位测试源文件") - } - installerPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "deploy", "install-linux.sh") + installerPath := filepath.Join("..", "..", "..", "deploy", "install-linux.sh") data, err := os.ReadFile(installerPath) if err != nil { t.Fatalf("读取 Linux 安装器失败:%v", err) diff --git a/backend/internal/admin/server_test.go b/backend/internal/admin/server_test.go index c43a86e..4a0062f 100644 --- a/backend/internal/admin/server_test.go +++ b/backend/internal/admin/server_test.go @@ -12,11 +12,7 @@ import ( ) func TestScertAdminSystemdUnitKeepsManualOnlyPolicy(t *testing.T) { - _, sourceFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("无法定位测试源文件") - } - unitPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "deploy", "systemd", "Scert-admin.service") + unitPath := filepath.Join("..", "..", "..", "deploy", "systemd", "Scert-admin.service") data, err := os.ReadFile(unitPath) if err != nil { t.Fatalf("读取 Scert-admin systemd unit 失败:%v", err) diff --git a/backend/internal/admin/service_units_test.go b/backend/internal/admin/service_units_test.go index 74b7f49..f8740e6 100644 --- a/backend/internal/admin/service_units_test.go +++ b/backend/internal/admin/service_units_test.go @@ -3,17 +3,12 @@ package admin import ( "os" "path/filepath" - "runtime" "strings" "testing" ) func TestScertSystemdUnitPolicy(t *testing.T) { - _, sourceFile, _, ok := runtime.Caller(0) - if !ok { - t.Fatal("无法定位测试源文件") - } - unitPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "deploy", "systemd", "Scert.service") + unitPath := filepath.Join("..", "..", "..", "deploy", "systemd", "Scert.service") data, err := os.ReadFile(unitPath) if err != nil { t.Fatalf("读取 Scert systemd unit 失败:%v", err) diff --git a/backend/internal/releasearchive/archive_test.go b/backend/internal/releasearchive/archive_test.go index 29ebcd2..f10034f 100644 --- a/backend/internal/releasearchive/archive_test.go +++ b/backend/internal/releasearchive/archive_test.go @@ -17,7 +17,7 @@ func TestCreateTarGzPreservesReleaseModes(t *testing.T) { destination := filepath.Join(t.TempDir(), "Scert-test.tar.gz") if err := Create(Options{ Source: source, Destination: destination, Format: FormatTarGz, - Executables: []string{"install-linux.sh", "scert", "scert-admin"}, + Executables: []string{"install-linux.sh", "scert", "scert-admin", "scert-nginx", "ScertDesktop"}, }); err != nil { t.Fatalf("创建 Linux 发布归档失败:%v", err) } @@ -47,6 +47,8 @@ func TestCreateTarGzPreservesReleaseModes(t *testing.T) { assertMode(t, modes, "install-linux.sh", 0o755) assertMode(t, modes, "scert", 0o755) assertMode(t, modes, "scert-admin", 0o755) + assertMode(t, modes, "scert-nginx", 0o755) + assertMode(t, modes, "ScertDesktop", 0o755) assertMode(t, modes, "web/index.html", 0o644) assertMode(t, modes, "web", 0o755) } @@ -123,7 +125,7 @@ func createFixture(t *testing.T) string { if err := os.MkdirAll(filepath.Join(source, "web"), 0o755); err != nil { t.Fatal(err) } - for _, name := range []string{"install-linux.sh", "scert", "scert-admin"} { + for _, name := range []string{"install-linux.sh", "scert", "scert-admin", "scert-nginx", "ScertDesktop"} { if err := os.WriteFile(filepath.Join(source, name), []byte(name), 0o644); err != nil { t.Fatal(err) } diff --git a/backend/internal/releasearchive/verify.go b/backend/internal/releasearchive/verify.go new file mode 100644 index 0000000..168d61c --- /dev/null +++ b/backend/internal/releasearchive/verify.go @@ -0,0 +1,150 @@ +package releasearchive + +import ( + "bytes" + "debug/buildinfo" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "unicode/utf16" +) + +// Verify 检查构建标记及本机构建路径,不将被禁止的路径写入发布清单。 +func Verify(source string, binaries, forbiddenPaths []string) error { + if source == "" || len(binaries) == 0 || len(forbiddenPaths) == 0 { + return errors.New("发布检查必须提供源目录、二进制文件和本机路径") + } + info, err := os.Lstat(source) + if err != nil { + return fmt.Errorf("检查发布源目录失败:%w", err) + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("发布源路径必须是普通目录") + } + patterns := pathPatterns(forbiddenPaths) + if len(patterns) == 0 { + return errors.New("本机路径不能为空") + } + if err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return errors.New("发布检查不接受符号链接或特殊文件") + } + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + if err := scanPaths(file, patterns); err != nil { + relative, _ := filepath.Rel(source, path) + return fmt.Errorf("发布文件 %s 检查失败:%w", relative, err) + } + return nil + }); err != nil { + return err + } + for _, name := range binaries { + if !filepath.IsLocal(name) { + return errors.New("二进制文件必须位于发布目录内") + } + info, err := buildinfo.ReadFile(filepath.Join(source, name)) + if err != nil { + return fmt.Errorf("读取 %s 的 Go 构建信息失败:%w", name, err) + } + trimmed := false + for _, setting := range info.Settings { + if setting.Key == "-trimpath" && setting.Value == "true" { + trimmed = true + } + } + if !trimmed { + return fmt.Errorf("二进制文件 %s 未启用 -trimpath", name) + } + } + return nil +} + +func pathPatterns(paths []string) [][]byte { + var patterns [][]byte + seen := make(map[string]bool) + for _, path := range paths { + if strings.TrimSpace(path) == "" { + continue + } + slash := strings.ReplaceAll(path, `\`, "/") + backslash := strings.ReplaceAll(slash, "/", `\`) + for _, variant := range []string{slash, backslash, strings.ReplaceAll(backslash, `\`, `\\`), strings.ReplaceAll(slash, "/", `\/`)} { + utf8Pattern := []byte(variant) + lowerASCII(utf8Pattern) + if seen[string(utf8Pattern)] { + continue + } + seen[string(utf8Pattern)] = true + patterns = append(patterns, utf8Pattern) + var little, big []byte + for _, unit := range utf16.Encode([]rune(variant)) { + little = append(little, byte(unit), byte(unit>>8)) + big = append(big, byte(unit>>8), byte(unit)) + } + lowerASCII(little) + lowerASCII(big) + patterns = append(patterns, little, big) + } + } + return patterns +} + +func scanPaths(reader io.Reader, patterns [][]byte) error { + longest := 0 + for _, pattern := range patterns { + if len(pattern) > longest { + longest = len(pattern) + } + } + if longest == 0 { + return errors.New("路径检查模式不能为空") + } + const chunkSize = 64 * 1024 + buffer := make([]byte, chunkSize+longest-1) + retained := 0 + for { + n, err := reader.Read(buffer[retained:]) + data := buffer[:retained+n] + lowerASCII(data) + for _, pattern := range patterns { + if bytes.Contains(data, pattern) { + return errors.New("发现本机构建路径,拒绝发布") + } + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + // 保留跨读取边界的前缀,避免路径被分块后漏检。 + retained = min(longest-1, len(data)) + copy(buffer, data[len(data)-retained:]) + } +} + +func lowerASCII(data []byte) { + for i, value := range data { + if value >= 'A' && value <= 'Z' { + data[i] = value + ('a' - 'A') + } + } +} diff --git a/backend/internal/releasearchive/verify_test.go b/backend/internal/releasearchive/verify_test.go new file mode 100644 index 0000000..7ff8ac3 --- /dev/null +++ b/backend/internal/releasearchive/verify_test.go @@ -0,0 +1,154 @@ +package releasearchive + +import ( + "bytes" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "unicode/utf16" +) + +func TestScanPathsRejectsEncodedLocalPaths(t *testing.T) { + const local = `D:\Workspace\Scert` + for name, value := range map[string]string{ + "native": local + `\backend\main.go`, + "forward": `d:/workspace/scert/backend/main.go`, + "json": `D:\\Workspace\\Scert\\backend\\main.go`, + "json_forward": `D:\/Workspace\/Scert\/backend\/main.go`, + } { + t.Run(name, func(t *testing.T) { + if err := scanPaths(strings.NewReader(value), pathPatterns([]string{local})); err == nil { + t.Fatal("未拒绝本机路径") + } + }) + } + for name, bigEndian := range map[string]bool{"utf16le": false, "utf16be": true} { + t.Run(name, func(t *testing.T) { + const unicodePath = `C:\用户\构建` + var data []byte + for _, unit := range utf16.Encode([]rune(unicodePath)) { + if bigEndian { + data = append(data, byte(unit>>8), byte(unit)) + } else { + data = append(data, byte(unit), byte(unit>>8)) + } + } + if err := scanPaths(bytes.NewReader(data), pathPatterns([]string{unicodePath})); err == nil { + t.Fatal("未拒绝 UTF-16 本机路径") + } + }) + } +} + +func TestScanPathsRejectsPathsAcrossReadBoundary(t *testing.T) { + reader := io.MultiReader(strings.NewReader("prefix C:/Use"), strings.NewReader("rs/build-user/project/main.go")) + if err := scanPaths(reader, pathPatterns([]string{`C:\Users\build-user`})); err == nil { + t.Fatal("读取边界上的本机路径被漏检") + } +} + +func TestScanPathsAllowsModuleAndRuntimePaths(t *testing.T) { + data := "git.pcux.cn/xuans/Scert/backend@v1.0.0/cmd/scert/main.go\x00/scert/conf/scert.json\x00/etc/nginx/nginx.conf\x00PATH=/usr/sbin:/usr/bin:/sbin:/bin" + if err := scanPaths(strings.NewReader(data), pathPatterns([]string{`D:\Workspace`, `C:\Users\builder`})); err != nil { + t.Fatalf("模块路径或部署路径被误拒绝:%v", err) + } +} + +type brokenReader struct{} + +func (brokenReader) Read([]byte) (int, error) { return 0, errors.New("读取失败") } + +func TestScanPathsPropagatesReadError(t *testing.T) { + if err := scanPaths(brokenReader{}, pathPatterns([]string{`C:\Users\builder`})); err == nil { + t.Fatal("读取失败必须阻止发布") + } +} + +func TestVerifyRequiresTrimpathBuildSetting(t *testing.T) { + for _, trimpath := range []bool{false, true} { + name := "untrimmed" + if trimpath { + name = "trimmed" + } + t.Run(name, func(t *testing.T) { + source := t.TempDir() + mainPath := filepath.Join(t.TempDir(), "main.go") + if err := os.WriteFile(mainPath, []byte("package main\nfunc main() {}\n"), 0o600); err != nil { + t.Fatal(err) + } + binary := "fixture" + if runtime.GOOS == "windows" { + binary += ".exe" + } + flag := "-trimpath=false" + if trimpath { + flag = "-trimpath=true" + } + command := exec.Command("go", "build", flag, "-buildvcs=false", "-o", filepath.Join(source, binary), mainPath) + command.Env = append(os.Environ(), "GOFLAGS=", "CGO_ENABLED=0", "GOOS="+runtime.GOOS, "GOARCH="+runtime.GOARCH) + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("构建路径检查夹具失败:%v\n%s", err, output) + } + err := Verify(source, []string{binary}, []string{"unused-build-root-298e4699"}) + if trimpath && err != nil { + t.Fatalf("裁剪产物检查失败:%v", err) + } + if !trimpath && (err == nil || !strings.Contains(err.Error(), "-trimpath")) { + t.Fatalf("未拒绝缺少路径裁剪的构建:%v", err) + } + if trimpath { + if err := Verify(source, []string{binary}, []string{filepath.Dir(mainPath)}); err != nil { + t.Fatalf("裁剪构建仍有源目录残留:%v", err) + } + } + }) + } +} + +func TestVerifyChecksStaticAssetsAndRejectsInvalidInputs(t *testing.T) { + source := t.TempDir() + if err := os.WriteFile(filepath.Join(source, "app.js"), []byte(`const path="C:\\Users\\builder\\src";`), 0o600); err != nil { + t.Fatal(err) + } + if err := Verify(source, []string{"missing"}, []string{`C:\Users\builder`}); err == nil || !strings.Contains(err.Error(), "本机构建路径") { + t.Fatalf("静态文件路径残留未被拒绝:%v", err) + } + for name, test := range map[string]struct { + binaries []string + paths []string + }{ + "empty_binaries": {}, + "empty_paths": {binaries: []string{"missing"}}, + "blank_paths": {binaries: []string{"missing"}, paths: []string{" "}}, + "outside": {binaries: []string{"../outside"}, paths: []string{"unused-local-root"}}, + "missing": {binaries: []string{"missing"}, paths: []string{"unused-local-root"}}, + } { + t.Run(name, func(t *testing.T) { + if err := Verify(source, test.binaries, test.paths); err == nil { + t.Fatal("无效发布检查输入未被拒绝") + } + }) + } +} + +func TestReleaseScriptUsesPathChecksAndIncludesLinuxHelper(t *testing.T) { + data, err := os.ReadFile(filepath.Join("..", "..", "..", "build", "build-release.ps1")) + if err != nil { + t.Fatal(err) + } + for _, required := range []string{ + "go build -trimpath", "wails build -trimpath", "Test-ReleasePaths -Source $targetDirectory", + `-Package "./cmd/scert-nginx"`, `$binaries += "scert-nginx"`, + `-Executable (@("install-linux.sh") + $binaries)`, + `trimpath = $true`, `local_path_check = "passed"`, + } { + if !strings.Contains(string(data), required) { + t.Errorf("发布脚本缺少必要约束:%s", required) + } + } +} diff --git a/build/README.md b/build/README.md index 8f10143..a5c8223 100644 --- a/build/README.md +++ b/build/README.md @@ -14,6 +14,7 @@ build/release/ │ └── linux-amd64/ │ ├── scert │ ├── scert-admin +│ ├── scert-nginx │ ├── web/ │ └── SHA256SUMS ├── Scert-20260915-V0.1.0-linux-amd64.tar.gz @@ -52,12 +53,20 @@ powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -OutputDirect `manifest.json` 记录源码提交、已跟踪文件是否未提交、实际 Go 工具链、目标平台、是否请求桌面端以及文件和归档的 SHA256。`source_dirty=true` 的包仅用于开发,正式发布必须从已复审提交构建。未跟踪本地产物不计入源码脏状态,也不会自动打包。 +## 本机路径保护 + +核心服务、辅助程序和 Wails 桌面版统一使用 `-trimpath`。手动构建也必须保留此参数,例如 `go build -trimpath -o scert ./cmd/scert` 和 `wails build -trimpath -nopackage`。前端生产构建明确关闭源码映射。 + +归档前逐个读取 Go 二进制构建信息,确认 `-trimpath=true`;同时扫描发布目录全部文件,拒绝工作区、用户目录、Windows 的 Go/Node 安装目录、实际 Go 工具链、模块缓存、构建缓存、临时目录和输出目录路径残留。Linux 的通用 `/usr/bin` 等运行时目录不因安装工具而被禁用。检查覆盖正反斜杠、JSON 转义、UTF-8 和 UTF-16。发现残留或无法读取构建信息时停止归档,保留失败目录用于排查。 + +清单仅记录 `trimpath=true` 和 `local_path_check=passed`,不会记录本机扫描路径。该检查不删除模块导入路径、相对源码文件名或程序运行时需要的部署路径,也不保证识别源码中人为写死且不属于本次构建环境的其他机器路径。 + 仅构建本轮 Linux 服务候选包示例: ```powershell powershell -ExecutionPolicy Bypass -File .\build\build-release.ps1 -Target linux-amd64 -SkipDesktop -OutputDirectory .\build\release-candidate-20260915 ``` -此命令不构建 Windows 或 Wails 桌面端,不代表跨平台发布验收。归档包含 `scert`、`scert-admin`、Web 静态资源、安装器、systemd 模板和部署说明。解压后先运行 `sha256sum -c SHA256SUMS`,再按部署说明操作。 +此命令不构建 Windows 或 Wails 桌面端,不代表跨平台发布验收。归档包含 `scert`、`scert-admin`、`scert-nginx`、Web 静态资源、安装器、systemd 模板和部署说明。解压后先运行 `sha256sum -c SHA256SUMS`,再按部署说明操作。`scert-nginx` 仅随包提供,普通安装器不会安装或启用它;启用前仍需单独完成 root 策略、本地 socket、Nginx 接线和实机验收。 构建通过不等于生产验收通过;真实 DNS、ACME、SSH、Windows Service、systemd、WebView2 和 HTTPS 仍需在授权环境复核。 diff --git a/build/build-release.ps1 b/build/build-release.ps1 index 1dffc54..d40af37 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -107,7 +107,7 @@ function Invoke-DesktopBuild { if ($TargetName.StartsWith("linux-")) { $env:CGO_ENABLED = "1" } - & wails build -nopackage -platform ($TargetName.Replace("-", "/")) -o "ScertDesktop" + & wails build -trimpath -nopackage -platform ($TargetName.Replace("-", "/")) -o "ScertDesktop" if ($LASTEXITCODE -ne 0) { throw "构建 $TargetName 的 Scert 桌面版失败。" } @@ -194,6 +194,7 @@ function Invoke-ReleaseArchive { $arguments = @( "run", + "-trimpath", "./cmd/scert-release", "--source", $Source, "--output", $Destination, @@ -222,6 +223,42 @@ function Invoke-ReleaseArchive { } } +function Test-ReleasePaths { + param( + [Parameter(Mandatory = $true)] + [string]$Source, + [Parameter(Mandatory = $true)] + [string[]]$Binaries, + [Parameter(Mandatory = $true)] + [string[]]$ForbiddenPaths + ) + + $arguments = @("run", "-trimpath", "./cmd/scert-release", "--verify-only", "--source", $Source) + foreach ($binary in $Binaries) { + $arguments += @("--binary", $binary) + } + foreach ($path in ($ForbiddenPaths | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -Unique)) { + $arguments += @("--forbid-path", $path) + } + $checkGoOS = $env:GOOS + $checkGoArch = $env:GOARCH + $checkCGOEnabled = $env:CGO_ENABLED + try { + $env:GOOS = "" + $env:GOARCH = "" + $env:CGO_ENABLED = "0" + & go @arguments + if ($LASTEXITCODE -ne 0) { + throw "发布路径检查未通过,停止归档。" + } + } + finally { + $env:GOOS = $checkGoOS + $env:GOARCH = $checkGoArch + $env:CGO_ENABLED = $checkCGOEnabled + } +} + $backend = Resolve-SafeDirectory -Path $BackendDirectory -Label "后端目录" $output = Resolve-SafeDirectory -Path $OutputDirectory -Label "输出目录" $desktop = Resolve-SafeDirectory -Path $DesktopDirectory -Label "桌面项目目录" @@ -313,6 +350,22 @@ try { if ($LASTEXITCODE -ne 0) { throw "无法取得实际构建使用的 Go 工具链版本。" } + $goEnvironmentJSON = (& go env -json GOROOT GOPATH GOMODCACHE GOCACHE GOTMPDIR) + if ($LASTEXITCODE -ne 0) { + throw "无法取得本机构建路径,拒绝发布。" + } + $goEnvironment = ($goEnvironmentJSON -join "`n") | ConvertFrom-Json + $forbiddenPaths = @( + (Split-Path -Parent $scriptDirectory), $backend, $desktop, $frontend, $deploy, $output, + $env:USERPROFILE, $env:HOME, [System.IO.Path]::GetTempPath(), + $goEnvironment.GOROOT, $goEnvironment.GOMODCACHE, $goEnvironment.GOCACHE, $goEnvironment.GOTMPDIR + ) + $goEnvironment.GOPATH.Split([System.IO.Path]::PathSeparator) + if ($env:OS -eq "Windows_NT") { + $forbiddenPaths += @( + (Split-Path -Parent (Get-Command go).Source), + (Split-Path -Parent (Get-Command node).Source) + ) + } foreach ($item in $Target) { $parts = $item.Split("-", 2) $goos = $parts[0] @@ -323,10 +376,15 @@ try { $extension = if ($goos -eq "windows") { ".exe" } else { "" } $scertPath = Join-Path $targetDirectory ("scert" + $extension) $adminPath = Join-Path $targetDirectory ("scert-admin" + $extension) + $binaries = @(("scert" + $extension), ("scert-admin" + $extension)) Write-Host "正在构建 $goos/$goarch 的 Scert……" Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination $scertPath -Package "./cmd/scert" Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination $adminPath -Package "./cmd/scert-admin" + if ($goos -eq "linux") { + Invoke-GoBuild -OperatingSystem $goos -Architecture $goarch -Destination (Join-Path $targetDirectory "scert-nginx") -Package "./cmd/scert-nginx" + $binaries += "scert-nginx" + } $webPath = Join-Path $targetDirectory "web" New-Item -ItemType Directory -Path $webPath -Force | Out-Null @@ -346,6 +404,7 @@ try { $desktopExtension = if ($goos -eq "windows") { ".exe" } else { "" } $desktopPath = Join-Path $targetDirectory ("ScertDesktop" + $desktopExtension) Invoke-DesktopBuild -ProjectDirectory $desktop -TargetName $item -Destination $desktopPath + $binaries += ("ScertDesktop" + $desktopExtension) $file = Get-Item -LiteralPath $desktopPath $hash = Get-FileHash -LiteralPath $desktopPath -Algorithm SHA256 $manifestArtifacts.Add([ordered]@{ @@ -361,6 +420,7 @@ try { } } + Test-ReleasePaths -Source $targetDirectory -Binaries $binaries -ForbiddenPaths $forbiddenPaths Write-TargetChecksums -TargetDirectory $targetDirectory | Out-Null foreach ($file in (Get-ChildItem -LiteralPath $targetDirectory -Recurse -File | Sort-Object FullName)) { $hash = Get-FileHash -LiteralPath $file.FullName -Algorithm SHA256 @@ -375,9 +435,7 @@ try { if ($goos -eq "linux") { $archivePath = Join-Path $output "$releaseName-$item.tar.gz" - Invoke-ReleaseArchive -Source $targetDirectory -Destination $archivePath -Format "tar.gz" -Executable @( - "install-linux.sh", "scert", "scert-admin" - ) + Invoke-ReleaseArchive -Source $targetDirectory -Destination $archivePath -Format "tar.gz" -Executable (@("install-linux.sh") + $binaries) } else { $archivePath = Join-Path $output "$releaseName-$item.zip" @@ -408,6 +466,8 @@ $manifest = [ordered]@{ source_commit = $sourceCommit.Trim() source_dirty = $sourceDirty go_toolchain = $goVersion.Trim() + trimpath = $true + local_path_check = "passed" targets = @($Target) desktop_requested = -not [bool]$SkipDesktop generated_at = [DateTimeOffset]::UtcNow.ToString("o") diff --git a/doc/Linux生产流程测试记录.md b/doc/Linux生产流程测试记录.md index d868c4b..95275fb 100644 --- a/doc/Linux生产流程测试记录.md +++ b/doc/Linux生产流程测试记录.md @@ -1,5 +1,37 @@ # Linux 生产流程测试记录 +## 当前进展:路径裁剪与本地发布检查 + +2026-09-20 接续。本节为最新状态,后文为各阶段历史记录。实际仓库位于 `D:\Desktop\Scert`,原 `D:\Desktop\SSL` 已不存在。本次修改前检查点 `ba6c6d1` 已推送;本节产物来自修改中的工作区,仅用于构建验证,不是正式可部署发布包。 + +### 需求与完成状态 + +- [✓] 按用户最新要求,Go 与 Wails 发布入口均使用 `-trimpath`,手动构建示例同步补齐;前端明确关闭源码映射。 +- [✓] 发布检查读取每个 Go 二进制的构建信息,缺少 `-trimpath=true` 时拒绝归档;扫描发布目录的全部文件,发现本机构建路径时拒绝归档。 +- [✓] 扫描覆盖正反斜杠、JSON 转义、UTF-8、UTF-16 和跨读取边界;单元测试覆盖未裁剪二进制、静态资源残留、读取错误与缺失文件。 +- [✓] 三处安装器及服务模板测试不再依赖编译时 `runtime.Caller` 路径,支持 `go test -trimpath`。 +- [✓] Linux 发布入口补入 `scert-nginx`,归档可执行权限由实际二进制列表生成;单元测试和实际归档检查通过。普通安装器仍不安装或启用 root 辅助服务。 +- [!] 当前主机禁止运行 PowerShell 脚本,完整发布脚本未实际执行通过;没有修改执行策略。语法检查通过,实际构建和扫描通过直接调用工具验证。 +- [!] SSH 主机身份变化尚未确认,没有发送密码、上传文件、变更 `known_hosts`、重载 Nginx 或清空数据库。 + +### 本轮验证证据 + +- Windows 后端 `go test -trimpath -count=1 -p=2 ./...` 全部通过。 +- Windows `go test -race -trimpath -count=1 -p=1 ./internal/releasearchive ./internal/admin` 通过;桌面模块 `go test -trimpath -count=1 -p=2 ./...` 通过。 +- Windows 与 Linux 目标的发布检查、安装管理包及发布命令 `go vet` 通过;Linux 额外检查 `cmd/scert-nginx`。这是静态检查,不是 Linux 测试运行。 +- 前端 54 项测试通过,Vite 生产构建成功,无源码映射文件。 +- `.runtime/path-check-20260920-0151/` 保留本轮 Linux 的 `scert`、`scert-admin`、`scert-nginx`,Windows 的 `scert.exe`、`scert-admin.exe`、`ScertDesktop.exe` 和 Web 资源。 +- Wails 2.11.0 使用 `-trimpath -nopackage` 完成 Windows 产物构建。为保留已有生成文件且避免依赖文件改写,本轮先构建前端,再传入 `-s -skipbindings -m -nosyncgomod`;这不是完整发布脚本的桌面步骤验收。 +- 六个二进制均通过构建信息检查;包含部署说明、安装器和服务模板的目录通过路径扫描,额外覆盖旧工作区和当前 Go/Node 安装目录。扫描检查本次指定的构建环境路径,不等于识别所有可能手写的任意机器路径。 +- `.runtime/path-check-linux-20260920-0158.tar.gz` 为直接调用归档工具生成的验证包,确认三个 Linux 程序和安装器均为 0755。该包没有完整发布脚本生成的清单及校验文件,不用于安装。SHA-256:`cda83659c0b8a6382efda4907b1adaa26170e35e4232e9bffc8c494407db7f4a`。 +- 新交叉编译的 `.runtime/nginx-request-trimpath-20260920-0158.test` 构建信息包含 `-trimpath=true`,SHA-256 为 `ecfe71a2562fde94755339270988910e01b0ac76e2e21a2472e7e1484fc972cd`。未在 Linux 执行;旧测试程序保留,不作为后续部署产物。 + +### 前序交接与下一步 + +接手时的源码检查点 `822e204` 已包含请求取消栅栏、请求编号恢复、辅助服务能力检查及应用 HTTPS 监听保护。本轮未重复将交接中的 Linux 交叉编译结果算作真实执行,也未重跑历史服务器夹具。 + +待从可信控制台核实目标 SSH 主机身份后,先核对现场服务、端口、DNS、数据库和容量,再执行隔离的 Linux/Nginx/Unix IPC 测试。仍需完成辅助服务安装策略与 socket 模板、真实应用配置持久化协调验收、正式候选构建、管理证书续期、目标机推送、通知、重启及升级回滚。当前未达到生产部署验收条件。 + ## 当前进展:受限本地激活服务与身份边界验证 2026-09-18 02:20 起接续,修改前检查点 `cc94a61` 已推送。本轮继续验证独立辅助服务,没有替换正式二进制、安装正式 socket、重载正式 Nginx 或清空共享数据库。代理模式续期仍在申请前拒绝,完整生产验收尚未完成。 diff --git a/doc/PostgreSQL备份恢复说明.md b/doc/PostgreSQL备份恢复说明.md index 80ba457..019dab3 100644 --- a/doc/PostgreSQL备份恢复说明.md +++ b/doc/PostgreSQL备份恢复说明.md @@ -69,7 +69,7 @@ scert --config /scert/config/scert.json restore --input /scert/backup/manual-202 Windows PowerShell 示例(在 `backend` 目录执行,工具路径需按实际安装位置调整): ```powershell -go build -p 1 -o ../.runtime/scert-postgres-integration.exe ./cmd/scert +go build -trimpath -p 1 -o ../.runtime/scert-postgres-integration.exe ./cmd/scert $env:SCERT_POSTGRES_TEST_BIN = 'D:\Desktop\SSL\.runtime\postgres-tools\pgsql\bin' $env:SCERT_POSTGRES_TEST_CLI = 'D:\Desktop\SSL\.runtime\scert-postgres-integration.exe' $env:SCERT_POSTGRES_TEST_ROOT = 'D:\Desktop\SSL\.runtime\postgres-integration' diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 5549d18..aea2018 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -4,6 +4,9 @@ import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], publicDir: 'logo', + build: { + sourcemap: false, + }, server: (() => { const apiTarget = process.env.SCERT_API_TARGET || 'http://127.0.0.1:5200'; const apiProxy = {