publish: carry the Windows product into the projection
The tree adds the installer, service and test paths admitted by this source commit. Nothing else changes about what may cross. Source-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8 Policy-Sha: 3e4a3aa4ce092e9e3f51bc78daf3ddc14d9638f8 Tree-Digest: 89d6fce3828a0e0de0c8eeb41ee6750f4462217e2a04b5190297134d7f4a50c3
This commit is contained in:
parent
7c6ab1503e
commit
765ec4188f
35 changed files with 1425 additions and 379 deletions
|
|
@ -18,14 +18,14 @@
|
|||
elements, explicit Directory nesting, no StandardDirectory shorthand) —
|
||||
wixl's grammar, not v4's.
|
||||
|
||||
Scope (v1): install the already-built, already-signed server binary as a
|
||||
Windows service, with a forward-only upgrade guard. It does NOT bundle
|
||||
Scope (v1): install the staged server binary as a Windows service. Payload
|
||||
custody is checked by build-msi.sh; this is not an Authenticode claim.
|
||||
It does NOT bundle
|
||||
PostgreSQL — docker-compose doesn't either (postgres:16-alpine is a
|
||||
separate container); a native install is expected to point at a reachable
|
||||
Postgres the same way the docker path does via config/.env. The installer's
|
||||
job ends where the docker quick-start's does today: "service is running,
|
||||
go to http://localhost:<port>/setup and finish configuration in the
|
||||
browser" (README.md's existing docker flow, same UX, different transport).
|
||||
service is left stopped until the operator supplies database credentials,
|
||||
admin credentials and signing configuration in redflag.env, then starts it.
|
||||
|
||||
Config: the Go binary only ever reads OS environment variables
|
||||
(server/internal/config/config.go). Docker gets those from compose's
|
||||
|
|
@ -69,18 +69,24 @@
|
|||
Forward-only, no downgrade — ETHOS doctrine elsewhere in this codebase
|
||||
(refresh-token rotation, capability tokens) applied to the installer
|
||||
itself. Windows Installer only compares the first three version
|
||||
fields for upgrade detection — pass $(var.RedFlagVersion) as the
|
||||
3-part form from CI, same 3-vs-4-part reconciliation bump-version.sh
|
||||
already does for desktop/Cargo.toml vs tauri.conf.json.
|
||||
fields for upgrade detection. msi_version.py encodes patch*100+revision
|
||||
in the third field, so fourth-field RedFlag releases remain ordered.
|
||||
Same-version package replacement is safe from fourth-field collisions
|
||||
under that mapping and prevents duplicate product registrations.
|
||||
-->
|
||||
<MajorUpgrade DowngradeErrorMessage="A newer version of RedFlag Server is already installed. Forward-only upgrades only." />
|
||||
<MajorUpgrade AllowSameVersionUpgrades="yes" DowngradeErrorMessage="A newer version of RedFlag Server is already installed. Forward-only upgrades only." />
|
||||
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<Property Id="ARPNOMODIFY" Value="1" />
|
||||
<UIRef Id="RedFlagServerUI" />
|
||||
|
||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||
<Directory Id="ProgramFiles64Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="RedFlag">
|
||||
<Component Id="ServerBinary" Guid="7a2d5e9c-3f81-4c6b-9e0a-2b7d4f8c1a63">
|
||||
<!-- New component identity for the corrected 64-bit component;
|
||||
the product UpgradeCode remains the existing Server identity. -->
|
||||
<Component Id="ServerBinary" Guid="0d252388-94fb-468f-a050-9087d11f03dd" Win64="yes">
|
||||
<File Id="RedFlagServerExe"
|
||||
Source="dist/redflag-server-windows-amd64.exe"
|
||||
Name="redflag-server.exe"
|
||||
|
|
@ -105,7 +111,6 @@
|
|||
|
||||
<ServiceControl Id="RedFlagServerServiceControl"
|
||||
Name="RedFlagServer"
|
||||
Start="install"
|
||||
Stop="both"
|
||||
Remove="uninstall"
|
||||
Wait="yes" />
|
||||
|
|
@ -124,7 +129,7 @@
|
|||
-->
|
||||
<Directory Id="CommonAppDataFolder">
|
||||
<Directory Id="REDFLAGDATA" Name="RedFlag">
|
||||
<Component Id="ConfigTemplate" Guid="1c6e8a2d-9b47-4f3e-a1c8-6d3e9b4a2f70">
|
||||
<Component Id="ConfigTemplate" Guid="1c6e8a2d-9b47-4f3e-a1c8-6d3e9b4a2f70" Win64="no">
|
||||
<File Id="RedFlagEnvExample"
|
||||
Source="config/redflag.env.example"
|
||||
Name="redflag.env.example"
|
||||
|
|
@ -140,11 +145,11 @@
|
|||
</Feature>
|
||||
|
||||
<!--
|
||||
Desktop tray feature slots in here later (Casey, 2026-06-30: "checkbox
|
||||
Desktop feature slots in here later (Casey, 2026-06-30: "checkbox
|
||||
for adding the desktop alongside it" plus future screen-capture work
|
||||
from the session-broker RAF). Deliberately not stubbed as a disabled
|
||||
checkbox — a checkbox that does nothing when checked is worse than no
|
||||
checkbox. Wire it once the desktop/Tauri artifact has a real merge
|
||||
checkbox. Wire it once the complete Desktop/Qt payload has an owned
|
||||
path into this installer, per Casey's "keep it separate for now."
|
||||
-->
|
||||
|
||||
|
|
|
|||
98
installer/windows/ServerUI.wxs
Normal file
98
installer/windows/ServerUI.wxs
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Wix xmlns="http://schemas.microsoft.com/wix/2006/wi">
|
||||
<Fragment>
|
||||
<UI Id="RedFlagServerUI">
|
||||
<TextStyle Id="Normal" FaceName="Segoe UI" Size="9" />
|
||||
<TextStyle Id="Title" FaceName="Segoe UI" Size="14" Bold="yes" />
|
||||
<Property Id="DefaultUIFont" Value="Normal" />
|
||||
|
||||
<Dialog Id="ServerWelcome" Width="370" Height="270" Title="[ProductName] Setup">
|
||||
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Install RedFlag Server" />
|
||||
<Control Id="Scope" Type="Text" X="20" Y="55" Width="330" Height="50" Text="This installs the fleet authority and web dashboard as a Windows service. It does not install RedFlag Desktop, a local Agent, or PostgreSQL." />
|
||||
<Control Id="Requirements" Type="Text" X="20" Y="115" Width="330" Height="70" Text="You need administrator rights and an existing PostgreSQL database. After installation, create redflag.env from the supplied example, set your credentials and signing key, then start RedFlagServer." />
|
||||
<Control Id="Boundary" Type="Text" X="20" Y="190" Width="330" Height="35" Text="The service remains stopped after setup. Standalone local-machine installation is not part of this package." />
|
||||
<Control Id="Next" Type="PushButton" X="230" Y="240" Width="60" Height="18" Default="yes" Text="Next">
|
||||
<Publish Event="NewDialog" Value="ServerLicense">1</Publish>
|
||||
</Control>
|
||||
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
|
||||
<Publish Event="EndDialog" Value="Exit">1</Publish>
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<Dialog Id="ServerLicense" Width="370" Height="270" Title="[ProductName] License">
|
||||
<Control Id="Title" Type="Text" X="20" Y="15" Width="330" Height="24" Text="{\Title}GNU AGPL version 3" />
|
||||
<Control Id="License" Type="ScrollableText" X="20" Y="45" Width="330" Height="150" Sunken="yes" TabSkip="no">
|
||||
<Text SourceFile="dist/License.rtf" />
|
||||
</Control>
|
||||
<Control Id="Notice" Type="Text" X="20" Y="202" Width="330" Height="30" Text="RedFlag is free software under the license above. Click Install to install the Server files and register its service." />
|
||||
<Control Id="Back" Type="PushButton" X="160" Y="240" Width="60" Height="18" Text="Back">
|
||||
<Publish Event="NewDialog" Value="ServerWelcome">1</Publish>
|
||||
</Control>
|
||||
<Control Id="Install" Type="PushButton" X="230" Y="240" Width="60" Height="18" Default="yes" Text="Install">
|
||||
<Publish Event="EndDialog" Value="Return">1</Publish>
|
||||
</Control>
|
||||
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
|
||||
<Publish Event="EndDialog" Value="Exit">1</Publish>
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<Dialog Id="ServerMaintenance" Width="370" Height="270" Title="[ProductName] Maintenance">
|
||||
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Maintain RedFlag Server" />
|
||||
<Control Id="Scope" Type="Text" X="20" Y="65" Width="330" Height="100" Text="Repair reinstalls the Server files. Remove stops and removes its service and package files. Your redflag.env, server keys and database are preserved. Neither action changes a separately installed Agent or Desktop. Start the service manually after repair." />
|
||||
<Control Id="Repair" Type="PushButton" X="90" Y="200" Width="80" Height="22" Default="yes" Text="Repair">
|
||||
<Publish Property="REINSTALL" Value="ALL" Order="1">1</Publish>
|
||||
<Publish Property="REINSTALLMODE" Value="omus" Order="2">1</Publish>
|
||||
<Publish Event="EndDialog" Value="Return" Order="3">1</Publish>
|
||||
</Control>
|
||||
<Control Id="Remove" Type="PushButton" X="190" Y="200" Width="80" Height="22" Text="Remove">
|
||||
<Publish Property="REMOVE" Value="ALL" Order="1">1</Publish>
|
||||
<Publish Event="EndDialog" Value="Return" Order="2">1</Publish>
|
||||
</Control>
|
||||
<Control Id="Cancel" Type="PushButton" X="300" Y="240" Width="60" Height="18" Cancel="yes" Text="Cancel">
|
||||
<Publish Event="EndDialog" Value="Exit">1</Publish>
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<Dialog Id="ServerProgress" Width="370" Height="180" Title="[ProductName] Setup" Modeless="yes">
|
||||
<Control Id="Title" Type="Text" X="20" Y="20" Width="330" Height="24" Text="{\Title}Applying package changes" />
|
||||
<Control Id="Action" Type="Text" X="20" Y="70" Width="330" Height="30">
|
||||
<Subscribe Event="ActionText" Attribute="Text" />
|
||||
</Control>
|
||||
<Control Id="Progress" Type="ProgressBar" X="20" Y="110" Width="330" Height="16" ProgressBlocks="yes">
|
||||
<Subscribe Event="SetProgress" Attribute="Progress" />
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<Dialog Id="ServerComplete" Width="370" Height="270" Title="[ProductName] Setup">
|
||||
<Control Id="Title" Type="Text" X="20" Y="18" Width="330" Height="24" Text="{\Title}Package operation complete" />
|
||||
<Control Id="NextSteps" Type="Text" X="20" Y="65" Width="330" Height="135" Text="After installing or repairing: copy [CommonAppDataFolder]RedFlag\redflag.env.example to redflag.env in the same folder. Configure PostgreSQL, admin credentials, JWT secret and signing key. Start RedFlagServer in Services, then open http://localhost:8080 (or your configured port). Package completion does not verify database connectivity or application health. After removal, operator configuration and database remain." />
|
||||
<Control Id="Finish" Type="PushButton" X="290" Y="240" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
|
||||
<Publish Event="EndDialog" Value="Return">1</Publish>
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<Dialog Id="ServerFailed" Width="370" Height="180" Title="[ProductName] Setup">
|
||||
<Control Id="Message" Type="Text" X="20" Y="25" Width="330" Height="95" Text="Setup could not complete. Review the Windows Installer log before retrying. Do not assume the Server is installed or running." />
|
||||
<Control Id="Finish" Type="PushButton" X="290" Y="145" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
|
||||
<Publish Event="EndDialog" Value="Exit">1</Publish>
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<Dialog Id="ServerCancelled" Width="370" Height="180" Title="[ProductName] Setup">
|
||||
<Control Id="Message" Type="Text" X="20" Y="25" Width="330" Height="70" Text="Setup was cancelled. You can run this package again when ready." />
|
||||
<Control Id="Finish" Type="PushButton" X="290" Y="145" Width="60" Height="18" Default="yes" Cancel="yes" Text="Finish">
|
||||
<Publish Event="EndDialog" Value="Exit">1</Publish>
|
||||
</Control>
|
||||
</Dialog>
|
||||
|
||||
<InstallUISequence>
|
||||
<Show Dialog="ServerWelcome" After="CostFinalize">NOT Installed</Show>
|
||||
<Show Dialog="ServerMaintenance" After="ServerWelcome">Installed AND NOT REMOVE</Show>
|
||||
<Show Dialog="ServerProgress" Before="ExecuteAction">1</Show>
|
||||
<Show Dialog="ServerComplete" OnExit="success">1</Show>
|
||||
<Show Dialog="ServerFailed" OnExit="error">1</Show>
|
||||
<Show Dialog="ServerCancelled" OnExit="cancel">1</Show>
|
||||
</InstallUISequence>
|
||||
</UI>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
|
|
@ -15,11 +15,15 @@ PROOF_JSON="$4"
|
|||
SOURCE_COMMIT="$5"
|
||||
SOURCE_REF="$6"
|
||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
||||
MSI_VERSION=$(printf '%s\n' "$REDFLAG_VERSION" | cut -d. -f1-3)
|
||||
|
||||
for tool in jq msiinfo msiextract wixl; do
|
||||
for tool in jq msiinfo msiextract wixl python3; do
|
||||
command -v "$tool" >/dev/null || { echo "$tool not found" >&2; exit 2; }
|
||||
done
|
||||
[ "$(wixl --version)" = "0.106" ] || {
|
||||
echo "wixl 0.106 is required for the Server UI; use provision-msitools.sh" >&2
|
||||
exit 2
|
||||
}
|
||||
MSI_VERSION=$(python3 "$ROOT/installer/windows/msi_version.py" "$REDFLAG_VERSION")
|
||||
[ -f "$SERVER_EXE" ] || { echo "no such Server executable: $SERVER_EXE" >&2; exit 2; }
|
||||
|
||||
SERVER_EXE=$(realpath "$SERVER_EXE")
|
||||
|
|
@ -27,10 +31,12 @@ OUTPUT_MSI=$(realpath -m "$OUTPUT_MSI")
|
|||
PROOF_JSON=$(realpath -m "$PROOF_JSON")
|
||||
mkdir -p "$ROOT/installer/windows/dist" "$(dirname "$OUTPUT_MSI")" "$(dirname "$PROOF_JSON")"
|
||||
cp "$SERVER_EXE" "$ROOT/installer/windows/dist/redflag-server-windows-amd64.exe"
|
||||
python3 "$ROOT/installer/windows/render-license.py" \
|
||||
"$ROOT/LICENSE" "$ROOT/installer/windows/dist/License.rtf"
|
||||
|
||||
(
|
||||
cd "$ROOT/installer/windows"
|
||||
wixl Product.wxs -D RedFlagVersion="$MSI_VERSION" -o "$OUTPUT_MSI"
|
||||
wixl -a x64 --ext ui Product.wxs ServerUI.wxs -D RedFlagVersion="$MSI_VERSION" -o "$OUTPUT_MSI"
|
||||
)
|
||||
|
||||
CAB_FILE=$(find "$(dirname "$OUTPUT_MSI")" "$ROOT/installer/windows" \
|
||||
|
|
@ -52,6 +58,8 @@ fi
|
|||
--source-ref "$SOURCE_REF" \
|
||||
--proof-record "$PROOF_JSON"
|
||||
|
||||
python3 "$ROOT/installer/windows/verify-msi-boundary.py" "$OUTPUT_MSI"
|
||||
|
||||
FIXTURE="$ROOT/installer/windows/testdata/run3181-hollow.msi"
|
||||
if "$ROOT/installer/windows/verify-msi.sh" "$FIXTURE" \
|
||||
"$ROOT/installer/windows/dist/redflag-server-windows-amd64.exe" \
|
||||
|
|
|
|||
24
installer/windows/msi_version.py
Normal file
24
installer/windows/msi_version.py
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Preserve RedFlag's fourth version field in MSI's three-field identity."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
|
||||
|
||||
def product_version(version):
|
||||
if not re.fullmatch(r"[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}(\.[0-9]{1,2})?", version):
|
||||
raise ValueError("expected major.minor.patch[.revision], with revision 0..99")
|
||||
parts = [int(part) for part in version.split(".")]
|
||||
major, minor, patch = parts[:3]
|
||||
revision = parts[3] if len(parts) == 4 else 0
|
||||
build = patch * 100 + revision
|
||||
if major > 255 or minor > 255 or build > 65535:
|
||||
raise ValueError("version exceeds MSI limits (255.255.65535)")
|
||||
return f"{major}.{minor}.{build}"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
print(product_version(sys.argv[1]))
|
||||
except (ValueError, IndexError) as error:
|
||||
sys.exit(f"Invalid RedFlag MSI version: {error}")
|
||||
30
installer/windows/provision-msitools.sh
Normal file
30
installer/windows/provision-msitools.sh
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
#!/usr/bin/env bash
|
||||
# CI-only tool bootstrap shared by release and custody proof. wixl 0.101
|
||||
# silently omits the Server UI tables; 0.106 implements the required grammar.
|
||||
set -euo pipefail
|
||||
[ "$#" -eq 1 ] || { echo "usage: $0 <empty-install-prefix>" >&2; exit 2; }
|
||||
PREFIX=$(realpath -m "$1")
|
||||
[ ! -e "$PREFIX" ] || { echo "tool prefix already exists: $PREFIX" >&2; exit 2; }
|
||||
WORK=$(mktemp -d)
|
||||
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq build-essential valac bison gettext ninja-build \
|
||||
libglib2.0-dev libgsf-1-dev libgcab-dev libxml2-dev gobject-introspection \
|
||||
python3-venv curl jq
|
||||
|
||||
curl --fail --location --retry 3 \
|
||||
https://download.gnome.org/sources/msitools/0.106/msitools-0.106.tar.xz \
|
||||
-o "$WORK/msitools.tar.xz"
|
||||
echo "1ed34279cf8080f14f1b8f10e649474125492a089912e7ca70e59dfa2e5a659b $WORK/msitools.tar.xz" | sha256sum -c -
|
||||
curl --fail --location --retry 3 \
|
||||
https://files.pythonhosted.org/packages/55/a6/47b9353c331318a13eb050887eacfd61eb075746285f9baf7ef7de6ae235/meson-1.5.2-py3-none-any.whl \
|
||||
-o "$WORK/meson-1.5.2-py3-none-any.whl"
|
||||
echo "77706e2368a00d789c097632ccf4fc39251fba56d03e1e1b262559a3c7a08f5b $WORK/meson-1.5.2-py3-none-any.whl" | sha256sum -c -
|
||||
python3 -m venv "$WORK/venv"
|
||||
"$WORK/venv/bin/pip" install --no-index --no-deps "$WORK/meson-1.5.2-py3-none-any.whl"
|
||||
tar -xJf "$WORK/msitools.tar.xz" -C "$WORK"
|
||||
"$WORK/venv/bin/meson" setup "$WORK/build" "$WORK/msitools-0.106" \
|
||||
--prefix="$PREFIX" --libdir=lib
|
||||
"$WORK/venv/bin/meson" compile -C "$WORK/build"
|
||||
"$WORK/venv/bin/meson" install -C "$WORK/build"
|
||||
LD_LIBRARY_PATH="$PREFIX/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$PREFIX/bin/wixl" --version
|
||||
17
installer/windows/render-license.py
Normal file
17
installer/windows/render-license.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Put the repository's complete license into the MSI's native RTF control."""
|
||||
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
|
||||
def render(text):
|
||||
# The repository license is ASCII. Refuse lossy conversion if that changes.
|
||||
text.encode("ascii")
|
||||
escaped = text.replace("\\", "\\\\").replace("{", "\\{").replace("}", "\\}")
|
||||
return "{\\rtf1\\ansi\\deff0{\\fonttbl{\\f0 Segoe UI;}}\\f0\\fs18\n" + escaped.replace("\n", "\\par\n") + "}\n"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
source, destination = map(pathlib.Path, sys.argv[1:])
|
||||
destination.write_text(render(source.read_text(encoding="utf-8")), encoding="ascii")
|
||||
84
installer/windows/test-agent-verifier.ps1
Normal file
84
installer/windows/test-agent-verifier.ps1
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
# Run in elevated Windows PowerShell 5.1 and PowerShell 7 with operator-installed
|
||||
# OpenSSL 3 on PATH. Loads only verifier functions; never installs an agent.
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Template = Join-Path $PSScriptRoot "../../server/internal/services/templates/install/scripts/windows.ps1.tmpl"
|
||||
$Tokens = $null
|
||||
$ParseErrors = $null
|
||||
$AST = [System.Management.Automation.Language.Parser]::ParseFile(
|
||||
(Resolve-Path $Template).Path, [ref]$Tokens, [ref]$ParseErrors)
|
||||
if ($ParseErrors.Count -ne 0) { throw ($ParseErrors | Out-String) }
|
||||
$Names = @("Convert-HexToBytes", "Stop-Install", "Remove-InstallerScratchDirectory",
|
||||
"New-InstallerScratchDirectory", "Test-Ed25519Signature", "Assert-Ed25519Verifier")
|
||||
foreach ($Name in $Names) {
|
||||
$Function = $AST.Find({ param($Node)
|
||||
$Node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $Node.Name -eq $Name
|
||||
}, $true)
|
||||
if (-not $Function) { throw "Missing function: $Name" }
|
||||
. ([scriptblock]::Create($Function.Extent.Text))
|
||||
}
|
||||
|
||||
Assert-Ed25519Verifier
|
||||
$Key = Convert-HexToBytes "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||
$Sig = Convert-HexToBytes "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
|
||||
$Message = [byte[]]@(0x72)
|
||||
$WrongKey = Convert-HexToBytes "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a"
|
||||
if (Test-Ed25519Signature $Message $Sig $WrongKey) { throw "Wrong key accepted" }
|
||||
$Sig[0] = $Sig[0] -bxor 1
|
||||
if (Test-Ed25519Signature $Message $Sig $Key) { throw "Tampered signature accepted" }
|
||||
|
||||
$Rejected = $false
|
||||
try { Test-Ed25519Signature $Message ([byte[]]@(1)) $Key | Out-Null } catch { $Rejected = $true }
|
||||
if (-not $Rejected) { throw "Malformed signature accepted" }
|
||||
$Rejected = $false
|
||||
try { Test-Ed25519Signature $Message $Sig ([byte[]]@(1)) | Out-Null } catch { $Rejected = $true }
|
||||
if (-not $Rejected) { throw "Malformed key accepted" }
|
||||
$script:Ed25519OpenSSL = $null
|
||||
$Rejected = $false
|
||||
try { Test-Ed25519Signature $Message $Sig $Key | Out-Null } catch { $Rejected = $true }
|
||||
if (-not $Rejected) { throw "Missing verifier accepted" }
|
||||
# Cleanup must report its own trouble as a warning and leave the real failure
|
||||
# standing. Removing an absent directory is the cheapest way to force that path.
|
||||
$Warnings = @()
|
||||
Remove-InstallerScratchDirectory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "redflag-absent-$([guid]::NewGuid().ToString('N'))") -WarningVariable +Warnings -WarningAction SilentlyContinue
|
||||
if ($Warnings.Count -eq 0) { throw "Cleanup of a missing directory raised no warning" }
|
||||
|
||||
$script:Ed25519OpenSSL = $null
|
||||
$Primary = $null
|
||||
try {
|
||||
try { throw "PRIMARY FAILURE" } finally {
|
||||
Remove-InstallerScratchDirectory -Path (Join-Path ([System.IO.Path]::GetTempPath()) "redflag-absent-$([guid]::NewGuid().ToString('N'))") -WarningAction SilentlyContinue
|
||||
}
|
||||
} catch { $Primary = $_.Exception.Message }
|
||||
if ($Primary -ne "PRIMARY FAILURE") { throw "Cleanup masked the primary failure: got '$Primary'" }
|
||||
|
||||
# Stop-Install exits; run it in a child PowerShell so this script survives.
|
||||
# The regression it guards: with $ErrorActionPreference = "Stop", a bare
|
||||
# Write-Error inside the desktop try/catch threw, was caught by that catch,
|
||||
# printed as a skip warning, and the installer exited 0 on a hash mismatch.
|
||||
$Self = (Get-Process -Id $PID).Path
|
||||
$Probe = @"
|
||||
`$ErrorActionPreference = "Stop"
|
||||
$($AST.Find({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq "Stop-Install" }, $true).Extent.Text)
|
||||
try {
|
||||
Stop-Install @("REFUSAL LINE 1", "REFUSAL LINE 2", "REFUSAL LINE 3")
|
||||
} catch {
|
||||
Write-Host "SWALLOWED BY CATCH"
|
||||
}
|
||||
Write-Host "CONTINUED PAST REFUSAL"
|
||||
"@
|
||||
$ProbeFile = Join-Path ([System.IO.Path]::GetTempPath()) "redflag-probe-$([guid]::NewGuid().ToString('N')).ps1"
|
||||
Set-Content -LiteralPath $ProbeFile -Value $Probe
|
||||
try {
|
||||
$Output = & $Self -NoProfile -File $ProbeFile 2>&1 | Out-String
|
||||
$Code = $LASTEXITCODE
|
||||
} finally {
|
||||
Remove-InstallerScratchDirectory -Path $ProbeFile
|
||||
}
|
||||
if ($Code -ne 1) { throw "Refusal exited $Code, expected 1" }
|
||||
if ($Output -match "SWALLOWED BY CATCH") { throw "An enclosing catch swallowed the refusal" }
|
||||
if ($Output -match "CONTINUED PAST REFUSAL") { throw "Installer continued past a refusal" }
|
||||
foreach ($n in 1..3) {
|
||||
if ($Output -notmatch "REFUSAL LINE $n") { throw "Diagnostic line $n never reached the operator" }
|
||||
}
|
||||
|
||||
Write-Host "PASS: exact installer functions accept valid signatures and reject tampering, wrong keys, malformed inputs, and missing verifier; cleanup warns without masking."
|
||||
35
installer/windows/test_build_inputs.py
Normal file
35
installer/windows/test_build_inputs.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import importlib.util
|
||||
import pathlib
|
||||
import unittest
|
||||
|
||||
from msi_version import product_version
|
||||
|
||||
spec = importlib.util.spec_from_file_location("render_license", pathlib.Path(__file__).with_name("render-license.py"))
|
||||
license_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(license_module)
|
||||
|
||||
|
||||
class BuildInputs(unittest.TestCase):
|
||||
def test_revision_upgrades_are_distinct_and_ordered(self):
|
||||
releases = ["0.2.9.5", "0.2.9.6", "0.2.10", "0.3.0", "1.0.0"]
|
||||
versions = [tuple(map(int, product_version(v).split("."))) for v in releases]
|
||||
self.assertEqual(versions, sorted(set(versions)))
|
||||
self.assertGreater(versions[0], (0, 2, 9)) # Previous MSI scheme.
|
||||
self.assertEqual(product_version("0.2.9.6"), "0.2.906")
|
||||
|
||||
def test_three_part_release_equals_revision_zero(self):
|
||||
self.assertEqual(product_version("0.3.1"), product_version("0.3.1.0"))
|
||||
|
||||
def test_out_of_range_or_nonrelease_versions_are_rejected(self):
|
||||
for version in ["0.2.9.100", "256.1.0", "1.256.0", "1.0.656", "1.0.655.36", "0.2.9-rc1", "dev", "1.2", "1.2.3.4.5"]:
|
||||
with self.subTest(version=version), self.assertRaises(ValueError):
|
||||
product_version(version)
|
||||
|
||||
def test_license_escapes_rtf_syntax_without_losing_text(self):
|
||||
rendered = license_module.render("{license}\\path\nsecond line")
|
||||
self.assertIn(r"\{license\}\\path\par", rendered)
|
||||
self.assertIn("second line", rendered)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
47
installer/windows/verify-msi-boundary.py
Normal file
47
installer/windows/verify-msi-boundary.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Check Server package ownership and wizard tables after custody verification."""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
|
||||
def export(msi, table):
|
||||
return subprocess.check_output(["msiinfo", "export", msi, table], text=True)
|
||||
|
||||
|
||||
def rows(msi, table):
|
||||
lines = export(msi, table).splitlines()
|
||||
return [dict(zip(lines[0].split("\t"), line.split("\t"))) for line in lines[3:] if line]
|
||||
|
||||
|
||||
def verify(msi):
|
||||
properties = {row["Property"]: row["Value"] for row in rows(msi, "Property")}
|
||||
assert properties["ProductName"] == "RedFlag Server", "wrong product"
|
||||
assert properties["UpgradeCode"].lower().strip("{}") == "8f3c9e6a-4b1d-4a7f-9c2e-1d6a8b2f5e91", "Server upgrade identity changed"
|
||||
files = {row["FileName"].split("|")[-1] for row in rows(msi, "File")}
|
||||
assert files == {"redflag-server.exe", "redflag.env.example"}, f"unexpected Server payload: {files}"
|
||||
assert {row["Feature"] for row in rows(msi, "Feature")} == {"ServerFeature"}, "unexpected product mode"
|
||||
components = {row["Component"]: row for row in rows(msi, "Component")}
|
||||
assert int(components["ServerBinary"]["Attributes"]) & 256, "Server component is not 64-bit"
|
||||
services = rows(msi, "ServiceInstall")
|
||||
assert len(services) == 1 and services[0]["Name"] == "RedFlagServer", "unexpected service ownership"
|
||||
controls = rows(msi, "ServiceControl")
|
||||
assert len(controls) == 1 and controls[0]["Name"] == "RedFlagServer", "unexpected service control"
|
||||
events = int(controls[0]["Event"])
|
||||
assert not events & (1 | 16), "MSI must not start an unconfigured service"
|
||||
assert events & (2 | 32 | 128) == (2 | 32 | 128), "stop/remove lifecycle missing"
|
||||
dialogs = {row["Dialog"] for row in rows(msi, "Dialog")}
|
||||
assert {"ServerWelcome", "ServerLicense", "ServerMaintenance", "ServerProgress", "ServerComplete", "ServerFailed", "ServerCancelled"} <= dialogs, "wizard incomplete"
|
||||
controls_text = export(msi, "Control")
|
||||
assert "GNU AFFERO GENERAL PUBLIC LICENSE" in controls_text, "full license text missing"
|
||||
assert "How to Apply These Terms to Your New Programs" in controls_text, "license text truncated"
|
||||
summary = subprocess.check_output(["msiinfo", "suminfo", msi], text=True)
|
||||
assert "x64;" in summary, "Server AMD64 payload needs an x64 MSI"
|
||||
print("Server package boundary verified; Windows UI and installation still require native acceptance.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
verify(sys.argv[1])
|
||||
except (AssertionError, KeyError, ValueError, subprocess.CalledProcessError) as error:
|
||||
sys.exit(f"Server package boundary failed: {error}")
|
||||
Loading…
Reference in a new issue