初版功能完成
ci / Go checks (ubuntu-latest) (push) Has been cancelled
ci / Go checks (windows-latest) (push) Has been cancelled

This commit is contained in:
qsc
2026-08-29 13:12:17 +08:00
commit 142e5dc7d6
217 changed files with 21313 additions and 0 deletions
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
output_directory="${1:?usage: Collect-ServerEvidence.sh OUTPUT_DIRECTORY}"
mkdir -p "$output_directory"
stamp="$(date -u +%Y%m%dT%H%M%SZ)"
uname -a >"$output_directory/$stamp-uname.txt"
ip -details address show >"$output_directory/$stamp-ip-address.txt"
ip -4 route show table all >"$output_directory/$stamp-ipv4-routes.txt"
{
echo "[public-keys]"
wg show all public-key
echo "[listen-ports]"
wg show all listen-port
echo "[peers]"
wg show all peers
echo "[allowed-ips]"
wg show all allowed-ips
echo "[endpoints]"
wg show all endpoints
echo "[latest-handshakes]"
wg show all latest-handshakes
echo "[transfer]"
wg show all transfer
} >"$output_directory/$stamp-wireguard-public.txt"
iptables-save >"$output_directory/$stamp-iptables.txt"
cat /proc/sys/net/ipv4/ip_forward >"$output_directory/$stamp-ip-forward.txt"
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$output_directory/$stamp-"* >"$output_directory/$stamp-SHA256SUMS"
fi
echo "Server evidence captured under $output_directory"
@@ -0,0 +1,46 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet("Engineer", "Site")]
[string]$Role,
[Parameter(Mandatory = $true)]
[string]$OutputDirectory
)
$ErrorActionPreference = "Stop"
$resolved = [System.IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
$stamp = (Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ")
$prefix = "$stamp-$($Role.ToLowerInvariant())"
$adapters = Get-NetAdapter -IncludeHidden | Select-Object Name, InterfaceDescription, ifIndex, Status, MacAddress, LinkSpeed
$remlinkAdapters = @($adapters | Where-Object Name -eq "RemLink")
$ipConfiguration = Get-NetIPConfiguration -All | Select-Object InterfaceAlias, InterfaceIndex, IPv4Address, IPv4DefaultGateway, DNSServer
$routes = Get-NetRoute -AddressFamily IPv4 | Select-Object DestinationPrefix, NextHop, InterfaceAlias, InterfaceIndex, RouteMetric, Protocol, PolicyStore
$nat = @(Get-NetNat -ErrorAction SilentlyContinue | Select-Object Name, InternalIPInterfaceAddressPrefix, ExternalIPInterfaceAddressPrefix, Active)
$processes = Get-Process | Where-Object ProcessName -Match "RemLink|wireguard" | Select-Object ProcessName, Id, Path, StartTime
$inventory = [ordered]@{
captured_at = (Get-Date).ToUniversalTime().ToString("o")
computer = $env:COMPUTERNAME
role = $Role
elevated = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
remlink_adapter_count = $remlinkAdapters.Count
remlink_adapters = $remlinkAdapters
adapters = $adapters
ip_configuration = $ipConfiguration
ipv4_routes = $routes
existing_nat_read_only_snapshot = $nat
processes = $processes
}
$path = Join-Path $resolved "$prefix-network.json"
$inventory | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $path
Get-FileHash -Algorithm SHA256 $path | Format-List | Out-File -Encoding utf8 (Join-Path $resolved "$prefix-network.sha256.txt")
if ($remlinkAdapters.Count -ne 1) {
throw "Expected exactly one adapter named RemLink; found $($remlinkAdapters.Count). Evidence was saved to $path"
}
if (@($adapters | Where-Object { $_.Name -Match "WireGuard" -or $_.InterfaceDescription -Match "WireGuardNT" }).Count -ne 0) {
throw "Unexpected independent WireGuard/WireGuardNT adapter detected. Evidence was saved to $path"
}
Write-Host "Windows evidence captured: $path"
+45
View File
@@ -0,0 +1,45 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[string]$EngineerA = "Engineer-A",
[string]$SiteA = "Site-A",
[string]$EngineerB = "Engineer-B",
[string]$SiteB = "Site-B"
)
$ErrorActionPreference = "Stop"
$resolved = [System.IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
$scenarios = 1..18 | ForEach-Object {
[ordered]@{
id = "T{0:D2}" -f $_
status = "NOT_RUN"
started_at = $null
completed_at = $null
operator = $env:USERNAME
evidence = @()
notes = ""
}
}
$manifest = [ordered]@{
schema_version = 2
created_at = (Get-Date).ToUniversalTime().ToString("o")
topology = [ordered]@{
engineer_a = $EngineerA
site_a = $SiteA
engineer_b = $EngineerB
site_b = $SiteB
}
gates = @(
[ordered]@{ id = "Gate A"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" },
[ordered]@{ id = "Gate B"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" },
[ordered]@{ id = "Gate C"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" },
[ordered]@{ id = "Gate D"; status = "NOT_RUN"; started_at = $null; completed_at = $null; operator = $env:USERNAME; evidence = @(); notes = "" }
)
scenarios = $scenarios
}
$manifestPath = Join-Path $resolved "acceptance-run.json"
$manifest | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $manifestPath
Copy-Item (Join-Path $PSScriptRoot "..\..\docs\validation\T01-T18-runbook.md") (Join-Path $resolved "T01-T18-runbook.md")
Write-Host "Acceptance run initialized: $manifestPath"
+108
View File
@@ -0,0 +1,108 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$RunDirectory,
[Parameter(Mandatory = $true)]
[ValidatePattern('^(T(0[1-9]|1[0-8])|Gate [A-D])$')]
[string]$ID,
[Parameter(Mandatory = $true)]
[ValidateSet("PASS", "FAIL", "NOT_RUN")]
[string]$Status,
[string[]]$EvidencePath = @(),
[string]$Notes = "",
[string]$Operator = $env:USERNAME
)
$ErrorActionPreference = "Stop"
$runRoot = [System.IO.Path]::GetFullPath($RunDirectory).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$manifestPath = Join-Path $runRoot "acceptance-run.json"
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw "Acceptance manifest not found: $manifestPath"
}
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
if ($manifest.schema_version -ne 2) {
throw "Unsupported acceptance manifest schema version: $($manifest.schema_version)"
}
$entry = if ($ID.StartsWith("Gate ")) {
@($manifest.gates | Where-Object id -eq $ID)
} else {
@($manifest.scenarios | Where-Object id -eq $ID)
}
if ($entry.Count -ne 1) {
throw "Manifest must contain exactly one entry for $ID"
}
$entry = $entry[0]
$records = @()
foreach ($rawPath in $EvidencePath) {
$candidate = if ([System.IO.Path]::IsPathRooted($rawPath)) { $rawPath } else { Join-Path $runRoot $rawPath }
$fullPath = [System.IO.Path]::GetFullPath($candidate)
$rootPrefix = $runRoot + [System.IO.Path]::DirectorySeparatorChar
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Evidence must be stored inside the acceptance run directory: $fullPath"
}
if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
throw "Evidence file not found: $fullPath"
}
if ($fullPath -eq $manifestPath) {
throw "The acceptance manifest cannot be used as its own evidence"
}
$item = Get-Item -LiteralPath $fullPath
$records += [ordered]@{
path = [System.IO.Path]::GetRelativePath($runRoot, $fullPath).Replace('\', '/')
sha256 = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant()
bytes = $item.Length
}
}
if (($Status -eq "PASS" -or $Status -eq "FAIL") -and $records.Count -eq 0) {
throw "$Status requires at least one evidence file"
}
if ($Status -eq "NOT_RUN" -and $records.Count -ne 0) {
throw "NOT_RUN must not carry evidence; use PASS or FAIL after execution"
}
if ($Status -eq "PASS" -and $ID.StartsWith("Gate ")) {
$dependencies = switch ($ID) {
"Gate A" { @("T01", "T02") }
"Gate B" { @("Gate A") }
"Gate C" { @("Gate B", "T08", "T09") }
"Gate D" { @("Gate C", "T07", "T08", "T09") }
}
foreach ($dependency in $dependencies) {
$dependencyEntry = if ($dependency.StartsWith("Gate ")) {
@($manifest.gates | Where-Object id -eq $dependency)
} else {
@($manifest.scenarios | Where-Object id -eq $dependency)
}
if ($dependencyEntry.Count -ne 1 -or $dependencyEntry[0].status -ne "PASS") {
throw "$ID cannot be marked PASS until $dependency is PASS"
}
}
}
foreach ($property in @("started_at", "completed_at", "operator", "notes")) {
if (-not $entry.PSObject.Properties[$property]) {
$entry | Add-Member -NotePropertyName $property -NotePropertyValue $null
}
}
$now = (Get-Date).ToUniversalTime().ToString("o")
$entry.status = $Status
$entry.evidence = $records
$entry.notes = $Notes
$entry.operator = $Operator
if ($Status -eq "NOT_RUN") {
$entry.started_at = $null
$entry.completed_at = $null
} else {
if (-not $entry.started_at) { $entry.started_at = $now }
$entry.completed_at = $now
}
$temporary = Join-Path $runRoot (".acceptance-run-" + [guid]::NewGuid().ToString("N") + ".tmp")
try {
$manifest | ConvertTo-Json -Depth 12 | Set-Content -Encoding utf8 -LiteralPath $temporary
[System.IO.File]::Move($temporary, $manifestPath, $true)
} finally {
Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
}
Write-Host "Acceptance result recorded: $ID=$Status"
+80
View File
@@ -0,0 +1,80 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$RunDirectory
)
$ErrorActionPreference = "Stop"
$runRoot = [System.IO.Path]::GetFullPath($RunDirectory).TrimEnd([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar)
$manifestPath = Join-Path $runRoot "acceptance-run.json"
if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) {
throw "Acceptance manifest not found: $manifestPath"
}
$manifest = Get-Content -Raw -LiteralPath $manifestPath | ConvertFrom-Json
if ($manifest.schema_version -ne 2) {
throw "Unsupported acceptance manifest schema version: $($manifest.schema_version)"
}
$wantedGates = @("Gate A", "Gate B", "Gate C", "Gate D")
$wantedScenarios = 1..18 | ForEach-Object { "T{0:D2}" -f $_ }
if (@($manifest.gates).Count -ne 4 -or @($manifest.scenarios).Count -ne 18) {
throw "Manifest must contain four Gates and eighteen scenarios"
}
$actualGates = @($manifest.gates | ForEach-Object id | Sort-Object)
$actualScenarios = @($manifest.scenarios | ForEach-Object id | Sort-Object)
if (Compare-Object $wantedGates $actualGates -SyncWindow 0) { throw "Manifest Gate IDs are incomplete or duplicated" }
if (Compare-Object $wantedScenarios $actualScenarios -SyncWindow 0) { throw "Manifest scenario IDs are incomplete or duplicated" }
$allEntries = @($manifest.gates) + @($manifest.scenarios)
foreach ($entry in $allEntries) {
if ($entry.status -notin @("PASS", "FAIL", "NOT_RUN")) {
throw "$($entry.id) has invalid status $($entry.status)"
}
$evidence = @($entry.evidence)
if ($entry.status -eq "NOT_RUN") {
if ($evidence.Count -ne 0 -or $entry.completed_at) {
throw "$($entry.id) is NOT_RUN but carries evidence or a completion time"
}
continue
}
if ($evidence.Count -eq 0 -or -not $entry.started_at -or -not $entry.completed_at) {
throw "$($entry.id) requires evidence plus start/completion timestamps"
}
foreach ($record in $evidence) {
if (-not $record.path -or $record.sha256 -notmatch '^[0-9a-f]{64}$' -or $record.bytes -lt 0) {
throw "$($entry.id) contains an invalid evidence record"
}
$fullPath = [System.IO.Path]::GetFullPath((Join-Path $runRoot $record.path))
$rootPrefix = $runRoot + [System.IO.Path]::DirectorySeparatorChar
if (-not $fullPath.StartsWith($rootPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or
-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) {
throw "$($entry.id) evidence is missing or outside the run directory: $($record.path)"
}
$item = Get-Item -LiteralPath $fullPath
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $fullPath).Hash.ToLowerInvariant()
if ($actualHash -ne $record.sha256 -or $item.Length -ne $record.bytes) {
throw "$($entry.id) evidence hash/size mismatch: $($record.path)"
}
}
}
$dependenciesByGate = [ordered]@{
"Gate A" = @("T01", "T02")
"Gate B" = @("Gate A")
"Gate C" = @("Gate B", "T08", "T09")
"Gate D" = @("Gate C", "T07", "T08", "T09")
}
foreach ($gateID in $dependenciesByGate.Keys) {
$gate = @($manifest.gates | Where-Object id -eq $gateID)[0]
if ($gate.status -ne "PASS") { continue }
foreach ($dependency in $dependenciesByGate[$gateID]) {
$dependencyEntry = if ($dependency.StartsWith("Gate ")) {
@($manifest.gates | Where-Object id -eq $dependency)[0]
} else {
@($manifest.scenarios | Where-Object id -eq $dependency)[0]
}
if ($dependencyEntry.status -ne "PASS") {
throw "$gateID is PASS while dependency $dependency is not PASS"
}
}
}
Write-Host "Acceptance manifest and evidence hashes verified: $manifestPath"
@@ -0,0 +1,40 @@
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
$root = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-acceptance-tools-" + [guid]::NewGuid().ToString("N"))
try {
& (Join-Path $PSScriptRoot "New-AcceptanceRun.ps1") -OutputDirectory $root | Out-Null
$missingEvidenceRejected = $false
try {
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T01 -Status PASS | Out-Null
} catch {
$missingEvidenceRejected = $true
}
if (-not $missingEvidenceRejected) { throw "PASS without evidence was accepted" }
"T01 physical evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "t01.txt")
"T02 physical evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "t02.txt")
"Gate A evidence fixture" | Set-Content -Encoding utf8 -LiteralPath (Join-Path $root "gate-a.txt")
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T01 -Status PASS -EvidencePath t01.txt | Out-Null
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID T02 -Status PASS -EvidencePath t02.txt | Out-Null
& (Join-Path $PSScriptRoot "Set-AcceptanceResult.ps1") -RunDirectory $root -ID "Gate A" -Status PASS -EvidencePath gate-a.txt | Out-Null
& (Join-Path $PSScriptRoot "Test-AcceptanceRun.ps1") -RunDirectory $root | Out-Null
"tampered" | Add-Content -Encoding utf8 -LiteralPath (Join-Path $root "t01.txt")
$tamperRejected = $false
try {
& (Join-Path $PSScriptRoot "Test-AcceptanceRun.ps1") -RunDirectory $root | Out-Null
} catch {
$tamperRejected = $true
}
if (-not $tamperRejected) { throw "Tampered acceptance evidence was accepted" }
Write-Host "Acceptance tooling self-test passed"
} finally {
$resolvedTemp = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd([System.IO.Path]::DirectorySeparatorChar)
$resolvedRoot = [System.IO.Path]::GetFullPath($root)
if ($resolvedRoot.StartsWith($resolvedTemp + [System.IO.Path]::DirectorySeparatorChar, [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($resolvedRoot).StartsWith("remlink-acceptance-tools-")) {
Remove-Item -LiteralPath $resolvedRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
+109
View File
@@ -0,0 +1,109 @@
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
$repository = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$goFiles = Get-ChildItem -Path $repository -Recurse -File -Filter *.go | Where-Object {
$_.FullName -notmatch '[\\/]frontend[\\/]node_modules[\\/]' -and $_.Name -notlike '*_test.go'
}
$violations = @()
foreach ($file in $goFiles) {
$relative = [System.IO.Path]::GetRelativePath($repository, $file.FullName)
$content = Get-Content -Raw $file.FullName
if ($relative -notlike 'internal\platform\windows\*' -and ($content -match 'powershell\.exe|Set-NetIPInterface|New-NetRoute|Remove-NetRoute')) {
$violations += "$relative contains a Windows network command outside internal/platform/windows"
}
if ($content -match 'New-NetNat|Get-NetNat|Remove-NetNat|Set-NetNat|IPEnableRouter|Disable-NetFirewall|Set-NetFirewallProfile|netsh\s+advfirewall') {
$violations += "$relative contains forbidden WinNAT, Windows forwarding, or firewall-disabling code"
}
if ($content -match 'S7Proxy|ModbusProxy|HTTPProxy|RDPProxy|WireGuardNT|TransitCIDR|TransitPrefix|SecondSubnetAdapter|SubnetAdapterName|\bP2P\b|\bSTUN\b|\bTURN\b|TAPDevice|EthernetFrame') {
$violations += "$relative contains a forbidden architecture symbol"
}
}
$siteConfig = Get-Content -Raw (Join-Path $repository 'internal/config/client.go')
if ($siteConfig -match '(?i)remote[_ ]?(cidr|subnet)|transit[_ ]?(cidr|prefix)') {
$violations += 'Site YAML configuration contains a forbidden Remote/Transit CIDR field'
}
$packetMux = Get-Content -Raw (Join-Path $repository 'internal/overlay/clientwg/packetmux.go')
if ($packetMux -match 'gopacket|tcpip/header|tcprelay|udprelay|pingrelay') {
$violations += 'PacketMux contains protocol parsing beyond IPv4 destination CIDR classification'
}
$serverSources = (Get-Content -Raw (Join-Path $repository 'cmd/server/main.go')) + (Get-Content -Raw (Join-Path $repository 'internal/overlay/serverwg/manager_linux.go'))
if ($serverSources -match 'wireguard/device|internal/overlay/clientwg|NewPacketMux|NewMuxTun') {
$violations += 'Server data plane imports client wireguard-go or PacketMux code'
}
$composePaths = @('deploy/docker/compose.yaml', 'deploy/docker/compose.release.yaml')
$composes = @{}
foreach ($composePath in $composePaths) {
$compose = Get-Content -Raw (Join-Path $repository $composePath)
$composes[$composePath] = $compose
if ($compose -match '(?m)^\s*privileged\s*:') { $violations += "$composePath enables privileged mode" }
if ($compose -notmatch 'NET_ADMIN') { $violations += "$composePath does not grant NET_ADMIN" }
if ($compose -notmatch '/dev/net/tun') { $violations += "$composePath does not map /dev/net/tun" }
if ($compose -match '(?m)^\s*-\s*"?(7001|6200):') { $violations += "$composePath publicly publishes an Overlay-only Control or Session port" }
if ($compose -match '(?i)MASQUERADE|\bSNAT\b') { $violations += "$composePath configures forbidden Overlay NAT" }
if ($compose -notmatch '\./data:/app/data') { $violations += "$composePath does not use the required ./data:/app/data persistence mount" }
if ($compose -notmatch '\$\{REMLINK_WG_PORT:-51820\}:\$\{REMLINK_WG_PORT:-51820\}/udp') {
$violations += "$composePath WireGuard host/container port mapping does not follow REMLINK_WG_PORT"
}
}
$compose = $composes['deploy/docker/compose.yaml']
$dockerServerConfig = Get-Content -Raw (Join-Path $repository 'deploy/docker/server.yaml')
$dockerfile = Get-Content -Raw (Join-Path $repository 'deploy/docker/Dockerfile')
if ($dockerServerConfig -notmatch '(?m)^\s*directory:\s*["'']?/app/data["'']?\s*$' -or $dockerfile -notmatch 'VOLUME \["/app/data"\]') {
$violations += 'Docker Server data directory does not target the required ./data:/app/data persistence mount'
}
$releaseDockerfile = Get-Content -Raw (Join-Path $repository 'deploy/docker/Dockerfile.release')
if ($releaseDockerfile -notmatch 'COPY linux-amd64/remlink-server' -or $releaseDockerfile -notmatch 'VOLUME \["/app/data"\]') {
$violations += 'Release Dockerfile does not package the released Server binary with persistent data'
}
$identityPathSource = Get-Content -Raw (Join-Path $repository 'internal/identity/path_windows.go')
$wintunRuntimeSource = Get-Content -Raw (Join-Path $repository 'internal/platform/windows/wintunruntime/runtime_windows.go')
if (($identityPathSource + $wintunRuntimeSource) -match 'ProgramData') {
$violations += 'Portable Windows identity or Wintun runtime still depends on ProgramData'
}
$releaseScript = Get-Content -Raw (Join-Path $repository 'scripts/build-release.ps1')
foreach ($packageName in @('RemLink-Engineer-v', 'RemLink-Site-v', 'RemLink-Server-v')) {
if ($releaseScript -notmatch [regex]::Escape($packageName)) {
$violations += "Release build does not define the independent $packageName package"
}
}
if ($releaseScript -match '\$windowsRoot') {
$violations += 'Release build still combines Engineer and Site under one Windows package root'
}
if ($releaseScript -notmatch 'go build[^\r\n]+-tags\s+"desktop,production"[^\r\n]+RemLinkEngineer\.exe') {
$violations += 'Engineer release build does not use the mandatory Wails desktop,production tags'
}
foreach ($dockerfileEntry in @{
'deploy/docker/Dockerfile' = $dockerfile
'deploy/docker/Dockerfile.release' = $releaseDockerfile
}.GetEnumerator()) {
if ($dockerfileEntry.Value -notmatch 'Acquire::Retries=5' -or
$dockerfileEntry.Value -notmatch 'Acquire::http::Timeout=30' -or
$dockerfileEntry.Value -notmatch 'APT_FORCE_IPV4' -or
$dockerfileEntry.Value -notmatch 'APT_DEBIAN_MIRROR' -or
$dockerfileEntry.Value -notmatch 'APT_SECURITY_MIRROR') {
$violations += "$($dockerfileEntry.Key) does not bound apt network waits and expose the IPv4 fallback"
}
}
$moduleText = Get-Content -Raw (Join-Path $repository 'go.mod')
$packageText = Get-Content -Raw (Join-Path $repository 'frontend/package.json')
if ($moduleText -match '@latest' -or $packageText -match '"(latest|\*)"') { $violations += 'A floating dependency version was found' }
$serverCollector = Get-Content -Raw (Join-Path $repository 'scripts/validation/Collect-ServerEvidence.sh')
if ($serverCollector -match '(?im)wg\s+show[^\r\n]*\bdump\b|show[^\r\n]*(private-key|preshared-key)') {
$violations += 'Server evidence collector may export WireGuard private or preshared keys'
}
$preflight = Get-Content -Raw (Join-Path $repository 'deploy/docker/preflight.sh')
if ($preflight -notmatch 'probe_interface="([^"]+)"') {
$violations += 'Docker preflight does not define a fixed WireGuard probe interface'
} elseif ($Matches[1].Length -gt 15) {
$violations += "Docker preflight interface '$($Matches[1])' exceeds the Linux 15-character interface-name limit"
}
if ($preflight -notmatch 'ip link error') {
$violations += 'Docker preflight suppresses the underlying ip link diagnostic'
}
if ($violations.Count -gt 0) {
$violations | ForEach-Object { Write-Error $_ }
exit 1
}
Write-Host "Architecture policy checks passed ($($goFiles.Count) production Go files inspected)."
@@ -0,0 +1,48 @@
[CmdletBinding()]
param()
$ErrorActionPreference = "Stop"
$repository = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$engineerSource = Get-Content -Raw -LiteralPath (Join-Path $repository "frontend/engineer/src/api.ts")
if ($engineerSource -notmatch 'import\.meta\.env\.DEV' -or $engineerSource -notmatch 'production Demo fallback is disabled') {
throw "Engineer API does not fail closed when the production Native binding is absent"
}
if ($engineerSource -match '\\d\{1,3\}.*CIDR_INVALID') {
throw "Engineer frontend duplicates CIDR validation instead of calling the Go Core"
}
$serverAppSource = Get-Content -Raw -LiteralPath (Join-Path $repository "frontend/server/src/App.vue")
if ($serverAppSource -notmatch 'Array\.isArray' -or $serverAppSource -notmatch 'logs\.value=arrayOrEmpty') {
throw "Server frontend does not normalize nullable legacy Admin list responses"
}
$checks = @(
[ordered]@{
name = "Engineer"
root = Join-Path $repository "frontend/engineer/dist"
required = @("GetState", "CreateSession", "CheckCIDRs", "production Demo fallback is disabled")
forbidden = @("4488624737516445881", "site-qingdao", "dev-request", "青岛现场 01")
},
[ordered]@{
name = "Server"
root = Join-Path $repository "frontend/server/dist"
required = @("/api/v1/admin/nodes", "/api/v1/admin/sessions", "/api/v1/admin/network", "/api/v1/admin/logs")
forbidden = @("8648912340291133", "demo-join-token-after-rotation", "节点上线,Overlay")
}
)
foreach ($check in $checks) {
if (-not (Test-Path -LiteralPath $check.root -PathType Container)) {
throw "$($check.name) production bundle is missing; run npm build first"
}
$bundle = (Get-ChildItem -LiteralPath $check.root -Recurse -File | ForEach-Object { Get-Content -Raw -LiteralPath $_.FullName }) -join "`n"
foreach ($required in $check.required) {
if (-not $bundle.Contains($required)) {
throw "$($check.name) production bundle is missing required runtime marker: $required"
}
}
foreach ($forbidden in $check.forbidden) {
if ($bundle.Contains($forbidden)) {
throw "$($check.name) production bundle contains development fixture data: $forbidden"
}
}
}
Write-Host "Frontend production bundles contain live adapters and no Demo fixtures"
+177
View File
@@ -0,0 +1,177 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$PackagePath,
[ValidateSet("Engineer", "Site", "Server")]
[string]$Role
)
$ErrorActionPreference = "Stop"
$resolvedPackage = [System.IO.Path]::GetFullPath($PackagePath)
$temporaryRoot = $null
$packageRoot = $resolvedPackage
try {
if (Test-Path -LiteralPath $resolvedPackage -PathType Leaf) {
if ([System.IO.Path]::GetExtension($resolvedPackage) -ne ".zip") {
throw "Release package file must be a ZIP: $resolvedPackage"
}
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-release-verify-" + [guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Path $temporaryRoot | Out-Null
Expand-Archive -LiteralPath $resolvedPackage -DestinationPath $temporaryRoot
$roots = @(Get-ChildItem -LiteralPath $temporaryRoot -Directory)
$looseFiles = @(Get-ChildItem -LiteralPath $temporaryRoot -File)
if ($roots.Count -ne 1 -or $looseFiles.Count -ne 0) {
throw "Release ZIP must contain exactly one top-level package directory"
}
$packageRoot = $roots[0].FullName
} elseif (-not (Test-Path -LiteralPath $resolvedPackage -PathType Container)) {
throw "Release package not found: $resolvedPackage"
}
$roleMarkers = [ordered]@{
Engineer = "RemLinkEngineer.exe"
Site = "RemLinkSite.exe"
Server = "linux-amd64/remlink-server"
}
$detectedRoles = @($roleMarkers.GetEnumerator() | Where-Object {
Test-Path -LiteralPath (Join-Path $packageRoot $_.Value) -PathType Leaf
} | ForEach-Object Key)
if (-not $Role) {
if ($detectedRoles.Count -ne 1) {
throw "Unable to infer one package role; found markers for: $($detectedRoles -join ', ')"
}
$Role = $detectedRoles[0]
}
if ($detectedRoles.Count -ne 1 -or $detectedRoles[0] -ne $Role) {
throw "$Role package must contain only its own executable; found markers for: $($detectedRoles -join ', ')"
}
$forbiddenExecutableNames = switch ($Role) {
Engineer { @("RemLinkSite.exe", "remlink-server") }
Site { @("RemLinkEngineer.exe", "remlink-server") }
Server { @("RemLinkEngineer.exe", "RemLinkSite.exe") }
}
$foreignExecutables = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | Where-Object { $_.Name -in $forbiddenExecutableNames })
if ($foreignExecutables.Count -ne 0) {
throw "$Role package contains a foreign executable: $($foreignExecutables[0].FullName)"
}
$required = @(
"README.md", "BUILD-INFO.json", "SHA256SUMS.txt", "docs/deployment-and-usage.md",
"scripts/validation/Test-ReleasePackage.ps1"
)
switch ($Role) {
Engineer {
$required += @("RemLinkEngineer.exe", "engineer.yaml", "THIRD_PARTY_NOTICES.md")
}
Site {
$required += @("RemLinkSite.exe", "site.yaml", "THIRD_PARTY_NOTICES.md")
}
Server {
$required += @(
"linux-amd64/remlink-server", "linux-amd64/server.yaml", "linux-amd64/THIRD_PARTY_NOTICES.md",
"docker/compose.yaml", "docker/Dockerfile", "docker/compose.release.yaml", "docker/Dockerfile.release",
"docker/.env.example", "docker/.env.china.example", "docker/preflight.sh", "docker/server.yaml", "docker/README.md",
"docs/implementation-status.md",
"scripts/validation/New-AcceptanceRun.ps1", "scripts/validation/Set-AcceptanceResult.ps1",
"scripts/validation/Test-AcceptanceRun.ps1", "scripts/validation/Test-AcceptanceTools.ps1",
"scripts/validation/Test-ReleasePackage.ps1", "scripts/validation/Collect-WindowsEvidence.ps1",
"scripts/validation/Collect-ServerEvidence.sh", "scripts/validation/Test-RemoteTargets.ps1",
"docs/validation/T01-T18-runbook.md", "docs/validation/requirements-evidence.md"
)
}
}
foreach ($relative in $required) {
$target = Join-Path $packageRoot $relative
if (-not (Test-Path -LiteralPath $target -PathType Leaf)) {
throw "Required $Role release entry is missing: $relative"
}
if ((Get-Item -LiteralPath $target).Length -eq 0) {
throw "Required $Role release entry is empty: $relative"
}
}
if ($Role -in @("Engineer", "Site")) {
$clientConfigName = if ($Role -eq "Engineer") { "engineer.yaml" } else { "site.yaml" }
$clientConfig = Get-Content -Raw -LiteralPath (Join-Path $packageRoot $clientConfigName)
if ($clientConfig -notmatch '(?m)^join_token:\s*') {
throw "$Role package YAML does not expose the join_token convenience field"
}
if ($clientConfig -match '(?im)^\s*(node_token|wg_private_key)\s*:') {
throw "$Role package YAML contains a durable Node secret field"
}
}
$checksumPath = Join-Path $packageRoot "SHA256SUMS.txt"
$checksums = @{}
foreach ($line in Get-Content -LiteralPath $checksumPath) {
if ($line -notmatch '^([0-9a-f]{64}) (.+)$') {
throw "Malformed checksum line: $line"
}
$relative = $Matches[2].Replace('\', '/')
if ([System.IO.Path]::IsPathRooted($relative) -or $relative -match '(^|/)\.\.(/|$)' -or $relative -eq "SHA256SUMS.txt") {
throw "Unsafe or self-referential checksum path: $relative"
}
if ($checksums.ContainsKey($relative)) {
throw "Duplicate checksum path: $relative"
}
$checksums[$relative] = $Matches[1]
}
$actualFiles = @(Get-ChildItem -LiteralPath $packageRoot -Recurse -File | ForEach-Object {
[System.IO.Path]::GetRelativePath($packageRoot, $_.FullName).Replace('\', '/')
} | Where-Object { $_ -ne "SHA256SUMS.txt" })
if ($checksums.Count -ne $actualFiles.Count) {
throw "Checksum count $($checksums.Count) does not match package file count $($actualFiles.Count)"
}
foreach ($relative in $actualFiles) {
if (-not $checksums.ContainsKey($relative)) {
throw "Package file is not covered by SHA256SUMS.txt: $relative"
}
$actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath (Join-Path $packageRoot $relative)).Hash.ToLowerInvariant()
if ($actualHash -ne $checksums[$relative]) {
throw "Checksum mismatch: $relative"
}
}
$buildInfo = Get-Content -Raw -LiteralPath (Join-Path $packageRoot "BUILD-INFO.json") | ConvertFrom-Json
if (-not $buildInfo.version -or $buildInfo.role -ne $Role -or @($buildInfo.target).Count -ne 1) {
throw "BUILD-INFO.json does not describe one $Role target"
}
if ($Role -in @("Engineer", "Site") -and $buildInfo.portable_data_root -ne "executable_directory") {
throw "$Role BUILD-INFO.json does not declare its portable executable data root"
}
if ($Role -eq "Engineer" -and (@($buildInfo.wails_build_tags) -join ',') -ne 'desktop,production') {
throw "Engineer BUILD-INFO.json does not declare the mandatory Wails desktop,production tags"
}
if ($Role -eq "Server") {
$acceptanceRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("remlink-acceptance-smoke-" + [guid]::NewGuid().ToString("N"))
try {
& (Join-Path $packageRoot "scripts/validation/New-AcceptanceRun.ps1") -OutputDirectory $acceptanceRoot
$manifest = Get-Content -Raw -LiteralPath (Join-Path $acceptanceRoot "acceptance-run.json") | ConvertFrom-Json
if ($manifest.schema_version -ne 2 -or @($manifest.gates).Count -ne 4 -or @($manifest.scenarios).Count -ne 18) {
throw "Packaged acceptance initializer produced an invalid manifest"
}
if (@($manifest.gates + $manifest.scenarios | Where-Object status -ne "NOT_RUN").Count -ne 0) {
throw "Packaged acceptance initializer fabricated a completed result"
}
if (-not (Test-Path -LiteralPath (Join-Path $acceptanceRoot "T01-T18-runbook.md") -PathType Leaf)) {
throw "Packaged acceptance initializer did not copy its runbook"
}
} finally {
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
if ($acceptanceRoot -and $acceptanceRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($acceptanceRoot).StartsWith("remlink-acceptance-smoke-")) {
Remove-Item -LiteralPath $acceptanceRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
Write-Host "$Role release package verified: $packageRoot ($($checksums.Count) checksums)"
} finally {
if ($temporaryRoot) {
$tempBase = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
if ($temporaryRoot.StartsWith($tempBase, [System.StringComparison]::OrdinalIgnoreCase) -and
[System.IO.Path]::GetFileName($temporaryRoot).StartsWith("remlink-release-verify-")) {
Remove-Item -LiteralPath $temporaryRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
+53
View File
@@ -0,0 +1,53 @@
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$TargetIPv4,
[Parameter(Mandatory = $true)]
[string]$OutputDirectory,
[int[]]$TCPPorts = @(102, 502, 80, 3389),
[int]$UDPPort = 0,
[string]$UDPPayload = "RemLink-T09",
[int]$TimeoutMilliseconds = 3000
)
$ErrorActionPreference = "Stop"
$address = [System.Net.IPAddress]::Parse($TargetIPv4)
if ($address.AddressFamily -ne [System.Net.Sockets.AddressFamily]::InterNetwork) {
throw "TargetIPv4 must be IPv4"
}
$resolved = [System.IO.Path]::GetFullPath($OutputDirectory)
New-Item -ItemType Directory -Force -Path $resolved | Out-Null
$results = [ordered]@{
captured_at = (Get-Date).ToUniversalTime().ToString("o")
target = $TargetIPv4
ping = $null
tcp = @()
udp = $null
}
$ping = Test-Connection -TargetName $TargetIPv4 -Count 4 -ErrorAction SilentlyContinue
$results.ping = @($ping | Select-Object Address, Latency, Status)
foreach ($port in $TCPPorts) {
$probe = Test-NetConnection -ComputerName $TargetIPv4 -Port $port -InformationLevel Detailed -WarningAction SilentlyContinue
$results.tcp += [ordered]@{ port = $port; success = [bool]$probe.TcpTestSucceeded; remote_address = "$($probe.RemoteAddress)" }
}
if ($UDPPort -gt 0) {
$client = [System.Net.Sockets.UdpClient]::new([System.Net.Sockets.AddressFamily]::InterNetwork)
try {
$client.Client.ReceiveTimeout = $TimeoutMilliseconds
$payload = [Text.Encoding]::UTF8.GetBytes($UDPPayload)
[void]$client.Send($payload, $payload.Length, $TargetIPv4, $UDPPort)
$remote = [System.Net.IPEndPoint]::new([System.Net.IPAddress]::Any, 0)
$reply = $client.Receive([ref]$remote)
$replyText = [Text.Encoding]::UTF8.GetString($reply)
$results.udp = [ordered]@{ port = $UDPPort; success = ($replyText -eq $UDPPayload); reply = $replyText; remote = "$remote" }
} catch {
$results.udp = [ordered]@{ port = $UDPPort; success = $false; error = $_.Exception.Message }
} finally {
$client.Dispose()
}
}
$path = Join-Path $resolved ((Get-Date).ToUniversalTime().ToString("yyyyMMddTHHmmssZ") + "-target-$TargetIPv4.json")
$results | ConvertTo-Json -Depth 8 | Set-Content -Encoding utf8 $path
Get-FileHash -Algorithm SHA256 -LiteralPath $path | Format-List | Out-File -Encoding utf8 "$path.sha256.txt"
Write-Host "Remote target evidence: $path"