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
|
|
@ -224,6 +224,8 @@ jobs:
|
||||||
cache: true
|
cache: true
|
||||||
- name: Install template integrity
|
- name: Install template integrity
|
||||||
run: cd server && go test -run 'TestInstallTemplateRenders|TestFreshInstallConfigKeys|TestInstallTemplateScriptletSyntax' -v -count=1 ./internal/services/
|
run: cd server && go test -run 'TestInstallTemplateRenders|TestFreshInstallConfigKeys|TestInstallTemplateScriptletSyntax' -v -count=1 ./internal/services/
|
||||||
|
- name: Package ownership guards
|
||||||
|
run: python3 -m unittest discover -s installer/linux -p 'test_*.py' -v
|
||||||
|
|
||||||
# Dependency vulnerability scanning — RedFlag held to the supply-chain standard
|
# Dependency vulnerability scanning — RedFlag held to the supply-chain standard
|
||||||
# it enforces on the fleet. Tools installed directly (no third-party actions) so
|
# it enforces on the fleet. Tools installed directly (no third-party actions) so
|
||||||
|
|
@ -296,10 +298,18 @@ jobs:
|
||||||
# window rather than a single commit.
|
# window rather than a single commit.
|
||||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ] || \
|
||||||
[ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
|
[ "${{ github.event.before }}" = "0000000000000000000000000000000000000000" ]; then
|
||||||
|
# A run owns what its branch adds to the trunk, so measure from
|
||||||
|
# the merge base. A counted window reaches past that base and
|
||||||
|
# fails on commits the run did not introduce.
|
||||||
|
BASE="$(git merge-base origin/main HEAD 2>/dev/null || true)"
|
||||||
|
if [ -n "$BASE" ] && [ "$BASE" != "$(git rev-parse HEAD)" ]; then
|
||||||
|
RANGE="$BASE..HEAD"
|
||||||
|
else
|
||||||
RANGE="$(git rev-list --max-count=10 HEAD | tail -1)^..HEAD"
|
RANGE="$(git rev-list --max-count=10 HEAD | tail -1)^..HEAD"
|
||||||
git rev-parse --verify "${RANGE%%..*}" >/dev/null 2>&1 || RANGE="HEAD"
|
git rev-parse --verify "${RANGE%%..*}" >/dev/null 2>&1 || RANGE="HEAD"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
fi
|
||||||
python3 .publication/commit_voice.py --range "$RANGE" \
|
python3 .publication/commit_voice.py --range "$RANGE" \
|
||||||
--allowlist .publication/commit-voice-allowlist.json \
|
--allowlist .publication/commit-voice-allowlist.json \
|
||||||
--repository Fimeg/RedFlag
|
--repository Fimeg/RedFlag
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ name: msi-custody-proof
|
||||||
# Manual internal proof for an ordinary branch. It calls the same MSI build
|
# Manual internal proof for an ordinary branch. It calls the same MSI build
|
||||||
# entry point as release.yml, but creates no tag, release, or public artifact.
|
# entry point as release.yml, but creates no tag, release, or public artifact.
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
|
branches: [task/windows-product-boundary-20260909]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -24,6 +26,14 @@ jobs:
|
||||||
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
|
echo "value=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
echo "Server version: $VERSION"
|
echo "Server version: $VERSION"
|
||||||
|
|
||||||
|
- name: Check native runtime lifecycle and migration payload
|
||||||
|
run: |
|
||||||
|
cd server
|
||||||
|
go test -race -count=1 ./cmd/server ./internal/database ./internal/config
|
||||||
|
|
||||||
|
- name: Check MSI version identity and license rendering
|
||||||
|
run: python3 -m unittest discover -s installer/windows -p 'test_build_inputs.py' -v
|
||||||
|
|
||||||
- name: Build staged Windows Server
|
- name: Build staged Windows Server
|
||||||
env:
|
env:
|
||||||
GOOS: windows
|
GOOS: windows
|
||||||
|
|
@ -42,8 +52,9 @@ jobs:
|
||||||
- name: Build MSI and prove staged-byte custody
|
- name: Build MSI and prove staged-byte custody
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
sudo apt-get update -qq
|
bash installer/windows/provision-msitools.sh "$RUNNER_TEMP/msitools-0.106"
|
||||||
sudo apt-get install -y -qq jq msitools wixl
|
export PATH="$RUNNER_TEMP/msitools-0.106/bin:$PATH"
|
||||||
|
export LD_LIBRARY_PATH="$RUNNER_TEMP/msitools-0.106/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
installer/windows/build-msi.sh \
|
installer/windows/build-msi.sh \
|
||||||
dist/redflag-server-windows-amd64.exe \
|
dist/redflag-server-windows-amd64.exe \
|
||||||
"${{ steps.version.outputs.value }}" \
|
"${{ steps.version.outputs.value }}" \
|
||||||
|
|
|
||||||
|
|
@ -405,14 +405,11 @@ jobs:
|
||||||
# (msiinfo confirms Directory/Component/ServiceInstall/Upgrade
|
# (msiinfo confirms Directory/Component/ServiceInstall/Upgrade
|
||||||
# tables all populated correctly against a real cross-compiled
|
# tables all populated correctly against a real cross-compiled
|
||||||
# server binary).
|
# server binary).
|
||||||
sudo apt-get update -qq
|
# Older distro wixl versions omit UI tables without failing the
|
||||||
# /usr/bin/wixl is shipped by the `wixl` package on Ubuntu noble,
|
# build. Release and custody proof use the same pinned compiler.
|
||||||
# not by `msitools` — msitools carries msiinfo and msibuild only.
|
bash installer/windows/provision-msitools.sh "$RUNNER_TEMP/msitools-0.106"
|
||||||
# Installing msitools alone succeeds and then dies twenty lines
|
export PATH="$RUNNER_TEMP/msitools-0.106/bin:$PATH"
|
||||||
# later on "wixl: command not found", which is how v0.2.9.3 failed
|
export LD_LIBRARY_PATH="$RUNNER_TEMP/msitools-0.106/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}"
|
||||||
# on 2026-09-04. msitools stays for the msiinfo verification the
|
|
||||||
# Product.wxs comments describe.
|
|
||||||
sudo apt-get install -y -qq jq msitools wixl
|
|
||||||
command -v wixl
|
command -v wixl
|
||||||
command -v msiinfo
|
command -v msiinfo
|
||||||
command -v msiextract
|
command -v msiextract
|
||||||
|
|
|
||||||
|
|
@ -332,17 +332,26 @@ installer/linux/build-deb.sh
|
||||||
installer/linux/debian/control.in
|
installer/linux/debian/control.in
|
||||||
installer/linux/debian/postinst
|
installer/linux/debian/postinst
|
||||||
installer/linux/debian/postrm
|
installer/linux/debian/postrm
|
||||||
|
installer/linux/debian/preinst
|
||||||
installer/linux/debian/prerm
|
installer/linux/debian/prerm
|
||||||
installer/linux/desktop/redflag-desktop.desktop
|
installer/linux/desktop/redflag-desktop.desktop
|
||||||
installer/linux/inspect-deb.sh
|
installer/linux/inspect-deb.sh
|
||||||
installer/linux/polkit/50-redflag-agent.rules
|
installer/linux/polkit/50-redflag-agent.rules
|
||||||
installer/linux/sudoers/redflag-agent
|
installer/linux/sudoers/redflag-agent
|
||||||
installer/linux/systemd/redflag-agent.service
|
installer/linux/systemd/redflag-agent.service
|
||||||
|
installer/linux/test_maintainer_scripts.py
|
||||||
installer/windows/Product.wxs
|
installer/windows/Product.wxs
|
||||||
|
installer/windows/ServerUI.wxs
|
||||||
installer/windows/build-desktop.ps1
|
installer/windows/build-desktop.ps1
|
||||||
installer/windows/build-msi.sh
|
installer/windows/build-msi.sh
|
||||||
installer/windows/config/redflag.env.example
|
installer/windows/config/redflag.env.example
|
||||||
|
installer/windows/msi_version.py
|
||||||
|
installer/windows/provision-msitools.sh
|
||||||
|
installer/windows/render-license.py
|
||||||
|
installer/windows/test-agent-verifier.ps1
|
||||||
|
installer/windows/test_build_inputs.py
|
||||||
installer/windows/testdata/run3181-hollow.msi
|
installer/windows/testdata/run3181-hollow.msi
|
||||||
|
installer/windows/verify-msi-boundary.py
|
||||||
installer/windows/verify-msi.sh
|
installer/windows/verify-msi.sh
|
||||||
protocol/README.md
|
protocol/README.md
|
||||||
protocol/testdata/mutation-golden.json
|
protocol/testdata/mutation-golden.json
|
||||||
|
|
@ -364,7 +373,11 @@ scripts/update-action-pins.sh
|
||||||
scripts/vendor/release-contract-core.py
|
scripts/vendor/release-contract-core.py
|
||||||
server/.env.example
|
server/.env.example
|
||||||
server/Dockerfile
|
server/Dockerfile
|
||||||
|
server/cmd/server/http_lifecycle.go
|
||||||
|
server/cmd/server/http_lifecycle_test.go
|
||||||
server/cmd/server/main.go
|
server/cmd/server/main.go
|
||||||
|
server/cmd/server/service_other.go
|
||||||
|
server/cmd/server/service_windows.go
|
||||||
server/cmd/server/webui_test.go
|
server/cmd/server/webui_test.go
|
||||||
server/cmd/server/wire.go
|
server/cmd/server/wire.go
|
||||||
server/docker-entrypoint.sh
|
server/docker-entrypoint.sh
|
||||||
|
|
@ -456,6 +469,7 @@ server/internal/config/config_test.go
|
||||||
server/internal/crypto/aesgcm.go
|
server/internal/crypto/aesgcm.go
|
||||||
server/internal/database/db.go
|
server/internal/database/db.go
|
||||||
server/internal/database/db_test.go
|
server/internal/database/db_test.go
|
||||||
|
server/internal/database/embedded_migrations_test.go
|
||||||
server/internal/database/migration_runner_test.go
|
server/internal/database/migration_runner_test.go
|
||||||
server/internal/database/migrations/001_initial_schema.down.sql
|
server/internal/database/migrations/001_initial_schema.down.sql
|
||||||
server/internal/database/migrations/001_initial_schema.up.sql
|
server/internal/database/migrations/001_initial_schema.up.sql
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,26 @@ LC_ALL=C git ls-tree -r --full-tree HEAD^{tree} | LC_ALL=C sort | sha256sum
|
||||||
```
|
```
|
||||||
|
|
||||||
The result must equal the commit's `Tree-Digest` trailer.
|
The result must equal the commit's `Tree-Digest` trailer.
|
||||||
|
|
||||||
|
## Why public history begins at the epoch
|
||||||
|
|
||||||
|
Public history starts at a single constructed commit because the history
|
||||||
|
before it was not admissible. Twenty-six commits carried an internal author
|
||||||
|
identity and one body quoted an internal registry address, and no force push
|
||||||
|
removes what a mirror has already copied.
|
||||||
|
|
||||||
|
Replaying eleven hundred commit messages through a filter would have produced
|
||||||
|
a different object graph wearing the old words, and would still have required
|
||||||
|
a person to read every one of them. The honest alternative is this one: the
|
||||||
|
development history is preserved in full inside the private forge, where it is
|
||||||
|
useful, and the public repository carries what was deliberately sent out.
|
||||||
|
|
||||||
|
The reviewed history allowlist is empty for the first time. It held forty-four
|
||||||
|
commits, and every one of them was excused for exactly the thing the epoch
|
||||||
|
removes.
|
||||||
|
|
||||||
|
Everything after the epoch commit is an ordinary publication carrying the same
|
||||||
|
three trailers, so a force push stops being routine and starts meaning that
|
||||||
|
something deliberate happened. The trailers bind a projection to its source
|
||||||
|
without pretending the public commit is the private one: same lineage,
|
||||||
|
different tree, and the record says so.
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,12 @@ curl -sfL -H "X-Registration-Token: your-token" "https://your-server.com/api/v1/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:**
|
**Windows:**
|
||||||
|
|
||||||
|
Run elevated Windows PowerShell with an operator-installed OpenSSL 3 executable
|
||||||
|
on `PATH` (`Get-Command openssl`). The installer checks Ed25519 acceptance and
|
||||||
|
rejection before changing the agent. Missing verification support, unsigned or
|
||||||
|
invalid signatures, and manifest hash mismatches stop installation. Installing
|
||||||
|
PowerShell 7 alone does not supply this verifier.
|
||||||
```powershell
|
```powershell
|
||||||
iwr -Headers @{"X-Registration-Token"="your-token"} "https://your-server.com/api/v1/install/windows" | iex
|
iwr -Headers @{"X-Registration-Token"="your-token"} "https://your-server.com/api/v1/install/windows" | iex
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -564,9 +564,33 @@ fn verify_artifacts(token: &CapabilityToken) -> Result<usize, Denial> {
|
||||||
Ok(verified)
|
Ok(verified)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replay guard. token_id is recorded BEFORE execution so a token can never run
|
// The ledger is shared by fleet and standalone callers, including separate
|
||||||
// twice even across a crash. A record-write failure is fail-closed (deny).
|
// helper processes. Lock and append to the existing inode; never truncate old
|
||||||
|
// claims. Sync the claim before permitting mutation.
|
||||||
fn replay_check_and_record(token_id: &str, state_path: &Path) -> Result<(), Denial> {
|
fn replay_check_and_record(token_id: &str, state_path: &Path) -> Result<(), Denial> {
|
||||||
|
#[cfg(unix)]
|
||||||
|
return replay_check_and_record_as(token_id, state_path, 0);
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
Err(Denial::new(
|
||||||
|
EXIT_INTERNAL,
|
||||||
|
"replay_lock_unavailable",
|
||||||
|
"unsupported platform",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
fn replay_check_and_record_as(
|
||||||
|
token_id: &str,
|
||||||
|
state_path: &Path,
|
||||||
|
required_uid: u32,
|
||||||
|
) -> Result<(), Denial> {
|
||||||
|
if token_id.is_empty() || token_id.trim() != token_id || token_id.contains(['\n', '\r']) {
|
||||||
|
return Err(Denial::new(
|
||||||
|
EXIT_BAD_TOKEN,
|
||||||
|
"invalid_token_id",
|
||||||
|
"invalid replay ledger key",
|
||||||
|
));
|
||||||
|
}
|
||||||
if let Some(parent) = state_path.parent() {
|
if let Some(parent) = state_path.parent() {
|
||||||
fs::create_dir_all(parent).map_err(|e| {
|
fs::create_dir_all(parent).map_err(|e| {
|
||||||
Denial::new(
|
Denial::new(
|
||||||
|
|
@ -578,33 +602,164 @@ fn replay_check_and_record(token_id: &str, state_path: &Path) -> Result<(), Deni
|
||||||
// SEC-021: a writable replay-guard dir lets a compromised agent clear
|
// SEC-021: a writable replay-guard dir lets a compromised agent clear
|
||||||
// consumed-token records and replay. Validate after ensure-exists so a
|
// consumed-token records and replay. Validate after ensure-exists so a
|
||||||
// pre-planted attacker-owned dir is refused, not adopted.
|
// pre-planted attacker-owned dir is refused, not adopted.
|
||||||
validate_trusted_path_as(parent, 0)?;
|
validate_trusted_path_as(parent, required_uid)?;
|
||||||
}
|
}
|
||||||
if fs::symlink_metadata(state_path).is_ok() {
|
let failure = |e: std::io::Error| {
|
||||||
validate_trusted_path_as(state_path, 0)?;
|
Denial::new(
|
||||||
|
EXIT_INTERNAL,
|
||||||
|
"replay_ledger_io_failed",
|
||||||
|
format!("{}: {}", state_path.display(), e),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let mut ledger = fs::OpenOptions::new()
|
||||||
|
.read(true)
|
||||||
|
.append(true)
|
||||||
|
.create(true)
|
||||||
|
.mode(0o600)
|
||||||
|
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
|
||||||
|
.open(state_path)
|
||||||
|
.map_err(failure)?;
|
||||||
|
validate_trusted_path_as(state_path, required_uid)?;
|
||||||
|
let metadata = ledger.metadata().map_err(failure)?;
|
||||||
|
if !metadata.is_file()
|
||||||
|
|| metadata.uid() != required_uid
|
||||||
|
|| metadata.mode() & 0o022 != 0
|
||||||
|
|| metadata.nlink() != 1
|
||||||
|
{
|
||||||
|
return Err(Denial::new(
|
||||||
|
EXIT_TRUST_PATH,
|
||||||
|
"replay_ledger_unsafe",
|
||||||
|
"ledger must be a private, singly linked regular file",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
if let Ok(contents) = fs::read_to_string(state_path) {
|
// SAFETY: ledger owns the live descriptor; closing it releases the lock on
|
||||||
if contents.lines().any(|l| l.trim() == token_id) {
|
// every return path. No caller-side mutex is part of this trust boundary.
|
||||||
|
while unsafe { libc::flock(ledger.as_raw_fd(), libc::LOCK_EX) } != 0 {
|
||||||
|
let error = std::io::Error::last_os_error();
|
||||||
|
if error.kind() != std::io::ErrorKind::Interrupted {
|
||||||
|
return Err(failure(error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut existing = String::new();
|
||||||
|
ledger.read_to_string(&mut existing).map_err(failure)?;
|
||||||
|
if !existing.is_empty() && !existing.ends_with('\n') {
|
||||||
|
return Err(Denial::new(
|
||||||
|
EXIT_INTERNAL,
|
||||||
|
"replay_ledger_incomplete",
|
||||||
|
"partial record requires operator recovery",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if existing.lines().any(|line| line.trim() == token_id) {
|
||||||
return Err(Denial::new(
|
return Err(Denial::new(
|
||||||
EXIT_REPLAY,
|
EXIT_REPLAY,
|
||||||
"token_already_consumed",
|
"token_already_consumed",
|
||||||
format!("token_id={}", token_id),
|
format!("token_id={}", token_id),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
ledger
|
||||||
|
.write_all(format!("{}\n", token_id).as_bytes())
|
||||||
|
.map_err(failure)?;
|
||||||
|
ledger.sync_all().map_err(failure)?;
|
||||||
|
if let Some(parent) = state_path.parent() {
|
||||||
|
fs::File::open(parent)
|
||||||
|
.and_then(|directory| directory.sync_all())
|
||||||
|
.map_err(failure)?;
|
||||||
}
|
}
|
||||||
let mut existing = fs::read_to_string(state_path).unwrap_or_default();
|
|
||||||
existing.push_str(token_id);
|
|
||||||
existing.push('\n');
|
|
||||||
fs::write(state_path, existing).map_err(|e| {
|
|
||||||
Denial::new(
|
|
||||||
EXIT_INTERNAL,
|
|
||||||
"state_write_failed",
|
|
||||||
format!("{}: {}", state_path.display(), e),
|
|
||||||
)
|
|
||||||
})?;
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(test, unix))]
|
||||||
|
mod replay_ledger_tests {
|
||||||
|
use super::*;
|
||||||
|
use std::sync::{Arc, Barrier};
|
||||||
|
|
||||||
|
fn fixture(name: &str) -> PathBuf {
|
||||||
|
let path = std::env::temp_dir().join(format!(
|
||||||
|
"redflag-replay-{}-{}-{}",
|
||||||
|
name,
|
||||||
|
std::process::id(),
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_nanos()
|
||||||
|
));
|
||||||
|
fs::create_dir(&path).unwrap();
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o700)).unwrap();
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_duplicate_has_one_winner() {
|
||||||
|
let root = fixture("duplicate");
|
||||||
|
let barrier = Arc::new(Barrier::new(16));
|
||||||
|
let handles: Vec<_> = (0..16)
|
||||||
|
.map(|_| {
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
let ledger = root.join("consumed-tokens");
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
replay_check_and_record_as("one-token", &ledger, unsafe { libc::geteuid() })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let results: Vec<_> = handles.into_iter().map(|h| h.join().unwrap()).collect();
|
||||||
|
assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1);
|
||||||
|
assert!(results
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| r.as_ref().err())
|
||||||
|
.all(|d| d.code == EXIT_REPLAY));
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn concurrent_distinct_claims_preserve_legacy_records() {
|
||||||
|
let root = fixture("distinct");
|
||||||
|
let path = root.join("consumed-tokens");
|
||||||
|
fs::write(&path, "legacy-token\n").unwrap();
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
let barrier = Arc::new(Barrier::new(16));
|
||||||
|
let handles: Vec<_> = (0..16)
|
||||||
|
.map(|i| {
|
||||||
|
let barrier = barrier.clone();
|
||||||
|
let path = path.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
barrier.wait();
|
||||||
|
replay_check_and_record_as(&format!("token-{i}"), &path, unsafe {
|
||||||
|
libc::geteuid()
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
for handle in handles {
|
||||||
|
handle.join().unwrap();
|
||||||
|
}
|
||||||
|
let contents = fs::read_to_string(&path).unwrap();
|
||||||
|
assert_eq!(contents.lines().collect::<BTreeSet<_>>().len(), 17);
|
||||||
|
assert_eq!(
|
||||||
|
replay_check_and_record_as("legacy-token", &path, unsafe { libc::geteuid() })
|
||||||
|
.unwrap_err()
|
||||||
|
.code,
|
||||||
|
EXIT_REPLAY
|
||||||
|
);
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn corrupt_or_symlinked_ledger_never_becomes_empty_state() {
|
||||||
|
let root = fixture("corrupt");
|
||||||
|
let path = root.join("consumed-tokens");
|
||||||
|
fs::write(&path, "partial-record").unwrap();
|
||||||
|
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
assert!(replay_check_and_record_as("next", &path, unsafe { libc::geteuid() }).is_err());
|
||||||
|
assert_eq!(fs::read_to_string(&path).unwrap(), "partial-record");
|
||||||
|
let link = root.join("link");
|
||||||
|
std::os::unix::fs::symlink(&path, &link).unwrap();
|
||||||
|
assert!(replay_check_and_record_as("next", &link, unsafe { libc::geteuid() }).is_err());
|
||||||
|
fs::remove_dir_all(root).unwrap();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Build the argv plan for the one authorized operation. Returns a list of
|
// Build the argv plan for the one authorized operation. Returns a list of
|
||||||
// (program, args) invocations — single-element for line managers, per-artifact
|
// (program, args) invocations — single-element for line managers, per-artifact
|
||||||
// for image/winget managers. Unsupported (type, operation) pairs are denied
|
// for image/winget managers. Unsupported (type, operation) pairs are denied
|
||||||
|
|
@ -1025,11 +1180,7 @@ fn create_staging_file_without_symlinks(staging: &Path) -> Result<fs::File, Deni
|
||||||
return Err(Denial::new(
|
return Err(Denial::new(
|
||||||
EXIT_TRUST_PATH,
|
EXIT_TRUST_PATH,
|
||||||
"stage_open_failed",
|
"stage_open_failed",
|
||||||
format!(
|
format!("{}: {}", staging.display(), std::io::Error::last_os_error()),
|
||||||
"{}: {}",
|
|
||||||
staging.display(),
|
|
||||||
std::io::Error::last_os_error()
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
Ok(unsafe { fs::File::from_raw_fd(descriptor) })
|
Ok(unsafe { fs::File::from_raw_fd(descriptor) })
|
||||||
|
|
@ -5022,7 +5173,10 @@ fn main() {
|
||||||
// The signed release manifest lists version_cmd "--version" for this
|
// The signed release manifest lists version_cmd "--version" for this
|
||||||
// component, so the binary has to answer it. Positional, like every other
|
// component, so the binary has to answer it. Positional, like every other
|
||||||
// arm here, and ahead of any path that touches privilege or state.
|
// arm here, and ahead of any path that touches privilege or state.
|
||||||
if matches!(args.get(1).map(|s| s.as_str()), Some("--version") | Some("-V")) {
|
if matches!(
|
||||||
|
args.get(1).map(|s| s.as_str()),
|
||||||
|
Some("--version") | Some("-V")
|
||||||
|
) {
|
||||||
println!("RedFlag helper v{}", env!("REDFLAG_VERSION"));
|
println!("RedFlag helper v{}", env!("REDFLAG_VERSION"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -107,13 +107,15 @@ install -D -m 0644 "$HERE/polkit/50-redflag-agent.rules" \
|
||||||
# overrides rather than reimplementing any of its logic.
|
# overrides rather than reimplementing any of its logic.
|
||||||
install -D -m 0755 "$PROVISION_SRC" \
|
install -D -m 0755 "$PROVISION_SRC" \
|
||||||
"$STAGE/usr/lib/redflag/provision-standalone-authority.sh"
|
"$STAGE/usr/lib/redflag/provision-standalone-authority.sh"
|
||||||
|
install -D -m 0644 "$REPO_ROOT/LICENSE" "$STAGE/usr/share/doc/redflag/copyright"
|
||||||
|
install -D -m 0644 "$REPO_ROOT/THIRD_PARTY_LICENSES.md" "$STAGE/usr/share/doc/redflag/THIRD_PARTY_LICENSES.md"
|
||||||
|
|
||||||
# ---- control ----------------------------------------------------------------
|
# ---- control ----------------------------------------------------------------
|
||||||
install -d -m 0755 "$STAGE/DEBIAN"
|
install -d -m 0755 "$STAGE/DEBIAN"
|
||||||
sed -e "s|@VERSION@|$VERSION|g" -e "s|@MAINTAINER@|$MAINTAINER|g" \
|
sed -e "s|@VERSION@|$VERSION|g" -e "s|@MAINTAINER@|$MAINTAINER|g" \
|
||||||
"$HERE/debian/control.in" > "$STAGE/DEBIAN/control"
|
"$HERE/debian/control.in" > "$STAGE/DEBIAN/control"
|
||||||
|
|
||||||
for script in postinst prerm postrm; do
|
for script in preinst postinst prerm postrm; do
|
||||||
sed -e "s|@VERSION@|$VERSION|g" "$HERE/debian/$script" > "$STAGE/DEBIAN/$script"
|
sed -e "s|@VERSION@|$VERSION|g" "$HERE/debian/$script" > "$STAGE/DEBIAN/$script"
|
||||||
chmod 0755 "$STAGE/DEBIAN/$script"
|
chmod 0755 "$STAGE/DEBIAN/$script"
|
||||||
done
|
done
|
||||||
|
|
@ -131,7 +133,7 @@ EOF
|
||||||
|
|
||||||
# Syntax-check the maintainer scripts before shipping them. A broken postinst is
|
# Syntax-check the maintainer scripts before shipping them. A broken postinst is
|
||||||
# discovered at install time on the operator's machine otherwise.
|
# discovered at install time on the operator's machine otherwise.
|
||||||
for script in postinst prerm postrm; do
|
for script in preinst postinst prerm postrm; do
|
||||||
sh -n "$STAGE/DEBIAN/$script" || fail "$script failed shell syntax check"
|
sh -n "$STAGE/DEBIAN/$script" || fail "$script failed shell syntax check"
|
||||||
done
|
done
|
||||||
if command -v visudo >/dev/null 2>&1; then
|
if command -v visudo >/dev/null 2>&1; then
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,18 @@ case "$1" in
|
||||||
*) exit 0 ;;
|
*) exit 0 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
|
# Also guard reconfiguration and recovery after an interrupted installation.
|
||||||
|
for b in redflag-agent redflag-helper redflag-desktop; do
|
||||||
|
path="$RUN_BIN_DIR/$b"
|
||||||
|
if [ -L "$path" ]; then
|
||||||
|
[ "$(readlink "$path")" = "$PKG_BIN_DIR/$b" ] && continue
|
||||||
|
elif [ ! -e "$path" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
warn "$path is not the package runtime link; explicit migration is required"
|
||||||
|
exit 1
|
||||||
|
done
|
||||||
|
|
||||||
# ---- user, groups -----------------------------------------------------------
|
# ---- user, groups -----------------------------------------------------------
|
||||||
if ! getent group "$LOCAL_GROUP" >/dev/null 2>&1; then
|
if ! getent group "$LOCAL_GROUP" >/dev/null 2>&1; then
|
||||||
addgroup --system "$LOCAL_GROUP"
|
addgroup --system "$LOCAL_GROUP"
|
||||||
|
|
@ -69,15 +81,15 @@ chown "$AGENT_USER":"$AGENT_USER" "$AGENT_CONFIG_DIR" "$SERVER_KEY_DIR"
|
||||||
# ---- runtime symlinks -------------------------------------------------------
|
# ---- runtime symlinks -------------------------------------------------------
|
||||||
# The agent, helper and their sudoers/service contracts all reference
|
# The agent, helper and their sudoers/service contracts all reference
|
||||||
# /usr/local/bin (agent/internal/constants/paths.go). Debian policy keeps
|
# /usr/local/bin (agent/internal/constants/paths.go). Debian policy keeps
|
||||||
# package files out of /usr/local, so ship in /usr/bin and link. A real file at
|
# package files out of /usr/local, so ship in /usr/bin and link. The preflight
|
||||||
# the link path is left alone: that is a helper-performed self-update binary,
|
# above refuses foreign paths; the package-managed helper refuses self-update.
|
||||||
# and clobbering it would silently downgrade the running install.
|
|
||||||
for b in redflag-agent redflag-helper redflag-desktop; do
|
for b in redflag-agent redflag-helper redflag-desktop; do
|
||||||
if [ -L "$RUN_BIN_DIR/$b" ] || [ ! -e "$RUN_BIN_DIR/$b" ]; then
|
if [ -L "$RUN_BIN_DIR/$b" ] || [ ! -e "$RUN_BIN_DIR/$b" ]; then
|
||||||
install -d -m 0755 "$RUN_BIN_DIR"
|
install -d -m 0755 "$RUN_BIN_DIR"
|
||||||
ln -sfn "$PKG_BIN_DIR/$b" "$RUN_BIN_DIR/$b"
|
ln -sfn "$PKG_BIN_DIR/$b" "$RUN_BIN_DIR/$b"
|
||||||
else
|
else
|
||||||
warn "$RUN_BIN_DIR/$b exists and is not a symlink — leaving it (self-updated binary?); packaged build is at $PKG_BIN_DIR/$b"
|
warn "$RUN_BIN_DIR/$b changed during configuration — refusing runtime takeover"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if command -v setcap >/dev/null 2>&1; then
|
if command -v setcap >/dev/null 2>&1; then
|
||||||
|
|
@ -145,9 +157,11 @@ elif [ -x "$PROVISION" ]; then
|
||||||
else
|
else
|
||||||
warn "standalone authority provisioning failed — agent installed, local approval unavailable"
|
warn "standalone authority provisioning failed — agent installed, local approval unavailable"
|
||||||
warn "re-run once resolved: sudo REDFLAG_BIN_DIR=$RUN_BIN_DIR $PROVISION"
|
warn "re-run once resolved: sudo REDFLAG_BIN_DIR=$RUN_BIN_DIR $PROVISION"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
warn "$PROVISION missing or not executable — standalone authority not provisioned"
|
warn "$PROVISION missing or not executable — standalone authority not provisioned"
|
||||||
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ---- desktop access ---------------------------------------------------------
|
# ---- desktop access ---------------------------------------------------------
|
||||||
|
|
@ -162,10 +176,12 @@ fi
|
||||||
|
|
||||||
# ---- service ----------------------------------------------------------------
|
# ---- service ----------------------------------------------------------------
|
||||||
if [ -d /run/systemd/system ]; then
|
if [ -d /run/systemd/system ]; then
|
||||||
systemctl daemon-reload || true
|
systemctl daemon-reload
|
||||||
systemctl enable redflag-agent.service || true
|
systemctl enable redflag-agent.service
|
||||||
systemctl restart redflag-agent.service || \
|
if ! systemctl restart redflag-agent.service; then
|
||||||
warn "redflag-agent did not start — check: journalctl -u redflag-agent"
|
warn "redflag-agent did not start — check: journalctl -u redflag-agent"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ case "$1" in
|
||||||
remove|purge)
|
remove|purge)
|
||||||
for b in redflag-agent redflag-helper redflag-desktop; do
|
for b in redflag-agent redflag-helper redflag-desktop; do
|
||||||
# Only our own symlinks; a self-updated real binary is left for the operator.
|
# Only our own symlinks; a self-updated real binary is left for the operator.
|
||||||
if [ -L "$RUN_BIN_DIR/$b" ]; then
|
if [ -L "$RUN_BIN_DIR/$b" ] && [ "$(readlink "$RUN_BIN_DIR/$b")" = "/usr/bin/$b" ]; then
|
||||||
rm -f "$RUN_BIN_DIR/$b"
|
rm -f "$RUN_BIN_DIR/$b"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
14
installer/linux/debian/preinst
Normal file
14
installer/linux/debian/preinst
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/sh
|
||||||
|
# Refuse an implicit takeover of script-installed or operator-owned binaries.
|
||||||
|
set -e
|
||||||
|
case "$1" in install|upgrade) ;; *) exit 0 ;; esac
|
||||||
|
for b in redflag-agent redflag-helper redflag-desktop; do
|
||||||
|
path="/usr/local/bin/$b"
|
||||||
|
if [ -L "$path" ]; then
|
||||||
|
[ "$(readlink "$path")" = "/usr/bin/$b" ] && continue
|
||||||
|
elif [ ! -e "$path" ]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
echo "[ERROR] [deb] [preinst] $path is not this package's runtime link; preserve the existing install and explicitly migrate it before installing redflag" >&2
|
||||||
|
exit 1
|
||||||
|
done
|
||||||
|
|
@ -92,6 +92,8 @@ check_file /etc/polkit-1/rules.d/50-redflag-agent.rules 644
|
||||||
check_file /etc/xdg/autostart/redflag-desktop.desktop 644
|
check_file /etc/xdg/autostart/redflag-desktop.desktop 644
|
||||||
check_file /usr/share/applications/redflag-desktop.desktop 644
|
check_file /usr/share/applications/redflag-desktop.desktop 644
|
||||||
check_file /usr/lib/redflag/provision-standalone-authority.sh 755
|
check_file /usr/lib/redflag/provision-standalone-authority.sh 755
|
||||||
|
check_file /usr/share/doc/redflag/copyright 644
|
||||||
|
check_file /usr/share/doc/redflag/THIRD_PARTY_LICENSES.md 644
|
||||||
|
|
||||||
# Debian policy: nothing under /usr/local may be shipped in the archive.
|
# Debian policy: nothing under /usr/local may be shipped in the archive.
|
||||||
if grep -qE ' \./usr/local/' "$TMP/contents"; then
|
if grep -qE ' \./usr/local/' "$TMP/contents"; then
|
||||||
|
|
@ -112,7 +114,7 @@ for b in redflag-agent redflag-helper redflag-desktop; do
|
||||||
done
|
done
|
||||||
|
|
||||||
# ---- maintainer scripts -----------------------------------------------------
|
# ---- maintainer scripts -----------------------------------------------------
|
||||||
for s in postinst prerm postrm; do
|
for s in preinst postinst prerm postrm; do
|
||||||
f="$TMP/DEBIAN/$s"
|
f="$TMP/DEBIAN/$s"
|
||||||
if [ ! -f "$f" ]; then bad maintscript "$s missing"; continue; fi
|
if [ ! -f "$f" ]; then bad maintscript "$s missing"; continue; fi
|
||||||
[ -x "$f" ] || bad maintscript "$s not executable"
|
[ -x "$f" ] || bad maintscript "$s not executable"
|
||||||
|
|
|
||||||
52
installer/linux/test_maintainer_scripts.py
Normal file
52
installer/linux/test_maintainer_scripts.py
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
"""Exercise package ownership guards without modifying the host installation."""
|
||||||
|
import pathlib
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
HERE = pathlib.Path(__file__).parent
|
||||||
|
|
||||||
|
|
||||||
|
class PackageOwnership(unittest.TestCase):
|
||||||
|
def run_script(self, name, root, action):
|
||||||
|
# Relocate only the fixed installation root. Execute the actual shell
|
||||||
|
# script; every command and branch in its ownership guard remains real.
|
||||||
|
script = (HERE / "debian" / name).read_text().replace("/usr/local/bin", str(root))
|
||||||
|
return subprocess.run(["sh", "-s", "--", action], input=script, text=True, capture_output=True)
|
||||||
|
|
||||||
|
def test_fresh_install_and_own_links_are_accepted(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
self.assertEqual(self.run_script("preinst", root, "install").returncode, 0)
|
||||||
|
for binary in ["redflag-agent", "redflag-helper", "redflag-desktop"]:
|
||||||
|
(root / binary).symlink_to("/usr/bin/" + binary)
|
||||||
|
self.assertEqual(self.run_script("preinst", root, "upgrade").returncode, 0)
|
||||||
|
|
||||||
|
def test_unmanaged_binary_and_foreign_link_survive_refusal(self):
|
||||||
|
for kind in ["binary", "symlink"]:
|
||||||
|
with self.subTest(kind=kind), tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
path = root / "redflag-agent"
|
||||||
|
if kind == "binary":
|
||||||
|
path.write_bytes(b"existing runtime")
|
||||||
|
else:
|
||||||
|
path.symlink_to("/opt/existing/redflag-agent")
|
||||||
|
result = self.run_script("preinst", root, "upgrade")
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("explicitly migrate", result.stderr)
|
||||||
|
if kind == "binary":
|
||||||
|
self.assertEqual(path.read_bytes(), b"existing runtime")
|
||||||
|
else:
|
||||||
|
self.assertEqual(str(path.readlink()), "/opt/existing/redflag-agent")
|
||||||
|
|
||||||
|
def test_reconfigure_refuses_before_provisioning(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = pathlib.Path(directory)
|
||||||
|
(root / "redflag-helper").write_bytes(b"existing helper")
|
||||||
|
result = self.run_script("postinst", root, "configure")
|
||||||
|
self.assertNotEqual(result.returncode, 0)
|
||||||
|
self.assertIn("explicit migration", result.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -18,14 +18,14 @@
|
||||||
elements, explicit Directory nesting, no StandardDirectory shorthand) —
|
elements, explicit Directory nesting, no StandardDirectory shorthand) —
|
||||||
wixl's grammar, not v4's.
|
wixl's grammar, not v4's.
|
||||||
|
|
||||||
Scope (v1): install the already-built, already-signed server binary as a
|
Scope (v1): install the staged server binary as a Windows service. Payload
|
||||||
Windows service, with a forward-only upgrade guard. It does NOT bundle
|
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
|
PostgreSQL — docker-compose doesn't either (postgres:16-alpine is a
|
||||||
separate container); a native install is expected to point at a reachable
|
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
|
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,
|
service is left stopped until the operator supplies database credentials,
|
||||||
go to http://localhost:<port>/setup and finish configuration in the
|
admin credentials and signing configuration in redflag.env, then starts it.
|
||||||
browser" (README.md's existing docker flow, same UX, different transport).
|
|
||||||
|
|
||||||
Config: the Go binary only ever reads OS environment variables
|
Config: the Go binary only ever reads OS environment variables
|
||||||
(server/internal/config/config.go). Docker gets those from compose's
|
(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
|
Forward-only, no downgrade — ETHOS doctrine elsewhere in this codebase
|
||||||
(refresh-token rotation, capability tokens) applied to the installer
|
(refresh-token rotation, capability tokens) applied to the installer
|
||||||
itself. Windows Installer only compares the first three version
|
itself. Windows Installer only compares the first three version
|
||||||
fields for upgrade detection — pass $(var.RedFlagVersion) as the
|
fields for upgrade detection. msi_version.py encodes patch*100+revision
|
||||||
3-part form from CI, same 3-vs-4-part reconciliation bump-version.sh
|
in the third field, so fourth-field RedFlag releases remain ordered.
|
||||||
already does for desktop/Cargo.toml vs tauri.conf.json.
|
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" />
|
<MediaTemplate EmbedCab="yes" />
|
||||||
|
|
||||||
|
<Property Id="ARPNOMODIFY" Value="1" />
|
||||||
|
<UIRef Id="RedFlagServerUI" />
|
||||||
|
|
||||||
<Directory Id="TARGETDIR" Name="SourceDir">
|
<Directory Id="TARGETDIR" Name="SourceDir">
|
||||||
<Directory Id="ProgramFiles64Folder">
|
<Directory Id="ProgramFiles64Folder">
|
||||||
<Directory Id="INSTALLFOLDER" Name="RedFlag">
|
<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"
|
<File Id="RedFlagServerExe"
|
||||||
Source="dist/redflag-server-windows-amd64.exe"
|
Source="dist/redflag-server-windows-amd64.exe"
|
||||||
Name="redflag-server.exe"
|
Name="redflag-server.exe"
|
||||||
|
|
@ -105,7 +111,6 @@
|
||||||
|
|
||||||
<ServiceControl Id="RedFlagServerServiceControl"
|
<ServiceControl Id="RedFlagServerServiceControl"
|
||||||
Name="RedFlagServer"
|
Name="RedFlagServer"
|
||||||
Start="install"
|
|
||||||
Stop="both"
|
Stop="both"
|
||||||
Remove="uninstall"
|
Remove="uninstall"
|
||||||
Wait="yes" />
|
Wait="yes" />
|
||||||
|
|
@ -124,7 +129,7 @@
|
||||||
-->
|
-->
|
||||||
<Directory Id="CommonAppDataFolder">
|
<Directory Id="CommonAppDataFolder">
|
||||||
<Directory Id="REDFLAGDATA" Name="RedFlag">
|
<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"
|
<File Id="RedFlagEnvExample"
|
||||||
Source="config/redflag.env.example"
|
Source="config/redflag.env.example"
|
||||||
Name="redflag.env.example"
|
Name="redflag.env.example"
|
||||||
|
|
@ -140,11 +145,11 @@
|
||||||
</Feature>
|
</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
|
for adding the desktop alongside it" plus future screen-capture work
|
||||||
from the session-broker RAF). Deliberately not stubbed as a disabled
|
from the session-broker RAF). Deliberately not stubbed as a disabled
|
||||||
checkbox — a checkbox that does nothing when checked is worse than no
|
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."
|
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_COMMIT="$5"
|
||||||
SOURCE_REF="$6"
|
SOURCE_REF="$6"
|
||||||
ROOT=$(cd "$(dirname "$0")/../.." && pwd)
|
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; }
|
command -v "$tool" >/dev/null || { echo "$tool not found" >&2; exit 2; }
|
||||||
done
|
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; }
|
[ -f "$SERVER_EXE" ] || { echo "no such Server executable: $SERVER_EXE" >&2; exit 2; }
|
||||||
|
|
||||||
SERVER_EXE=$(realpath "$SERVER_EXE")
|
SERVER_EXE=$(realpath "$SERVER_EXE")
|
||||||
|
|
@ -27,10 +31,12 @@ OUTPUT_MSI=$(realpath -m "$OUTPUT_MSI")
|
||||||
PROOF_JSON=$(realpath -m "$PROOF_JSON")
|
PROOF_JSON=$(realpath -m "$PROOF_JSON")
|
||||||
mkdir -p "$ROOT/installer/windows/dist" "$(dirname "$OUTPUT_MSI")" "$(dirname "$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"
|
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"
|
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" \
|
CAB_FILE=$(find "$(dirname "$OUTPUT_MSI")" "$ROOT/installer/windows" \
|
||||||
|
|
@ -52,6 +58,8 @@ fi
|
||||||
--source-ref "$SOURCE_REF" \
|
--source-ref "$SOURCE_REF" \
|
||||||
--proof-record "$PROOF_JSON"
|
--proof-record "$PROOF_JSON"
|
||||||
|
|
||||||
|
python3 "$ROOT/installer/windows/verify-msi-boundary.py" "$OUTPUT_MSI"
|
||||||
|
|
||||||
FIXTURE="$ROOT/installer/windows/testdata/run3181-hollow.msi"
|
FIXTURE="$ROOT/installer/windows/testdata/run3181-hollow.msi"
|
||||||
if "$ROOT/installer/windows/verify-msi.sh" "$FIXTURE" \
|
if "$ROOT/installer/windows/verify-msi.sh" "$FIXTURE" \
|
||||||
"$ROOT/installer/windows/dist/redflag-server-windows-amd64.exe" \
|
"$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}")
|
||||||
44
server/cmd/server/http_lifecycle.go
Normal file
44
server/cmd/server/http_lifecycle.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Report service readiness only after initialization and a successful bind.
|
||||||
|
// SCM stop and console signals use the same HTTP drain and deferred cleanup.
|
||||||
|
func serveHTTP(ctx context.Context, ready func(), addr string, handler http.Handler) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
listener, err := net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
server := &http.Server{Handler: handler}
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
finished := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(stopped)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||||
|
_ = server.Close()
|
||||||
|
}
|
||||||
|
case <-finished:
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
ready()
|
||||||
|
err = server.Serve(listener)
|
||||||
|
close(finished)
|
||||||
|
<-stopped
|
||||||
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
57
server/cmd/server/http_lifecycle_test.go
Normal file
57
server/cmd/server/http_lifecycle_test.go
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeHTTPStopsOnServiceCancellation(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
ready := make(chan struct{})
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- serveHTTP(ctx, func() { close(ready) }, "127.0.0.1:0", http.NewServeMux())
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-ready:
|
||||||
|
case err := <-done:
|
||||||
|
t.Fatalf("server exited before readiness: %v", err)
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("server never reported readiness")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("service stop: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("HTTP server did not stop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeHTTPBindFailureDoesNotReportReady(t *testing.T) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer listener.Close()
|
||||||
|
ready := false
|
||||||
|
err = serveHTTP(context.Background(), func() { ready = true }, listener.Addr().String(), http.NewServeMux())
|
||||||
|
if err == nil || ready {
|
||||||
|
t.Fatalf("occupied address: err=%v ready=%v", err, ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNativeHTTPCancelledStartupDoesNotReportReady(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
ready := false
|
||||||
|
if err := serveHTTP(ctx, func() { ready = true }, "127.0.0.1:0", http.NewServeMux()); err != nil || ready {
|
||||||
|
t.Fatalf("cancelled startup: err=%v ready=%v", err, ready)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -11,11 +11,13 @@ import (
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
"os/signal"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Fimeg/RedFlag/server/internal/api/handlers"
|
"github.com/Fimeg/RedFlag/server/internal/api/handlers"
|
||||||
|
|
@ -107,7 +109,7 @@ func isSetupComplete(cfg *config.Config, signingService *services.SigningService
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func startWelcomeModeServer() {
|
func startWelcomeModeServer(ctx context.Context, ready func()) {
|
||||||
setupHandler := handlers.NewSetupHandler("/app/config")
|
setupHandler := handlers.NewSetupHandler("/app/config")
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
|
||||||
|
|
@ -140,7 +142,7 @@ func startWelcomeModeServer() {
|
||||||
log.Printf("Welcome mode server started on :8080")
|
log.Printf("Welcome mode server started on :8080")
|
||||||
log.Printf("Waiting for configuration...")
|
log.Printf("Waiting for configuration...")
|
||||||
|
|
||||||
if err := router.Run(":8080"); err != nil {
|
if err := serveHTTP(ctx, ready, ":8080", router); err != nil {
|
||||||
log.Fatal("Failed to start welcome mode server:", err)
|
log.Fatal("Failed to start welcome mode server:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -169,6 +171,24 @@ func main() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !migrate {
|
||||||
|
handled, err := runWindowsService(func(ctx context.Context, ready func()) {
|
||||||
|
runServer(ctx, ready, false)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Windows service failed: ", err)
|
||||||
|
}
|
||||||
|
if handled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
runServer(ctx, func() {}, migrate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runServer(ctx context.Context, ready func(), migrate bool) {
|
||||||
|
|
||||||
// Load configuration
|
// Load configuration
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -177,7 +197,7 @@ func main() {
|
||||||
log.Printf("Or configure via web interface at: http://localhost:8080/setup")
|
log.Printf("Or configure via web interface at: http://localhost:8080/setup")
|
||||||
|
|
||||||
// Start welcome mode server
|
// Start welcome mode server
|
||||||
startWelcomeModeServer()
|
startWelcomeModeServer(ctx, ready)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -197,8 +217,7 @@ func main() {
|
||||||
|
|
||||||
// Handle migrate-only flag
|
// Handle migrate-only flag
|
||||||
if migrate {
|
if migrate {
|
||||||
migrationsPath := filepath.Join("internal", "database", "migrations")
|
if err := db.MigrateEmbedded(); err != nil {
|
||||||
if err := db.Migrate(migrationsPath); err != nil {
|
|
||||||
log.Fatal("Migration failed:", err)
|
log.Fatal("Migration failed:", err)
|
||||||
}
|
}
|
||||||
fmt.Printf("[OK] Database migrations completed\n")
|
fmt.Printf("[OK] Database migrations completed\n")
|
||||||
|
|
@ -206,8 +225,7 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run migrations — abort on failure (F-B1-11 fix)
|
// Run migrations — abort on failure (F-B1-11 fix)
|
||||||
migrationsPath := filepath.Join("internal", "database", "migrations")
|
if err := db.MigrateEmbedded(); err != nil {
|
||||||
if err := db.Migrate(migrationsPath); err != nil {
|
|
||||||
log.Fatalf("[ERROR] [server] [database] migration_failed error=%q — server cannot start with incomplete schema", err)
|
log.Fatalf("[ERROR] [server] [database] migration_failed error=%q — server cannot start with incomplete schema", err)
|
||||||
}
|
}
|
||||||
log.Printf("[INFO] [server] [database] migrations_complete")
|
log.Printf("[INFO] [server] [database] migrations_complete")
|
||||||
|
|
@ -376,7 +394,7 @@ func main() {
|
||||||
log.Printf("Server setup incomplete - starting welcome mode")
|
log.Printf("Server setup incomplete - starting welcome mode")
|
||||||
log.Printf("Setup required: Admin credentials, signing keys, and database configuration")
|
log.Printf("Setup required: Admin credentials, signing keys, and database configuration")
|
||||||
log.Printf("Access setup at: http://%s:%d/setup", serverAddr, cfg.Server.Port)
|
log.Printf("Access setup at: http://%s:%d/setup", serverAddr, cfg.Server.Port)
|
||||||
startWelcomeModeServer()
|
startWelcomeModeServer(ctx, ready)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1015,7 +1033,6 @@ func main() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load subsystems into queue
|
// Load subsystems into queue
|
||||||
ctx := context.Background()
|
|
||||||
if err := subsystemScheduler.LoadSubsystems(ctx); err != nil {
|
if err := subsystemScheduler.LoadSubsystems(ctx); err != nil {
|
||||||
log.Printf("Warning: Failed to load subsystems: %v", err)
|
log.Printf("Warning: Failed to load subsystems: %v", err)
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1135,7 +1152,7 @@ func main() {
|
||||||
fmt.Printf("Admin interface: http://%s:%d/admin\n", cfg.Server.Host, cfg.Server.Port)
|
fmt.Printf("Admin interface: http://%s:%d/admin\n", cfg.Server.Host, cfg.Server.Port)
|
||||||
fmt.Printf("Dashboard: http://%s:%d\n\n", cfg.Server.Host, cfg.Server.Port)
|
fmt.Printf("Dashboard: http://%s:%d\n\n", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
|
||||||
if err := router.Run(addr); err != nil {
|
if err := serveHTTP(ctx, ready, addr, router); err != nil {
|
||||||
log.Fatal("Failed to start server:", err)
|
log.Fatal("Failed to start server:", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
9
server/cmd/server/service_other.go
Normal file
9
server/cmd/server/service_other.go
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
func runWindowsService(run func(context.Context, func())) (bool, error) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
69
server/cmd/server/service_windows.go
Normal file
69
server/cmd/server/service_windows.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/sys/windows/svc"
|
||||||
|
)
|
||||||
|
|
||||||
|
const serverServiceName = "RedFlagServer"
|
||||||
|
|
||||||
|
type windowsServerService struct {
|
||||||
|
run func(context.Context, func())
|
||||||
|
}
|
||||||
|
|
||||||
|
func runWindowsService(run func(context.Context, func())) (bool, error) {
|
||||||
|
service, err := svc.IsWindowsService()
|
||||||
|
if err != nil || !service {
|
||||||
|
return service, err
|
||||||
|
}
|
||||||
|
return true, svc.Run(serverServiceName, &windowsServerService{run: run})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *windowsServerService) Execute(_ []string, requests <-chan svc.ChangeRequest, changes chan<- svc.Status) (bool, uint32) {
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
ready := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
status := svc.Status{State: svc.StartPending, CheckPoint: 1, WaitHint: 10000}
|
||||||
|
changes <- status
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
s.run(ctx, func() { close(ready) })
|
||||||
|
}()
|
||||||
|
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ready:
|
||||||
|
ready = nil
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
status = svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown}
|
||||||
|
changes <- status
|
||||||
|
}
|
||||||
|
case request := <-requests:
|
||||||
|
switch request.Cmd {
|
||||||
|
case svc.Interrogate:
|
||||||
|
changes <- status
|
||||||
|
case svc.Stop, svc.Shutdown:
|
||||||
|
status = svc.Status{State: svc.StopPending, CheckPoint: 1, WaitHint: 10000}
|
||||||
|
changes <- status
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
case <-ticker.C:
|
||||||
|
if status.State == svc.StartPending || status.State == svc.StopPending {
|
||||||
|
status.CheckPoint++
|
||||||
|
changes <- status
|
||||||
|
}
|
||||||
|
case <-done:
|
||||||
|
if ctx.Err() == nil {
|
||||||
|
return true, 1 // An unsolicited exit is not a successful service stop.
|
||||||
|
}
|
||||||
|
return false, 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -104,8 +104,17 @@ func TestWindowsInstallScriptUsesResolvedVersionAndRuntimeServerURL(t *testing.T
|
||||||
if !strings.Contains(body, "function Test-Ed25519Signature") {
|
if !strings.Contains(body, "function Test-Ed25519Signature") {
|
||||||
t.Fatalf("installer does not include Ed25519 verifier helper")
|
t.Fatalf("installer does not include Ed25519 verifier helper")
|
||||||
}
|
}
|
||||||
if !strings.Contains(body, "binary signature not checked (manifest hash pin already enforced)") {
|
// The installer no longer continues without a verifier, so the warning this
|
||||||
t.Fatalf("installer does not distinguish missing Ed25519 verifier from tampering")
|
// once asserted is gone. Its point survives the change: an unavailable
|
||||||
|
// verifier must be reported as a missing prerequisite, not as tampering.
|
||||||
|
if strings.Contains(body, "signature not checked") {
|
||||||
|
t.Fatalf("installer still continues when no Ed25519 verifier is available")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "function Assert-Ed25519Verifier") {
|
||||||
|
t.Fatalf("installer does not prove the Ed25519 verifier before installing")
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "A working operator-installed OpenSSL 3 Ed25519 verifier is required. No agent files or services were changed.") {
|
||||||
|
t.Fatalf("installer does not distinguish an unavailable Ed25519 verifier from tampering")
|
||||||
}
|
}
|
||||||
if strings.Contains(body, `Administrators:(OI)(CI)F`) {
|
if strings.Contains(body, `Administrators:(OI)(CI)F`) {
|
||||||
t.Fatalf("installer uses localized/early ACL grant that can block registration")
|
t.Fatalf("installer uses localized/early ACL grant that can block registration")
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,11 @@ func loadOrCreateKey(name string) (string, error) {
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := getEnv("REDFLAG_DATA_DIR", "/app/data")
|
defaultDataDir := "/app/data"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
defaultDataDir = filepath.Join(os.Getenv("ProgramData"), "RedFlag", "server")
|
||||||
|
}
|
||||||
|
dir := getEnv("REDFLAG_DATA_DIR", defaultDataDir)
|
||||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
return "", fmt.Errorf("create data dir %s: %w", dir, err)
|
return "", fmt.Errorf("create data dir %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
|
|
@ -332,7 +336,6 @@ func loadOrCreateKey(name string) (string, error) {
|
||||||
return encoded, nil
|
return encoded, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// RunSetupWizard is deprecated - configuration is now handled via web interface
|
// RunSetupWizard is deprecated - configuration is now handled via web interface
|
||||||
func RunSetupWizard() error {
|
func RunSetupWizard() error {
|
||||||
return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")
|
return fmt.Errorf("CLI setup wizard is deprecated. Please use the web interface at http://localhost:8080/setup for configuration")
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -14,6 +15,20 @@ import (
|
||||||
_ "github.com/lib/pq"
|
_ "github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Native packages carry their schema in the same binary as the server.
|
||||||
|
// Never depend on the service manager's working directory for migrations.
|
||||||
|
//
|
||||||
|
//go:embed migrations/*.up.sql
|
||||||
|
var embeddedMigrations embed.FS
|
||||||
|
|
||||||
|
func (db *DB) MigrateEmbedded() error {
|
||||||
|
source, err := fs.Sub(embeddedMigrations, "migrations")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open embedded migrations: %w", err)
|
||||||
|
}
|
||||||
|
return db.migrateFS(source)
|
||||||
|
}
|
||||||
|
|
||||||
// Connection-pool defaults. Sized for a single well-tuned server fronting 50–200
|
// Connection-pool defaults. Sized for a single well-tuned server fronting 50–200
|
||||||
// agents (SCALE-001 S1). The old 25/5 ceiling starved around ~30 concurrent agents
|
// agents (SCALE-001 S1). The old 25/5 ceiling starved around ~30 concurrent agents
|
||||||
// once the scheduler workers, sweeps, syncer, reconciler, and request handlers all
|
// once the scheduler workers, sweeps, syncer, reconciler, and request handlers all
|
||||||
|
|
@ -73,6 +88,10 @@ func envInt(key string, def int) int {
|
||||||
|
|
||||||
// Migrate runs database migrations with proper tracking and safety
|
// Migrate runs database migrations with proper tracking and safety
|
||||||
func (db *DB) Migrate(migrationsPath string) error {
|
func (db *DB) Migrate(migrationsPath string) error {
|
||||||
|
return db.migrateFS(os.DirFS(migrationsPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) migrateFS(source fs.FS) error {
|
||||||
// Create migrations table if it doesn't exist
|
// Create migrations table if it doesn't exist
|
||||||
createTableSQL := `
|
createTableSQL := `
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
|
@ -90,7 +109,7 @@ func (db *DB) Migrate(migrationsPath string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read migration files
|
// Read migration files
|
||||||
files, err := os.ReadDir(migrationsPath)
|
files, err := fs.ReadDir(source, ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read migrations directory: %w", err)
|
return fmt.Errorf("failed to read migrations directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -119,8 +138,7 @@ func (db *DB) Migrate(migrationsPath string) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read migration file
|
// Read migration file
|
||||||
path := filepath.Join(migrationsPath, filename)
|
content, err := fs.ReadFile(source, filename)
|
||||||
content, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
return fmt.Errorf("failed to read migration %s: %w", filename, err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
server/internal/database/embedded_migrations_test.go
Normal file
37
server/internal/database/embedded_migrations_test.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNativeMigrationPayloadMatchesSource(t *testing.T) {
|
||||||
|
sources, err := filepath.Glob("migrations/*.up.sql")
|
||||||
|
if err != nil || len(sources) == 0 {
|
||||||
|
t.Fatalf("migration source inventory: %v (%d files)", err, len(sources))
|
||||||
|
}
|
||||||
|
embedded, err := fs.Glob(embeddedMigrations, "migrations/*.up.sql")
|
||||||
|
if err != nil || len(embedded) != len(sources) {
|
||||||
|
t.Fatalf("embedded inventory: %v (%d files, want %d)", err, len(embedded), len(sources))
|
||||||
|
}
|
||||||
|
for _, name := range sources {
|
||||||
|
want, err := os.ReadFile(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := embeddedMigrations.ReadFile(filepath.ToSlash(name))
|
||||||
|
if err != nil || !bytes.Equal(got, want) {
|
||||||
|
t.Fatalf("embedded migration differs: %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Service startup may run from System32, not the checkout or install dir.
|
||||||
|
t.Chdir(t.TempDir())
|
||||||
|
for _, name := range embedded {
|
||||||
|
if _, err := embeddedMigrations.ReadFile(name); err != nil {
|
||||||
|
t.Fatalf("migration depends on working directory: %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -95,7 +95,7 @@ func TestServerStartsAfterMigrationFailure(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Must now use log.Fatalf for migration failure
|
// Must now use log.Fatalf for migration failure
|
||||||
migrationBlock := extractBlock(src, "db.Migrate(migrationsPath)", `migrations_complete`)
|
migrationBlock := extractBlock(src, "db.MigrateEmbedded()", `migrations_complete`)
|
||||||
if migrationBlock == "" {
|
if migrationBlock == "" {
|
||||||
t.Fatal("[ERROR] [server] [database] cannot find migration block in main.go")
|
t.Fatal("[ERROR] [server] [database] cannot find migration block in main.go")
|
||||||
}
|
}
|
||||||
|
|
@ -125,7 +125,7 @@ func TestServerMustAbortOnMigrationFailure(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
src := string(content)
|
src := string(content)
|
||||||
migrationBlock := extractBlock(src, "db.Migrate(migrationsPath)", `migrations_complete`)
|
migrationBlock := extractBlock(src, "db.MigrateEmbedded()", `migrations_complete`)
|
||||||
if migrationBlock == "" {
|
if migrationBlock == "" {
|
||||||
t.Fatal("[ERROR] [server] [database] cannot find migration block")
|
t.Fatal("[ERROR] [server] [database] cannot find migration block")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
@ -77,6 +78,96 @@ func TestInstallTemplateRenders(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWindowsInstallerVerifiesBeforeMutation(t *testing.T) {
|
||||||
|
raw, err := installScriptTemplates.ReadFile("templates/install/scripts/windows.ps1.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
source := string(raw)
|
||||||
|
|
||||||
|
// Comments in the template quote the very patterns asserted below, so
|
||||||
|
// occurrence counts are taken against the code alone.
|
||||||
|
var codeOnly strings.Builder
|
||||||
|
for _, line := range strings.Split(source, "\n") {
|
||||||
|
if !strings.HasPrefix(strings.TrimSpace(line), "#") {
|
||||||
|
codeOnly.WriteString(line)
|
||||||
|
codeOnly.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
code := codeOnly.String()
|
||||||
|
|
||||||
|
// Anchors ignore leading indentation: the block below the staging try is
|
||||||
|
// indented, and control-flow order is what this test is about, not columns.
|
||||||
|
at := func(pattern string) int {
|
||||||
|
loc := regexp.MustCompile(`(?m)^[ \t]*` + pattern).FindStringIndex(source)
|
||||||
|
if loc == nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return loc[0]
|
||||||
|
}
|
||||||
|
ordered := []struct {
|
||||||
|
name string
|
||||||
|
pattern string
|
||||||
|
}{
|
||||||
|
{"verifier preflight", `Assert-Ed25519Verifier\r?$`},
|
||||||
|
{"manifest signature", `\$ok = Test-Ed25519Signature`},
|
||||||
|
{"binary signature", `\$verified = Test-Ed25519Signature`},
|
||||||
|
{"config ACL repair", `Repair-AgentConfigAccessIfNeeded -ConfigFilePath`},
|
||||||
|
{"service stop", `Stop-Service -Name`},
|
||||||
|
{"binary replacement", `Move-Item -Path \$TmpBinary`},
|
||||||
|
}
|
||||||
|
prev := -1
|
||||||
|
for _, step := range ordered {
|
||||||
|
got := at(step.pattern)
|
||||||
|
if got < 0 {
|
||||||
|
t.Fatalf("missing installer step %q (pattern %s)", step.name, step.pattern)
|
||||||
|
}
|
||||||
|
if got <= prev {
|
||||||
|
t.Fatalf("installer step %q runs too early: verification must precede config, service, and binary changes", step.name)
|
||||||
|
}
|
||||||
|
prev = got
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fail-open verifier paths. The .NET Ed25519 probe used to return $null
|
||||||
|
// when no verifier existed and the installer carried on with a warning.
|
||||||
|
for _, forbidden := range []string{"return $null", "signature not checked", "best-effort (BouncyCastle)", "$null -eq $verified", "$null -eq $ok"} {
|
||||||
|
if strings.Contains(source, forbidden) {
|
||||||
|
t.Errorf("Windows installer contains fail-open verifier path: %s", forbidden)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// $ErrorActionPreference is Stop, so a bare Write-Error is terminating: it
|
||||||
|
// drops every diagnostic line after the first, skips the exit below it, and
|
||||||
|
// inside the desktop try/catch it was caught and downgraded to a skip
|
||||||
|
// warning that let the installer exit 0 on a manifest hash mismatch.
|
||||||
|
// Every refusal goes through Stop-Install instead.
|
||||||
|
if n := strings.Count(code, "Write-Error"); n != 1 {
|
||||||
|
t.Errorf("expected exactly one Write-Error (inside Stop-Install), found %d; refusals must use Stop-Install", n)
|
||||||
|
}
|
||||||
|
if !strings.Contains(code, "Write-Error $Line -ErrorAction Continue") {
|
||||||
|
t.Error("Stop-Install must report non-terminatingly so every diagnostic line reaches the operator")
|
||||||
|
}
|
||||||
|
if !regexp.MustCompile(`(?m)^\s*exit 1\s*$`).MatchString(source[strings.Index(source, "function Stop-Install"):]) {
|
||||||
|
t.Error("Stop-Install must exit deterministically")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup must not replace the failure that brought us here.
|
||||||
|
if strings.Contains(code, "Remove-Item -LiteralPath $StagingDir") ||
|
||||||
|
strings.Contains(code, "Remove-Item -LiteralPath $Scratch") {
|
||||||
|
t.Error("staging cleanup must go through Remove-InstallerScratchDirectory so it cannot mask the primary failure")
|
||||||
|
}
|
||||||
|
if n := strings.Count(code, "Remove-InstallerScratchDirectory -Path"); n != 3 {
|
||||||
|
t.Errorf("expected every scratch removal to use the non-masking helper, found %d", n)
|
||||||
|
}
|
||||||
|
// Downloads live inside the staging directory the finally already removes;
|
||||||
|
// a redundant Remove-Item there can throw and mask a tampering refusal.
|
||||||
|
for _, dead := range []string{"Remove-Item $TmpBinary", "Remove-Item $TmpDesktop"} {
|
||||||
|
if strings.Contains(code, dead) {
|
||||||
|
t.Errorf("redundant cleanup %q can throw and mask the refusal it follows", dead)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestFreshInstallConfigKeys verifies the install template's default JSON
|
// TestFreshInstallConfigKeys verifies the install template's default JSON
|
||||||
// includes the config keys the agent expects at minimum. This catches drift
|
// includes the config keys the agent expects at minimum. This catches drift
|
||||||
// where a new config struct field gets added but the template never writes it
|
// where a new config struct field gets added but the template never writes it
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,13 @@ if [ "$EUID" -ne 0 ]; then
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The installed helper uses sudo too. Check this before creating users or
|
||||||
|
# touching an existing installation; minimal root shells may not provide it.
|
||||||
|
if ! command -v sudo >/dev/null 2>&1; then
|
||||||
|
echo "ERROR: sudo is required by the RedFlag helper. Install sudo from your distribution, then rerun this installer." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# Variables
|
# Variables
|
||||||
AGENT_ID="{{.AgentID}}"
|
AGENT_ID="{{.AgentID}}"
|
||||||
AGENT_USER="redflag-agent"
|
AGENT_USER="redflag-agent"
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ $RegistrationToken = if ($env:RF_TOKEN) { $env:RF_TOKEN } else { "{{.Registra
|
||||||
$ServerUrl = if ($env:RF_SERVER) { $env:RF_SERVER } else { "{{.ServerURL}}" }
|
$ServerUrl = if ($env:RF_SERVER) { $env:RF_SERVER } else { "{{.ServerURL}}" }
|
||||||
$ServerUrl = $ServerUrl.TrimEnd("/")
|
$ServerUrl = $ServerUrl.TrimEnd("/")
|
||||||
$SkipServiceInstall = [bool]$env:RF_SKIP_SERVICE_INSTALL
|
$SkipServiceInstall = [bool]$env:RF_SKIP_SERVICE_INSTALL
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
function Convert-HexToBytes {
|
function Convert-HexToBytes {
|
||||||
param([Parameter(Mandatory=$true)][string]$Hex)
|
param([Parameter(Mandatory=$true)][string]$Hex)
|
||||||
|
|
@ -31,6 +32,44 @@ function Convert-HexToBytes {
|
||||||
return ,$Bytes
|
return ,$Bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# $ErrorActionPreference is Stop, which makes Write-Error terminating: only the
|
||||||
|
# first diagnostic line would reach the operator, the exit below it would never
|
||||||
|
# run, and inside a try/catch a refusal would be downgraded to a caught warning.
|
||||||
|
# Emit every line non-terminatingly, then exit. exit is not catchable, so a
|
||||||
|
# refusal raised here cannot be swallowed by an enclosing catch.
|
||||||
|
function Stop-Install {
|
||||||
|
param([Parameter(Mandatory=$true)][string[]]$Message)
|
||||||
|
|
||||||
|
foreach ($Line in $Message) {
|
||||||
|
Write-Error $Line -ErrorAction Continue
|
||||||
|
}
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cleanup must never replace the failure that brought us here. On Windows a
|
||||||
|
# scanner routinely holds a transient handle on a freshly downloaded executable,
|
||||||
|
# so removal can fail on exactly the paths where the real error matters most.
|
||||||
|
function Remove-InstallerScratchDirectory {
|
||||||
|
param([Parameter(Mandatory=$true)][string]$Path)
|
||||||
|
|
||||||
|
try {
|
||||||
|
Remove-Item -LiteralPath $Path -Recurse -Force
|
||||||
|
} catch {
|
||||||
|
Write-Warning "Could not remove installer staging directory ${Path}: $($_.Exception.Message)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function New-InstallerScratchDirectory {
|
||||||
|
$Scratch = Join-Path ([System.IO.Path]::GetTempPath()) ("redflag-" + [guid]::NewGuid().ToString("N"))
|
||||||
|
New-Item -ItemType Directory -Path $Scratch -ErrorAction Stop | Out-Null
|
||||||
|
& icacls $Scratch /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
Remove-InstallerScratchDirectory -Path $Scratch
|
||||||
|
throw "Failed to protect installer staging directory"
|
||||||
|
}
|
||||||
|
return $Scratch
|
||||||
|
}
|
||||||
|
|
||||||
function Test-Ed25519Signature {
|
function Test-Ed25519Signature {
|
||||||
param(
|
param(
|
||||||
[Parameter(Mandatory=$true)][byte[]]$Data,
|
[Parameter(Mandatory=$true)][byte[]]$Data,
|
||||||
|
|
@ -45,44 +84,49 @@ function Test-Ed25519Signature {
|
||||||
throw "invalid Ed25519 signature length"
|
throw "invalid Ed25519 signature length"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (-not $script:Ed25519OpenSSL) {
|
||||||
|
throw "OpenSSL 3 with Ed25519 support must be installed by the operator and available on PATH"
|
||||||
|
}
|
||||||
|
$Scratch = New-InstallerScratchDirectory
|
||||||
try {
|
try {
|
||||||
$null = [System.Security.Cryptography.Ed25519]
|
# RFC 8410 SubjectPublicKeyInfo for a raw 32-byte Ed25519 public key.
|
||||||
} catch {
|
[byte[]]$DER = (Convert-HexToBytes "302a300506032b6570032100") + $PublicKey
|
||||||
return $null
|
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "key.der"), $DER)
|
||||||
}
|
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "signature.bin"), $Signature)
|
||||||
|
[System.IO.File]::WriteAllBytes((Join-Path $Scratch "message.bin"), $Data)
|
||||||
|
# Use the native process exit code, not localized output or a missing
|
||||||
|
# .NET type. No downloaded executable participates in its own trust.
|
||||||
|
$Start = New-Object System.Diagnostics.ProcessStartInfo
|
||||||
|
$Start.FileName = $script:Ed25519OpenSSL
|
||||||
|
$Start.WorkingDirectory = $Scratch
|
||||||
|
$Start.Arguments = "pkeyutl -verify -rawin -pubin -keyform DER -inkey key.der -in message.bin -sigfile signature.bin"
|
||||||
|
$Start.UseShellExecute = $false
|
||||||
|
$Start.CreateNoWindow = $true
|
||||||
|
$Process = [System.Diagnostics.Process]::Start($Start)
|
||||||
try {
|
try {
|
||||||
$Ok = [System.Security.Cryptography.Ed25519]::VerifyData($Data, $Signature, $PublicKey)
|
$Process.WaitForExit()
|
||||||
return [bool]$Ok
|
return ($Process.ExitCode -eq 0)
|
||||||
} catch [System.Management.Automation.MethodException] {
|
} finally {
|
||||||
} catch [System.Management.Automation.RuntimeException] {
|
$Process.Dispose()
|
||||||
if ($_.Exception.Message -notmatch "method|overload|argument") {
|
|
||||||
throw
|
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
Remove-InstallerScratchDirectory -Path $Scratch
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
function Assert-Ed25519Verifier {
|
||||||
$Verifier = [System.Security.Cryptography.Ed25519]::Create($PublicKey)
|
$Verifier = Get-Command openssl -CommandType Application -ErrorAction Stop | Select-Object -First 1
|
||||||
$Ok = $Verifier.VerifyData($Data, $Signature)
|
$script:Ed25519OpenSSL = $Verifier.Source
|
||||||
return [bool]$Ok
|
# RFC 8032 section 7.1, test 2. Prove both acceptance and rejection before
|
||||||
} catch [System.Management.Automation.MethodException] {
|
# touching an installed agent; a version string does not prove a provider.
|
||||||
} catch [System.Management.Automation.RuntimeException] {
|
$Public = Convert-HexToBytes "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c"
|
||||||
if ($_.Exception.Message -notmatch "method|overload|argument") {
|
$Signature = Convert-HexToBytes "92a009a9f0d4cab8720e820b5f642540a2b27b5416503f8fb3762223ebdb69da085ac1e43e15996e458f3613d0f11d8c387b2eaeb4302aeeb00d291612bb0c00"
|
||||||
throw
|
if (-not (Test-Ed25519Signature -Data ([byte[]]@(0x72)) -Signature $Signature -PublicKey $Public)) {
|
||||||
|
throw "OpenSSL cannot verify Ed25519 signatures"
|
||||||
}
|
}
|
||||||
|
if (Test-Ed25519Signature -Data ([byte[]]@(0x73)) -Signature $Signature -PublicKey $Public) {
|
||||||
|
throw "Ed25519 verifier accepted tampered data"
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
$Ok = [System.Security.Cryptography.Ed25519]::Verify($Signature, $Data, $PublicKey)
|
|
||||||
return [bool]$Ok
|
|
||||||
} catch [System.Management.Automation.MethodException] {
|
|
||||||
} catch [System.Management.Automation.RuntimeException] {
|
|
||||||
if ($_.Exception.Message -notmatch "method|overload|argument") {
|
|
||||||
throw
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function Set-AgentConfigPermissions {
|
function Set-AgentConfigPermissions {
|
||||||
|
|
@ -99,14 +143,12 @@ function Set-AgentConfigPermissions {
|
||||||
# S-1-5-32-544 BUILTIN\Administrators
|
# S-1-5-32-544 BUILTIN\Administrators
|
||||||
& icacls $ConfigDirectoryPath /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-19:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
|
& icacls $ConfigDirectoryPath /inheritance:r /grant:r "*S-1-5-18:(OI)(CI)F" "*S-1-5-19:(OI)(CI)F" "*S-1-5-32-544:(OI)(CI)F" | Out-Null
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "Failed to set ACL on $ConfigDirectoryPath"
|
Stop-Install "Failed to set ACL on $ConfigDirectoryPath"
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& icacls $ConfigFilePath /inheritance:r /grant:r "*S-1-5-18:F" "*S-1-5-19:F" "*S-1-5-32-544:F" | Out-Null
|
& icacls $ConfigFilePath /inheritance:r /grant:r "*S-1-5-18:F" "*S-1-5-19:F" "*S-1-5-32-544:F" | Out-Null
|
||||||
if ($LASTEXITCODE -ne 0) {
|
if ($LASTEXITCODE -ne 0) {
|
||||||
Write-Error "Failed to set ACL on $ConfigFilePath"
|
Stop-Install "Failed to set ACL on $ConfigFilePath"
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -205,9 +247,18 @@ function Ensure-LocalApiGroup {
|
||||||
|
|
||||||
# Runtime admin check for better error messaging
|
# Runtime admin check for better error messaging
|
||||||
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
|
if (-NOT ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
|
||||||
Write-Error "This installer must be run as Administrator."
|
Stop-Install @(
|
||||||
Write-Error "Right-click PowerShell and select 'Run as Administrator', then retry."
|
"This installer must be run as Administrator.",
|
||||||
exit 1
|
"Right-click PowerShell and select 'Run as Administrator', then retry."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prove the Ed25519 verifier accepts and rejects before anything is downloaded
|
||||||
|
# or installed. A missing or broken verifier stops the install here, untouched.
|
||||||
|
try {
|
||||||
|
Assert-Ed25519Verifier
|
||||||
|
} catch {
|
||||||
|
Stop-Install "A working operator-installed OpenSSL 3 Ed25519 verifier is required. No agent files or services were changed. Detail: $($_.Exception.Message)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Detect Windows architecture
|
# Detect Windows architecture
|
||||||
|
|
@ -216,8 +267,7 @@ switch ($Arch) {
|
||||||
"AMD64" { $ArchTag = "amd64" }
|
"AMD64" { $ArchTag = "amd64" }
|
||||||
"ARM64" { $ArchTag = "arm64" }
|
"ARM64" { $ArchTag = "arm64" }
|
||||||
default {
|
default {
|
||||||
Write-Error "Unsupported architecture: $Arch. Supported: AMD64, ARM64."
|
Stop-Install "Unsupported architecture: $Arch. Supported: AMD64, ARM64."
|
||||||
exit 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -241,18 +291,154 @@ Write-Host "Platform: {{.Platform}}"
|
||||||
Write-Host "Installing to: $InstallDir\redflag-agent.exe"
|
Write-Host "Installing to: $InstallDir\redflag-agent.exe"
|
||||||
Write-Host
|
Write-Host
|
||||||
|
|
||||||
# Step 0: Detect existing installation and migration requirements
|
# Verify the download before changing an installed agent.
|
||||||
Write-Host "Detecting existing RedFlag installations..." -ForegroundColor Yellow
|
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
|
||||||
$MigrationNeeded = $false
|
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
|
||||||
$CurrentVersion = "unknown"
|
$StagingDir = New-InstallerScratchDirectory
|
||||||
$ConfigVersion = "0"
|
$TmpBinary = Join-Path $StagingDir "redflag-agent.exe"
|
||||||
$ConfigPath = Join-Path $AgentConfigDir "config.json"
|
try {
|
||||||
$OldConfigPath = Join-Path $OldConfigDir "config.json"
|
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
|
||||||
|
|
||||||
Repair-AgentConfigAccessIfNeeded -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
|
# --- Cold-start trust: verify the binary against the signed release manifest ---
|
||||||
|
# Parallels the Linux installer. The manifest lists the expected SHA-256 per
|
||||||
|
# platform/arch and is Ed25519-signed. Signature and hash checks are mandatory.
|
||||||
|
$ManifestUrl = "$ServerUrl/api/v1/manifest?version={{.Version}}"
|
||||||
|
$ServerPubKey = "{{.ServerPublicKey}}"
|
||||||
|
try {
|
||||||
|
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing
|
||||||
|
} catch {
|
||||||
|
$ManifestStatus = $null
|
||||||
|
$ManifestErrorBody = $null
|
||||||
|
$Resp = $_.Exception.Response
|
||||||
|
if ($Resp) {
|
||||||
|
try { $ManifestStatus = [int]$Resp.StatusCode } catch {}
|
||||||
|
try {
|
||||||
|
$Stream = $Resp.GetResponseStream()
|
||||||
|
if ($Stream) {
|
||||||
|
$Reader = New-Object System.IO.StreamReader($Stream)
|
||||||
|
$ManifestErrorBody = $Reader.ReadToEnd()
|
||||||
|
$Reader.Close()
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
$Reason = @()
|
||||||
|
if ($ManifestStatus) {
|
||||||
|
$Reason += "Failed to fetch release manifest from $ManifestUrl (HTTP $ManifestStatus) - refusing to install."
|
||||||
|
} else {
|
||||||
|
$Reason += "Failed to fetch release manifest from $ManifestUrl ($($_.Exception.Message)) - refusing to install."
|
||||||
|
}
|
||||||
|
if ($ManifestErrorBody) {
|
||||||
|
$Reason += "Manifest response: $ManifestErrorBody"
|
||||||
|
}
|
||||||
|
Stop-Install $Reason
|
||||||
|
}
|
||||||
|
$ManifestBody = $ManifestResp.Content
|
||||||
|
$ManifestSig = $null
|
||||||
|
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
|
||||||
|
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
|
||||||
|
}
|
||||||
|
|
||||||
# Check for existing installation in new location
|
if (-not ($ManifestSig -and $ServerPubKey)) {
|
||||||
if (Test-Path $ConfigPath) {
|
Stop-Install "Release manifest is unsigned or no server key embedded - refusing to install."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Establish the authority of the manifest before trusting any artifact hash.
|
||||||
|
try {
|
||||||
|
$pk = Convert-HexToBytes $ServerPubKey
|
||||||
|
$sg = Convert-HexToBytes $ManifestSig
|
||||||
|
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
|
||||||
|
$ok = Test-Ed25519Signature -Data $bodyBytes -Signature $sg -PublicKey $pk
|
||||||
|
} catch {
|
||||||
|
Stop-Install "Release manifest signature or public key is malformed - refusing to install."
|
||||||
|
}
|
||||||
|
if (-not $ok) {
|
||||||
|
Stop-Install "Release manifest signature invalid - refusing to install."
|
||||||
|
} else {
|
||||||
|
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
|
||||||
|
}
|
||||||
|
|
||||||
|
# Mandatory hash pin: the downloaded binary must match its manifest entry.
|
||||||
|
$Manifest = $ManifestBody | ConvertFrom-Json
|
||||||
|
$ExpectedHash = $null
|
||||||
|
foreach ($a in $Manifest.artifacts) {
|
||||||
|
if ($a.platform -eq "windows" -and $a.architecture -eq $ArchTag) {
|
||||||
|
$ExpectedHash = $a.sha256.ToLower()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (-not $ExpectedHash) {
|
||||||
|
Stop-Install "No manifest entry for windows/$ArchTag - refusing to install."
|
||||||
|
}
|
||||||
|
$ActualManifestHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
|
||||||
|
if ($ActualManifestHash -ne $ExpectedHash) {
|
||||||
|
Stop-Install @(
|
||||||
|
"Binary hash does not match the signed manifest - possible tampering.",
|
||||||
|
" expected (signed manifest): $ExpectedHash",
|
||||||
|
" actual (downloaded): $ActualManifestHash"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Write-Host "✓ Cold-start trust established: binary matches signed release manifest ($ActualManifestHash)" -ForegroundColor Green
|
||||||
|
# --- end cold-start manifest verification ---
|
||||||
|
|
||||||
|
# Verify checksum if server provided one
|
||||||
|
$ExpectedChecksum = $null
|
||||||
|
if ($Response.Headers.ContainsKey("X-Content-SHA256")) {
|
||||||
|
$ExpectedChecksum = $Response.Headers["X-Content-SHA256"]
|
||||||
|
}
|
||||||
|
if ($ExpectedChecksum) {
|
||||||
|
$ActualHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
|
||||||
|
if ($ActualHash -ne $ExpectedChecksum) {
|
||||||
|
Stop-Install @(
|
||||||
|
"Checksum verification failed",
|
||||||
|
"Expected: $ExpectedChecksum",
|
||||||
|
"Actual: $ActualHash"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Write-Host "Checksum verified: $ActualHash" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
Write-Host "WARNING: Server did not provide checksum header. Proceeding without verification." -ForegroundColor Yellow
|
||||||
|
}
|
||||||
|
|
||||||
|
# ISSUE-002: Verify binary signature before installation (TOFU model)
|
||||||
|
$ExpectedSignature = $null
|
||||||
|
if ($Response.Headers.ContainsKey("X-Content-Signature")) {
|
||||||
|
$ExpectedSignature = $Response.Headers["X-Content-Signature"]
|
||||||
|
}
|
||||||
|
if ($ExpectedSignature -and "{{.ServerPublicKey}}") {
|
||||||
|
Write-Host "Verifying binary signature..." -ForegroundColor Yellow
|
||||||
|
try {
|
||||||
|
$pubKeyBytes = Convert-HexToBytes "{{.ServerPublicKey}}"
|
||||||
|
$sigBytes = Convert-HexToBytes $ExpectedSignature
|
||||||
|
$binaryBytes = [System.IO.File]::ReadAllBytes($TmpBinary)
|
||||||
|
|
||||||
|
$verified = Test-Ed25519Signature -Data $binaryBytes -Signature $sigBytes -PublicKey $pubKeyBytes
|
||||||
|
if ($verified) {
|
||||||
|
Write-Host "✓ Binary signature verified (Ed25519)" -ForegroundColor Green
|
||||||
|
} else {
|
||||||
|
throw "Signature verification failed"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
Stop-Install @(
|
||||||
|
"Binary signature verification failed - possible tampering",
|
||||||
|
"Detail: $($_.Exception.Message)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Stop-Install "Missing binary signature or server public key - refusing to install."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Step 0: Detect existing installation and migration requirements
|
||||||
|
Write-Host "Detecting existing RedFlag installations..." -ForegroundColor Yellow
|
||||||
|
$MigrationNeeded = $false
|
||||||
|
$CurrentVersion = "unknown"
|
||||||
|
$ConfigVersion = "0"
|
||||||
|
$ConfigPath = Join-Path $AgentConfigDir "config.json"
|
||||||
|
$OldConfigPath = Join-Path $OldConfigDir "config.json"
|
||||||
|
|
||||||
|
Repair-AgentConfigAccessIfNeeded -ConfigFilePath $ConfigPath -ConfigDirectoryPath $AgentConfigDir
|
||||||
|
|
||||||
|
# Check for existing installation in new location
|
||||||
|
if (Test-Path $ConfigPath) {
|
||||||
Write-Host "✓ Existing installation detected at $ConfigDir" -ForegroundColor Green
|
Write-Host "✓ Existing installation detected at $ConfigDir" -ForegroundColor Green
|
||||||
try {
|
try {
|
||||||
$Config = Get-Content $ConfigPath | ConvertFrom-Json
|
$Config = Get-Content $ConfigPath | ConvertFrom-Json
|
||||||
|
|
@ -263,7 +449,7 @@ if (Test-Path $ConfigPath) {
|
||||||
}
|
}
|
||||||
Write-Host " Current agent version: $CurrentVersion"
|
Write-Host " Current agent version: $CurrentVersion"
|
||||||
Write-Host " Current config version: $ConfigVersion"
|
Write-Host " Current config version: $ConfigVersion"
|
||||||
} elseif (Test-Path $OldConfigPath) {
|
} elseif (Test-Path $OldConfigPath) {
|
||||||
Write-Host "⚠ Old installation detected at $OldConfigDir - MIGRATION REQUIRED" -ForegroundColor Yellow
|
Write-Host "⚠ Old installation detected at $OldConfigDir - MIGRATION REQUIRED" -ForegroundColor Yellow
|
||||||
$MigrationNeeded = $true
|
$MigrationNeeded = $true
|
||||||
try {
|
try {
|
||||||
|
|
@ -275,12 +461,12 @@ if (Test-Path $ConfigPath) {
|
||||||
}
|
}
|
||||||
Write-Host " Current agent version: $CurrentVersion"
|
Write-Host " Current agent version: $CurrentVersion"
|
||||||
Write-Host " Current config version: $ConfigVersion"
|
Write-Host " Current config version: $ConfigVersion"
|
||||||
} else {
|
} else {
|
||||||
Write-Host "✓ Fresh installation" -ForegroundColor Green
|
Write-Host "✓ Fresh installation" -ForegroundColor Green
|
||||||
}
|
}
|
||||||
|
|
||||||
# Determine if migration is needed
|
# Determine if migration is needed
|
||||||
if (-not $MigrationNeeded) {
|
if (-not $MigrationNeeded) {
|
||||||
# Check if config version indicates migration is needed
|
# Check if config version indicates migration is needed
|
||||||
try {
|
try {
|
||||||
if ([int]$ConfigVersion -lt 4) {
|
if ([int]$ConfigVersion -lt 4) {
|
||||||
|
|
@ -303,10 +489,10 @@ if (-not $MigrationNeeded) {
|
||||||
Write-Host "⚠ Missing security feature: machine_id_binding" -ForegroundColor Yellow
|
Write-Host "⚠ Missing security feature: machine_id_binding" -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Handle migration if needed
|
# Handle migration if needed
|
||||||
if ($MigrationNeeded) {
|
if ($MigrationNeeded) {
|
||||||
Write-Host
|
Write-Host
|
||||||
Write-Host "=== Migration Required ===" -ForegroundColor Cyan
|
Write-Host "=== Migration Required ===" -ForegroundColor Cyan
|
||||||
Write-Host "Agent will migrate on first start. Backing up configuration..." -ForegroundColor Yellow
|
Write-Host "Agent will migrate on first start. Backing up configuration..." -ForegroundColor Yellow
|
||||||
|
|
@ -317,194 +503,44 @@ if ($MigrationNeeded) {
|
||||||
# Backup old configuration if it exists
|
# Backup old configuration if it exists
|
||||||
if (Test-Path $OldConfigPath) {
|
if (Test-Path $OldConfigPath) {
|
||||||
Write-Host "Backing up old configuration..." -ForegroundColor Yellow
|
Write-Host "Backing up old configuration..." -ForegroundColor Yellow
|
||||||
Copy-Item $OldConfigPath $BackupDir -ErrorAction SilentlyContinue
|
Copy-Item $OldConfigPath $BackupDir -ErrorAction Stop
|
||||||
}
|
}
|
||||||
|
|
||||||
# Backup current configuration if we're upgrading
|
# Backup current configuration if we're upgrading
|
||||||
if (Test-Path $ConfigPath) {
|
if (Test-Path $ConfigPath) {
|
||||||
Write-Host "Backing up current configuration..." -ForegroundColor Yellow
|
Write-Host "Backing up current configuration..." -ForegroundColor Yellow
|
||||||
Copy-Item $ConfigPath "$BackupDir\config.json.backup" -ErrorAction SilentlyContinue
|
Copy-Item $ConfigPath "$BackupDir\config.json.backup" -ErrorAction Stop
|
||||||
}
|
}
|
||||||
|
|
||||||
Write-Host "Migration will run automatically when agent starts."
|
Write-Host "Migration will run automatically when agent starts."
|
||||||
Write-Host "View migration logs with: Get-EventLog -LogName Application -Source $ServiceName -Newest 50"
|
Write-Host "View migration logs with: Get-EventLog -LogName Application -Source $ServiceName -Newest 50"
|
||||||
Write-Host
|
Write-Host
|
||||||
}
|
}
|
||||||
|
|
||||||
# Step 1: Stop existing service if running
|
# Step 1: Stop existing service if running
|
||||||
$Service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
$Service = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue
|
||||||
if ($Service -and $Service.Status -eq 'Running') {
|
if ($Service -and $Service.Status -eq 'Running') {
|
||||||
Write-Host "Stopping existing RedFlag agent service..." -ForegroundColor Yellow
|
Write-Host "Stopping existing RedFlag agent service..." -ForegroundColor Yellow
|
||||||
Stop-Service -Name $ServiceName -Force
|
Stop-Service -Name $ServiceName -Force
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
}
|
|
||||||
|
|
||||||
# Step 2: Create directories
|
|
||||||
Write-Host "Creating directories..." -ForegroundColor Yellow
|
|
||||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path $AgentConfigDir | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path $ServerKeyDir | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
|
|
||||||
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
|
|
||||||
|
|
||||||
Ensure-LocalApiGroup -GroupName $LocalApiGroup
|
|
||||||
|
|
||||||
# Step 3: Download agent binary
|
|
||||||
Write-Host "Downloading agent binary..." -ForegroundColor Yellow
|
|
||||||
$BinaryPath = Join-Path $InstallDir "redflag-agent.exe"
|
|
||||||
$TmpBinary = Join-Path $env:TEMP "redflag-agent-download.exe"
|
|
||||||
$Response = Invoke-WebRequest -Uri $BinaryURL -OutFile $TmpBinary -UseBasicParsing -PassThru
|
|
||||||
|
|
||||||
# --- Cold-start trust: verify the binary against the signed release manifest ---
|
|
||||||
# Parallels the Linux installer. The manifest lists the expected SHA-256 per
|
|
||||||
# platform/arch and is Ed25519-signed. The hash pin is mandatory and fail-closed;
|
|
||||||
# the manifest signature is verified where an Ed25519 verifier exists. NOTE: .NET
|
|
||||||
# ships no native Ed25519, so on Windows the signature is best-effort (BouncyCastle)
|
|
||||||
# while the hash pin is always enforced — full signature parity with Linux lands
|
|
||||||
# when the Windows farm is reachable.
|
|
||||||
$ManifestUrl = "$ServerUrl/api/v1/manifest?version={{.Version}}"
|
|
||||||
$ServerPubKey = "{{.ServerPublicKey}}"
|
|
||||||
try {
|
|
||||||
$ManifestResp = Invoke-WebRequest -Uri $ManifestUrl -UseBasicParsing
|
|
||||||
} catch {
|
|
||||||
$ManifestStatus = $null
|
|
||||||
$ManifestErrorBody = $null
|
|
||||||
$Resp = $_.Exception.Response
|
|
||||||
if ($Resp) {
|
|
||||||
try { $ManifestStatus = [int]$Resp.StatusCode } catch {}
|
|
||||||
try {
|
|
||||||
$Stream = $Resp.GetResponseStream()
|
|
||||||
if ($Stream) {
|
|
||||||
$Reader = New-Object System.IO.StreamReader($Stream)
|
|
||||||
$ManifestErrorBody = $Reader.ReadToEnd()
|
|
||||||
$Reader.Close()
|
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
if ($ManifestStatus) {
|
|
||||||
Write-Error "Failed to fetch release manifest from $ManifestUrl (HTTP $ManifestStatus) - refusing to install."
|
|
||||||
} else {
|
|
||||||
Write-Error "Failed to fetch release manifest from $ManifestUrl ($($_.Exception.Message)) - refusing to install."
|
|
||||||
}
|
|
||||||
if ($ManifestErrorBody) {
|
|
||||||
Write-Error "Manifest response: $ManifestErrorBody"
|
|
||||||
}
|
|
||||||
Remove-Item $TmpBinary -Force
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$ManifestBody = $ManifestResp.Content
|
|
||||||
$ManifestSig = $null
|
|
||||||
if ($ManifestResp.Headers.ContainsKey("X-Content-Signature")) {
|
|
||||||
$ManifestSig = $ManifestResp.Headers["X-Content-Signature"]
|
|
||||||
}
|
|
||||||
|
|
||||||
if (-not ($ManifestSig -and $ServerPubKey)) {
|
# Step 2: Create directories
|
||||||
Write-Error "Release manifest is unsigned or no server key embedded - refusing to install."
|
Write-Host "Creating directories..." -ForegroundColor Yellow
|
||||||
Remove-Item $TmpBinary -Force
|
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||||
exit 1
|
New-Item -ItemType Directory -Force -Path $ConfigDir | Out-Null
|
||||||
}
|
New-Item -ItemType Directory -Force -Path $AgentConfigDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path $ServerKeyDir | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path "$ConfigDir\backups" | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path "$ConfigDir\state" | Out-Null
|
||||||
|
New-Item -ItemType Directory -Force -Path "$ConfigDir\logs" | Out-Null
|
||||||
|
|
||||||
# Defense-in-depth: verify the manifest's Ed25519 signature if a verifier exists.
|
Ensure-LocalApiGroup -GroupName $LocalApiGroup
|
||||||
try {
|
|
||||||
$pk = Convert-HexToBytes $ServerPubKey
|
|
||||||
$sg = Convert-HexToBytes $ManifestSig
|
|
||||||
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestBody)
|
|
||||||
$ok = Test-Ed25519Signature -Data $bodyBytes -Signature $sg -PublicKey $pk
|
|
||||||
} catch {
|
|
||||||
Write-Error "Release manifest signature or public key is malformed - refusing to install."
|
|
||||||
Remove-Item $TmpBinary -Force
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
if ($null -eq $ok) {
|
|
||||||
Write-Host "WARNING: No native Ed25519 verifier - manifest signature not checked (hash pin still enforced)." -ForegroundColor Yellow
|
|
||||||
Write-Host "For full signature verification, install PowerShell 7/.NET with Ed25519 support." -ForegroundColor Yellow
|
|
||||||
} elseif (-not $ok) {
|
|
||||||
Write-Error "Release manifest signature invalid - refusing to install."
|
|
||||||
Remove-Item $TmpBinary -Force
|
|
||||||
exit 1
|
|
||||||
} else {
|
|
||||||
Write-Host "✓ Manifest signature verified (Ed25519)" -ForegroundColor Green
|
|
||||||
}
|
|
||||||
|
|
||||||
# Mandatory hash pin: the downloaded binary must match its manifest entry.
|
Move-Item -Path $TmpBinary -Destination $BinaryPath -Force
|
||||||
$Manifest = $ManifestBody | ConvertFrom-Json
|
} finally {
|
||||||
$ExpectedHash = $null
|
Remove-InstallerScratchDirectory -Path $StagingDir
|
||||||
foreach ($a in $Manifest.artifacts) {
|
|
||||||
if ($a.platform -eq "windows" -and $a.architecture -eq $ArchTag) {
|
|
||||||
$ExpectedHash = $a.sha256.ToLower()
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (-not $ExpectedHash) {
|
|
||||||
Write-Error "No manifest entry for windows/$ArchTag - refusing to install."
|
|
||||||
Remove-Item $TmpBinary -Force
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
$ActualManifestHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
|
|
||||||
if ($ActualManifestHash -ne $ExpectedHash) {
|
|
||||||
Write-Error "Binary hash does not match the signed manifest - possible tampering."
|
|
||||||
Write-Error " expected (signed manifest): $ExpectedHash"
|
|
||||||
Write-Error " actual (downloaded): $ActualManifestHash"
|
|
||||||
Remove-Item $TmpBinary -Force
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "✓ Cold-start trust established: binary matches signed release manifest ($ActualManifestHash)" -ForegroundColor Green
|
|
||||||
# --- end cold-start manifest verification ---
|
|
||||||
|
|
||||||
# Verify checksum if server provided one
|
|
||||||
$ExpectedChecksum = $null
|
|
||||||
if ($Response.Headers.ContainsKey("X-Content-SHA256")) {
|
|
||||||
$ExpectedChecksum = $Response.Headers["X-Content-SHA256"]
|
|
||||||
}
|
|
||||||
if ($ExpectedChecksum) {
|
|
||||||
$ActualHash = (Get-FileHash -Path $TmpBinary -Algorithm SHA256).Hash.ToLower()
|
|
||||||
if ($ActualHash -ne $ExpectedChecksum) {
|
|
||||||
Write-Error "Checksum verification failed"
|
|
||||||
Write-Error "Expected: $ExpectedChecksum"
|
|
||||||
Write-Error "Actual: $ActualHash"
|
|
||||||
Remove-Item $TmpBinary -Force
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
Write-Host "Checksum verified: $ActualHash" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
Write-Host "WARNING: Server did not provide checksum header. Proceeding without verification." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
# ISSUE-002: Verify binary signature before installation (TOFU model)
|
|
||||||
$ExpectedSignature = $null
|
|
||||||
if ($Response.Headers.ContainsKey("X-Content-Signature")) {
|
|
||||||
$ExpectedSignature = $Response.Headers["X-Content-Signature"]
|
|
||||||
}
|
|
||||||
if ($ExpectedSignature -and "{{.ServerPublicKey}}") {
|
|
||||||
Write-Host "Verifying binary signature..." -ForegroundColor Yellow
|
|
||||||
try {
|
|
||||||
$pubKeyBytes = Convert-HexToBytes "{{.ServerPublicKey}}"
|
|
||||||
$sigBytes = Convert-HexToBytes $ExpectedSignature
|
|
||||||
$binaryBytes = [System.IO.File]::ReadAllBytes($TmpBinary)
|
|
||||||
|
|
||||||
$verified = Test-Ed25519Signature -Data $binaryBytes -Signature $sigBytes -PublicKey $pubKeyBytes
|
|
||||||
if ($null -eq $verified) {
|
|
||||||
Write-Host "WARNING: No native Ed25519 verifier - binary signature not checked (manifest hash pin already enforced)." -ForegroundColor Yellow
|
|
||||||
Write-Host "For full signature verification, install PowerShell 7/.NET with Ed25519 support." -ForegroundColor Yellow
|
|
||||||
} elseif ($verified) {
|
|
||||||
Write-Host "✓ Binary signature verified (Ed25519)" -ForegroundColor Green
|
|
||||||
} else {
|
|
||||||
throw "Signature verification failed"
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
Write-Error "Binary signature verification failed - possible tampering"
|
|
||||||
Write-Error "Detail: $($_.Exception.Message)"
|
|
||||||
Remove-Item $TmpBinary -Force
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Write-Host "WARNING: Cannot verify signature - missing public key or signature" -ForegroundColor Yellow
|
|
||||||
Write-Host "This is a security risk. Ensure server is trusted." -ForegroundColor Yellow
|
|
||||||
}
|
|
||||||
|
|
||||||
Move-Item -Path $TmpBinary -Destination $BinaryPath -Force
|
|
||||||
|
|
||||||
# Step 4: Handle configuration
|
# Step 4: Handle configuration
|
||||||
if (Test-Path $ConfigPath) {
|
if (Test-Path $ConfigPath) {
|
||||||
|
|
@ -655,11 +691,11 @@ try {
|
||||||
$ManifestDesktop = $Manifest.artifacts | Where-Object { $_.platform -eq "desktop-windows" -and $_.architecture -eq $ArchTag }
|
$ManifestDesktop = $Manifest.artifacts | Where-Object { $_.platform -eq "desktop-windows" -and $_.architecture -eq $ArchTag }
|
||||||
if ($ManifestDesktop) {
|
if ($ManifestDesktop) {
|
||||||
if ($DesktopHash -ne $ManifestDesktop.sha256.ToLower()) {
|
if ($DesktopHash -ne $ManifestDesktop.sha256.ToLower()) {
|
||||||
Write-Error "Desktop binary hash does not match signed manifest — refusing to install."
|
Stop-Install @(
|
||||||
Write-Error " expected: $($ManifestDesktop.sha256.ToLower())"
|
"Desktop binary hash does not match signed manifest — refusing to install.",
|
||||||
Write-Error " actual: $DesktopHash"
|
" expected: $($ManifestDesktop.sha256.ToLower())",
|
||||||
Remove-Item $TmpDesktop -Force
|
" actual: $DesktopHash"
|
||||||
exit 1
|
)
|
||||||
}
|
}
|
||||||
Write-Host "✓ Desktop hash verified against signed manifest" -ForegroundColor Green
|
Write-Host "✓ Desktop hash verified against signed manifest" -ForegroundColor Green
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue