source: advance the checked projection
Source-Sha: 194a59adfd6c901635da7a9c18253bb2210e2426 Policy-Sha: b9b0be0ee74e55f561a803b8aef6be3c2134f5a83b46816a069a372f2eb04f4d Tree-Digest: 4697f2e281c1c6ecafff17cf6eeb391a0a1ac0fa6e5fc429f5b473f4579c252c
This commit is contained in:
parent
0401c76c79
commit
ffdccba10f
42 changed files with 2516 additions and 554 deletions
|
|
@ -10,18 +10,6 @@ on:
|
|||
description: Stage a synthetic candidate for non-publishing internal admission
|
||||
type: boolean
|
||||
default: false
|
||||
publish_source:
|
||||
description: Explicitly publish the current internal public projection
|
||||
type: boolean
|
||||
default: false
|
||||
public_projection_sha:
|
||||
description: Exact 40-character internal public SHA authorized for publication
|
||||
type: string
|
||||
default: ""
|
||||
mirror_downstreams:
|
||||
description: Mirror the published Forge projection to optional downstreams
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
release-contract:
|
||||
|
|
@ -34,6 +22,8 @@ jobs:
|
|||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- name: Verify candidate policy and custody rejection
|
||||
run: python3 scripts/test-release-contract.py -v
|
||||
- name: Test publication construction and admission
|
||||
run: python3 -m unittest discover -s .publication -p 'test_*.py'
|
||||
- name: Stage synthetic custody admission fixture
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/main' && github.event.inputs.custody_admission == 'true'
|
||||
env:
|
||||
|
|
@ -366,181 +356,110 @@ jobs:
|
|||
- name: Gate the public surface
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m unittest discover -s .publication -p 'test_surface_gate.py'
|
||||
python3 -m unittest discover -s .publication -p 'test_surface*.py'
|
||||
git -c credential.helper= -c http.extraheader= fetch --no-tags \
|
||||
https://forge.caseytunturi.com/Fimeg/RedFlag.git refs/heads/public
|
||||
previous=$(git rev-parse FETCH_HEAD)
|
||||
python3 .publication/surface_gate.py \
|
||||
--repo . --sha "$GITHUB_SHA" \
|
||||
--manifest .publication/surface.json \
|
||||
--previous "$previous" --require-pass \
|
||||
--out /tmp/public-surface.md
|
||||
cat /tmp/public-surface.md
|
||||
grep -q '^\*\*Verdict: \(PASS\|REVIEW\)\*\*' /tmp/public-surface.md
|
||||
grep -q '^\*\*Verdict: PASS\*\*' /tmp/public-surface.md
|
||||
|
||||
# An internal public push proves that a projection is safe to disclose; it
|
||||
# does not disclose it. Publication requires a manual dispatch on the public
|
||||
# branch with both the boolean authority and the exact tested SHA. The
|
||||
# internal forge remains the only writer, and the result is read back
|
||||
# anonymously before any optional downstream moves.
|
||||
publish-forge:
|
||||
# Publication writes run only in the fixed-command trusted service.
|
||||
# No repository secret or Actions branch grants public ref authority.
|
||||
publication-candidate:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
needs: [release-contract, go-vet, go-test, rust-test, cross-compile, web-build, desktop-check, installer-integrity, dep-scan, commit-voice, action-pins]
|
||||
# Product development can remain green while disclosure is held for review.
|
||||
# The retained receipt, not the overall badge, carries publication readiness.
|
||||
runs-on: redflag-linux-build
|
||||
needs: [go-vet, go-test, rust-test, cross-compile, web-build, desktop-check, installer-integrity, dep-scan, commit-voice, action-pins, public-history, public-surface]
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true'
|
||||
env:
|
||||
PUBLIC_FORGE_TOKEN: ${{ secrets.PUBLIC_FORGE_TOKEN }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
path: control
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: source
|
||||
fetch-depth: 0
|
||||
- name: Publish and verify exact SHA
|
||||
persist-credentials: false
|
||||
- name: Record source job outcomes and prepare candidate
|
||||
env:
|
||||
AUTHORIZED_SHA: ${{ github.event.inputs.public_projection_sha }}
|
||||
SOURCE_JOB_RESULTS: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
: "${PUBLIC_FORGE_TOKEN:?PUBLIC_FORGE_TOKEN is required}"
|
||||
|
||||
expected="${GITHUB_SHA}"
|
||||
[[ "$AUTHORIZED_SHA" =~ ^[0-9a-f]{40}$ ]]
|
||||
if [ "$AUTHORIZED_SHA" != "$expected" ]; then
|
||||
echo "::error::authorized projection $AUTHORIZED_SHA does not equal tested workflow SHA $expected"
|
||||
exit 1
|
||||
fi
|
||||
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
|
||||
|
||||
# Ordinary publication is fast-forward only. .publication/EPOCH may
|
||||
# authorise exactly one replacement, and only of the SHA it names, so
|
||||
# the authorisation is spent the moment it is used.
|
||||
epoch_replaces() {
|
||||
[ -f .publication/EPOCH ] || return 1
|
||||
awk '$1 == "replaces" { print $2 }' .publication/EPOCH
|
||||
}
|
||||
|
||||
check_publishable() {
|
||||
url="$1"
|
||||
remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
|
||||
if [ -z "$remote_sha" ]; then
|
||||
return 0
|
||||
fi
|
||||
if git cat-file -e "${remote_sha}^{commit}" 2>/dev/null &&
|
||||
git merge-base --is-ancestor "$remote_sha" "$expected"; then
|
||||
return 0
|
||||
fi
|
||||
authorised="$(epoch_replaces || true)"
|
||||
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
|
||||
echo "[publish] epoch authorised to replace $remote_sha"
|
||||
EPOCH_LEASE="$remote_sha"
|
||||
return 0
|
||||
fi
|
||||
echo "[publish] refusing non-fast-forward public history: $url" >&2
|
||||
echo "[publish] remote is $remote_sha; .publication/EPOCH authorises ${authorised:-nothing}" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
EPOCH_LEASE=""
|
||||
check_publishable "$forge_url"
|
||||
|
||||
forge_auth="$(printf 'publisher-redflag:%s' "$PUBLIC_FORGE_TOKEN" | base64 -w0)"
|
||||
|
||||
if [ -n "$EPOCH_LEASE" ]; then
|
||||
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
|
||||
push --force-with-lease="refs/heads/public:$EPOCH_LEASE" "$forge_url" public:public
|
||||
else
|
||||
git -c "http.https://forge.caseytunturi.com/.extraheader=Authorization: Basic $forge_auth" \
|
||||
push "$forge_url" public:public
|
||||
fi
|
||||
|
||||
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
|
||||
test "$forge_sha" = "$expected"
|
||||
|
||||
# Fetch the anonymous Forgejo ref back into the checkout. Downstream
|
||||
# receives this ref, not the internal checkout ref that happened to
|
||||
# produce it.
|
||||
git fetch --force --no-tags "$forge_url" \
|
||||
refs/heads/public:refs/remotes/public-forge/public
|
||||
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
|
||||
echo "[publish] Forgejo anonymously serves exact tested SHA: $forge_sha"
|
||||
|
||||
# Downstreams reproduce the anonymously fetched Forgejo ref. They are
|
||||
# deliberately best-effort: a missing credential or divergent history is a
|
||||
# visible degraded mirror, never a failure of the canonical publication.
|
||||
mirror-downstreams:
|
||||
runs-on: redflag-linux-build
|
||||
needs: [publish-forge]
|
||||
if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/public' && github.event.inputs.publish_source == 'true' && github.event.inputs.mirror_downstreams == 'true'
|
||||
env:
|
||||
CODEBERG_TOKEN: ${{ secrets.CODEBERG_TOKEN }}
|
||||
MIRROR_GITHUB_TOKEN: ${{ secrets.MIRROR_GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
mkdir evidence
|
||||
source_sha=$(git -C source rev-parse HEAD)
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
jobs = json.loads(os.environ["SOURCE_JOB_RESULTS"])
|
||||
required = {"release-contract", "go-vet", "go-test", "rust-test", "cross-compile",
|
||||
"web-build", "desktop-check", "installer-integrity", "dep-scan",
|
||||
"commit-voice", "action-pins"}
|
||||
assert set(jobs) == required
|
||||
assert all(jobs[name]["result"] == "success" for name in required)
|
||||
evidence = dict(source_commit=os.environ["GITHUB_SHA"],
|
||||
repository=os.environ["GITHUB_REPOSITORY"],
|
||||
run_id=os.environ["GITHUB_RUN_ID"],
|
||||
dependency_results=jobs, source_ci_verified=False,
|
||||
publication_authorized=False)
|
||||
Path("evidence/source-ci.json").write_text(json.dumps(evidence, indent=2, sort_keys=True) + "\n")
|
||||
PY
|
||||
git -C source -c credential.helper= -c http.extraheader= fetch --no-tags \
|
||||
https://forge.caseytunturi.com/Fimeg/RedFlag.git refs/heads/public
|
||||
previous=$(git -C source rev-parse FETCH_HEAD)
|
||||
python3 control/.publication/vendor/prepare-publication.py \
|
||||
--repo source --source "$source_sha" --policy-commit "$source_sha" \
|
||||
--previous "$previous" --gate control/.publication/surface_gate.py \
|
||||
--output evidence/candidate --report-only
|
||||
- name: Scan candidate history with pinned gitleaks
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsSL -o "$RUNNER_TEMP/gitleaks.tar.gz" \
|
||||
https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz
|
||||
printf '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb %s\n' "$RUNNER_TEMP/gitleaks.tar.gz" | sha256sum -c -
|
||||
tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks
|
||||
git init --bare scan.git
|
||||
git -C scan.git index-pack --stdin < evidence/candidate/candidate.pack
|
||||
candidate=$(python3 -c 'import json; print(json.load(open("evidence/candidate/receipt.json"))["release_source_commit"])')
|
||||
scan_status=0
|
||||
# The reviewed allowlist rides with the control checkout, not the
|
||||
# candidate, and the trusted publisher repeats this with its own pinned copy.
|
||||
"$RUNNER_TEMP/gitleaks" git scan.git --redact --no-banner \
|
||||
--config control/.publication/gitleaks.toml \
|
||||
--log-opts="$candidate" || scan_status=$?
|
||||
export PUBLICATION_SCAN_STATUS="$scan_status"
|
||||
python3 - <<'PY'
|
||||
import json, os
|
||||
from pathlib import Path
|
||||
path = Path("evidence/candidate/receipt.json")
|
||||
receipt = json.loads(path.read_text())
|
||||
source = json.loads(Path("evidence/source-ci.json").read_text())
|
||||
assert source["source_commit"] == receipt["development_commit"]
|
||||
scanned = os.environ["PUBLICATION_SCAN_STATUS"] == "0"
|
||||
passed = receipt["state"] == "gates-passed" and scanned
|
||||
receipt.update(source_ci_dependencies_passed=True, source_ci=source, secret_scanner_verified=scanned,
|
||||
secret_scanner="gitleaks/8.30.1",
|
||||
state="ready-for-trusted-verification" if passed else "blocked")
|
||||
path.write_text(json.dumps(receipt, indent=2, sort_keys=True) + "\n")
|
||||
PY
|
||||
- name: Stage candidate in internal package custody
|
||||
env:
|
||||
PACKAGE_WRITE_TOKEN: ${{ secrets.PACKAGE_WRITE_TOKEN }}
|
||||
run: |
|
||||
python3 control/.publication/vendor/stage-publication.py \
|
||||
--candidate evidence/candidate --source "$GITHUB_SHA" --run-id "$GITHUB_RUN_ID" \
|
||||
--package-base "$GITHUB_SERVER_URL/api/packages/artifacts/generic/redflag-source"
|
||||
- name: Retain internal receipt and candidate
|
||||
if: always()
|
||||
uses: actions/upload-artifact@c6a3b2bd78b3985e4b2f15397fec357f0fd808de
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Mirror optional downstreams from Forgejo
|
||||
run: |
|
||||
set -euo pipefail
|
||||
expected="${GITHUB_SHA}"
|
||||
forge_url='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
|
||||
forge_sha="$(git ls-remote "$forge_url" refs/heads/public | awk '{print $1}')"
|
||||
test "$forge_sha" = "$expected"
|
||||
git fetch --force --no-tags "$forge_url" \
|
||||
refs/heads/public:refs/remotes/public-forge/public
|
||||
test "$(git rev-parse refs/remotes/public-forge/public)" = "$forge_sha"
|
||||
|
||||
mirror_downstream() {
|
||||
label="$1"
|
||||
url="$2"
|
||||
host="$3"
|
||||
user="$4"
|
||||
token="$5"
|
||||
|
||||
if [ -z "$token" ]; then
|
||||
echo "::warning::[mirror] $label credential absent; Forgejo is published, $label is degraded"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! remote_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"; then
|
||||
echo "::warning::[mirror] cannot read $label public ref; Forgejo remains authoritative"
|
||||
return 0
|
||||
fi
|
||||
lease=""
|
||||
if [ -n "$remote_sha" ]; then
|
||||
if ! git fetch --force --no-tags "$url" \
|
||||
"refs/heads/public:refs/remotes/mirror-check/$label"; then
|
||||
echo "::warning::[mirror] cannot fetch $label public ref; leaving it unchanged"
|
||||
return 0
|
||||
fi
|
||||
if ! git merge-base --is-ancestor "$remote_sha" "$forge_sha"; then
|
||||
# A mirror may follow the same epoch the forge just accepted,
|
||||
# and only from the SHA that epoch names. Anything else is the
|
||||
# divergence this branch has always refused.
|
||||
authorised="$(awk '$1 == "replaces" { print $2 }' .publication/EPOCH 2>/dev/null || true)"
|
||||
if [ -n "$authorised" ] && [ "$authorised" = "$remote_sha" ]; then
|
||||
echo "[mirror] $label follows the authorised epoch from $remote_sha"
|
||||
lease="$remote_sha"
|
||||
else
|
||||
echo "::warning::[mirror] refusing non-fast-forward $label history; recovery ref and explicit alignment required"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
auth="$(printf '%s:%s' "$user" "$token" | base64 -w0)"
|
||||
if [ -n "$lease" ]; then
|
||||
if ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
|
||||
push --force-with-lease="refs/heads/public:$lease" \
|
||||
"$url" refs/remotes/public-forge/public:public; then
|
||||
echo "::warning::[mirror] $label epoch push failed; Forgejo remains authoritative"
|
||||
return 0
|
||||
fi
|
||||
elif ! git -c "http.https://$host/.extraheader=Authorization: Basic $auth" \
|
||||
push "$url" refs/remotes/public-forge/public:public; then
|
||||
echo "::warning::[mirror] $label push failed; Forgejo remains authoritative"
|
||||
return 0
|
||||
fi
|
||||
|
||||
mirrored_sha="$(git ls-remote "$url" refs/heads/public | awk '{print $1}')"
|
||||
if [ "$mirrored_sha" != "$forge_sha" ]; then
|
||||
echo "::warning::[mirror] $label SHA mismatch after push; Forgejo remains authoritative"
|
||||
return 0
|
||||
fi
|
||||
echo "[mirror] $label agrees with Forgejo: $forge_sha"
|
||||
}
|
||||
|
||||
mirror_downstream codeberg 'https://codeberg.org/Fimeg/RedFlag.git' codeberg.org Fimeg "$CODEBERG_TOKEN"
|
||||
mirror_downstream github 'https://github.com/Fimeg/RedFlag.git' github.com Fimeg "$MIRROR_GITHUB_TOKEN"
|
||||
name: publication-candidate-${{ github.run_id }}
|
||||
path: evidence/
|
||||
if-no-files-found: warn
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
name: desktop-windows
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'helper/**'
|
||||
- 'agent/**'
|
||||
- 'installer/windows/**'
|
||||
- '.gitea/workflows/desktop-windows.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
|
|
@ -25,13 +32,41 @@ jobs:
|
|||
DESKTOP_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$output = Join-Path $env:RUNNER_TEMP ('redflag-desktop-' + [guid]::NewGuid().ToString())
|
||||
if ([string]::IsNullOrWhiteSpace($env:DESKTOP_VERSION)) {
|
||||
$cargoVersion = Select-String -Path desktop/Cargo.toml -Pattern '^version\s*=\s*"([^"]+)"' | Select-Object -First 1
|
||||
if (-not $cargoVersion) { throw 'Desktop Cargo version not found.' }
|
||||
$env:DESKTOP_VERSION = $cargoVersion.Matches[0].Groups[1].Value
|
||||
}
|
||||
$output = Join-Path (Get-Location).Path 'desktop-windows-payload'
|
||||
& ./installer/windows/build-desktop.ps1 -OutputDirectory $output -Version $env:DESKTOP_VERSION
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Native Desktop payload failed.' }
|
||||
"DESKTOP_PAYLOAD=$output" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
|
||||
- name: Check Windows Helper target
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Push-Location helper
|
||||
try {
|
||||
cargo check --locked
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Windows Helper check failed.' }
|
||||
cargo test --locked
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Windows Helper tests failed.' }
|
||||
} finally { Pop-Location }
|
||||
# The Agent's half of the fixed-task exchange is behind a windows build
|
||||
# tag, so the Linux go-test job never compiles it. This is the only lane
|
||||
# that can run it. Package-scoped: the rest of the Agent suite is proved
|
||||
# on Linux and this worker is not a second general test host.
|
||||
- name: Check Windows Agent transport
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Push-Location agent
|
||||
try {
|
||||
go vet ./internal/supplychain/
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Windows Agent transport vet failed.' }
|
||||
go test -v -count=1 ./internal/supplychain/
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Windows Agent transport tests failed.' }
|
||||
} finally { Pop-Location }
|
||||
- uses: actions/upload-artifact@ff15f0306b3f739f7b6fd43fb5d26cd321bd4de5 # v3
|
||||
with:
|
||||
name: redflag-desktop-windows-amd64
|
||||
path: ${{ env.DESKTOP_PAYLOAD }}
|
||||
path: desktop-windows-payload
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
|
|
|||
|
|
@ -19,49 +19,58 @@ jobs:
|
|||
promote:
|
||||
runs-on: release-trusted
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Verify selected internal release tag
|
||||
- name: Fetch and verify signed source from internal Gitea
|
||||
id: release
|
||||
env:
|
||||
PROMOTE_TAG: ${{ github.event.inputs.tag }}
|
||||
SELECTED_TAG: ${{ github.event.inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG=$PROMOTE_TAG
|
||||
TAG=$SELECTED_TAG
|
||||
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+){2,3}([.-][0-9A-Za-z]+)*$ ]]
|
||||
[[ "$TAG" != */* ]]
|
||||
git check-ref-format "refs/tags/$TAG"
|
||||
|
||||
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
|
||||
test -r "$TOKEN_FILE"
|
||||
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
|
||||
GIT_TOKEN_FILE="$TOKEN_FILE" \
|
||||
GIT_TOKEN_USERNAME=release-redflag \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test -s "$TOKEN_FILE"
|
||||
|
||||
# Bootstrap from the internal authority without downloading an action.
|
||||
# Keep credentials out of URLs, git config and command arguments.
|
||||
ASKPASS=$(mktemp)
|
||||
trap 'rm -f "$ASKPASS"' EXIT
|
||||
cat >"$ASKPASS" <<'ASKPASS_EOF'
|
||||
#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
*Username*) printf '%s\n' release-redflag ;;
|
||||
*Password*) cat "$GIT_TOKEN_FILE" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS_EOF
|
||||
chmod 700 "$ASKPASS"
|
||||
export GIT_ASKPASS="$ASKPASS" GIT_TOKEN_FILE="$TOKEN_FILE" GIT_TERMINAL_PROMPT=0
|
||||
RELEASE_SOURCE_DIR=$(mktemp -d "$PWD/.release-source.XXXXXX")
|
||||
git init -q "$RELEASE_SOURCE_DIR"
|
||||
cd "$RELEASE_SOURCE_DIR"
|
||||
git remote add origin "${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
|
||||
git -c credential.helper= fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = tag
|
||||
SOURCE_SHA=$(git rev-parse "${TAG}^{commit}")
|
||||
git checkout --detach "$SOURCE_SHA"
|
||||
git config gpg.format ssh
|
||||
git config gpg.ssh.allowedSignersFile .gitea/allowed_signers
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = tag
|
||||
git tag -v "$TAG"
|
||||
|
||||
SOURCE_SHA=$(git rev-parse "${TAG}^{commit}")
|
||||
VERSION=${TAG#v}
|
||||
echo "RELEASE_SOURCE_DIR=$RELEASE_SOURCE_DIR" >> "$GITHUB_ENV"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch accepted internal Gitea release bytes
|
||||
env:
|
||||
RELEASE_BUILD_SERVER_URL: ${{ vars.RELEASE_BUILD_SERVER_URL }}
|
||||
TAG: ${{ steps.release.outputs.tag }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
|
||||
ACCEPTED_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$RELEASE_SOURCE_DIR"
|
||||
[[ "$ACCEPTED_RECEIPT_SHA256" =~ ^[0-9a-f]{64}$ ]]
|
||||
command -v jq >/dev/null
|
||||
|
||||
|
|
@ -120,7 +129,10 @@ jobs:
|
|||
>/tmp/actual-release-assets.sorted
|
||||
diff -u /tmp/expected-release-assets.sorted /tmp/actual-release-assets.sorted
|
||||
|
||||
python3 scripts/release-contract.py verify artifacts/internal "$VERSION" "$SOURCE_SHA" "$TAG"
|
||||
# Use the same declared builder identity as assembly and custody.
|
||||
: "${RELEASE_BUILD_SERVER_URL:?Configure the internal build authority URL}"
|
||||
GITHUB_SERVER_URL="$RELEASE_BUILD_SERVER_URL" \
|
||||
python3 scripts/release-contract.py verify artifacts/internal "$VERSION" "$SOURCE_SHA" "$TAG"
|
||||
echo "Accepted internal alpha $TAG receipt $RECEIPT_SHA256"
|
||||
|
||||
- name: Promote the same bytes to Forgejo
|
||||
|
|
@ -131,6 +143,7 @@ jobs:
|
|||
ACCEPTED_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$RELEASE_SOURCE_DIR"
|
||||
FORGE_URL='https://forge.caseytunturi.com/Fimeg/RedFlag.git'
|
||||
FORGE_API='https://forge.caseytunturi.com/api/v1/repos/Fimeg/RedFlag'
|
||||
TOKEN_FILE=/srv/release-trusted/.secrets/forge-redflag-release-token
|
||||
|
|
|
|||
|
|
@ -2,6 +2,16 @@ name: release
|
|||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Existing signed tag whose staged candidate should enter custody without rebuilding
|
||||
required: true
|
||||
type: string
|
||||
receipt_sha256:
|
||||
description: SHA-256 of the existing package candidate files.sha256
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
|
@ -10,6 +20,7 @@ jobs:
|
|||
# Gate runs before any build. A tag that doesn't agree with the tree is a
|
||||
# broken release waiting to happen — fail here, not after artifacts exist.
|
||||
gate:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: redflag-linux-build
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
|
@ -189,6 +200,7 @@ jobs:
|
|||
|
||||
# Build the web UI once — it's the same embed for every platform.
|
||||
web:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: redflag-linux-build
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
|
@ -217,6 +229,7 @@ jobs:
|
|||
# binary and signed into the release manifest. If this fails, `release` never
|
||||
# builds (it is in `needs`).
|
||||
dep-scan:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: redflag-linux-build
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
|
|
@ -648,11 +661,14 @@ jobs:
|
|||
|
||||
- name: Assemble deterministic release candidate
|
||||
id: assemble
|
||||
env:
|
||||
RELEASE_BUILD_SERVER_URL: ${{ vars.RELEASE_BUILD_SERVER_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
command -v jq >/dev/null || (apt-get update -qq && apt-get install -y -qq jq)
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
scripts/assemble-release-candidate.sh \
|
||||
: "${RELEASE_BUILD_SERVER_URL:?Configure the internal build authority URL}"
|
||||
GITHUB_SERVER_URL="$RELEASE_BUILD_SERVER_URL" scripts/assemble-release-candidate.sh \
|
||||
artifacts candidate "$VERSION" "$GITHUB_SHA" "v$VERSION"
|
||||
RECEIPT=$(sha256sum candidate/files.sha256 | awk '{print $1}')
|
||||
echo "receipt_sha256=$RECEIPT" >> "$GITHUB_OUTPUT"
|
||||
|
|
@ -668,58 +684,84 @@ jobs:
|
|||
# rebuilds nothing and publishes only Casey's internal Gitea alpha release.
|
||||
# Public promotion is a separate manual workflow after hands-on testing.
|
||||
publish_internal:
|
||||
if: always() && !cancelled() && ((github.event_name == 'push' && needs.stage_package.result == 'success') || github.event_name == 'workflow_dispatch')
|
||||
runs-on: release-trusted
|
||||
needs: [stage_package]
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Reverify release tag
|
||||
- name: Fetch and verify signed source from internal Gitea
|
||||
id: release
|
||||
env:
|
||||
SELECTED_TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
TAG="v$VERSION"
|
||||
TAG=$SELECTED_TAG
|
||||
[[ "$TAG" =~ ^v[0-9]+(\.[0-9]+){2,3}([.-][0-9A-Za-z]+)*$ ]]
|
||||
git check-ref-format "refs/tags/$TAG"
|
||||
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
|
||||
test -r "$TOKEN_FILE"
|
||||
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
|
||||
GIT_TOKEN_FILE="$TOKEN_FILE" \
|
||||
GIT_TOKEN_USERNAME=release-redflag \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
git fetch --force origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test -s "$TOKEN_FILE"
|
||||
|
||||
# Bootstrap from the internal authority without downloading an action.
|
||||
# Keep credentials out of URLs, git config and command arguments.
|
||||
ASKPASS=$(mktemp)
|
||||
trap 'rm -f "$ASKPASS"' EXIT
|
||||
cat >"$ASKPASS" <<'ASKPASS_EOF'
|
||||
#!/usr/bin/env bash
|
||||
case "$1" in
|
||||
*Username*) printf '%s\n' release-redflag ;;
|
||||
*Password*) cat "$GIT_TOKEN_FILE" ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS_EOF
|
||||
chmod 700 "$ASKPASS"
|
||||
export GIT_ASKPASS="$ASKPASS" GIT_TOKEN_FILE="$TOKEN_FILE" GIT_TERMINAL_PROMPT=0
|
||||
RELEASE_SOURCE_DIR=$(mktemp -d "$PWD/.release-source.XXXXXX")
|
||||
git init -q "$RELEASE_SOURCE_DIR"
|
||||
cd "$RELEASE_SOURCE_DIR"
|
||||
git remote add origin "${GITHUB_SERVER_URL%/}/${GITHUB_REPOSITORY}.git"
|
||||
git -c credential.helper= fetch --no-tags origin "refs/tags/$TAG:refs/tags/$TAG"
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = tag
|
||||
SOURCE_SHA=$(git rev-parse "${TAG}^{commit}")
|
||||
git checkout --detach "$SOURCE_SHA"
|
||||
git config gpg.format ssh
|
||||
git config gpg.ssh.allowedSignersFile .gitea/allowed_signers
|
||||
test "$(git cat-file -t "refs/tags/$TAG")" = tag
|
||||
test "$(git rev-parse "${TAG}^{commit}")" = "$GITHUB_SHA"
|
||||
git tag -v "$TAG"
|
||||
|
||||
# The trusted lane checks ancestry independently of ordinary CI.
|
||||
GIT_ASKPASS="$PWD/scripts/git-askpass-token.sh" \
|
||||
GIT_TOKEN_FILE="$TOKEN_FILE" \
|
||||
GIT_TOKEN_USERNAME=release-redflag \
|
||||
GIT_TERMINAL_PROMPT=0 \
|
||||
git fetch --no-tags origin refs/heads/public:refs/remotes/origin/public
|
||||
git merge-base --is-ancestor "$GITHUB_SHA" refs/remotes/origin/public
|
||||
if [ "$GITHUB_EVENT_NAME" = push ]; then
|
||||
test "$SOURCE_SHA" = "$GITHUB_SHA"
|
||||
fi
|
||||
# Check public ancestry independently of the build lane.
|
||||
git -c credential.helper= fetch --no-tags origin refs/heads/public:refs/remotes/origin/public
|
||||
git merge-base --is-ancestor "$SOURCE_SHA" refs/remotes/origin/public
|
||||
echo "RELEASE_SOURCE_DIR=$RELEASE_SOURCE_DIR" >> "$GITHUB_ENV"
|
||||
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${TAG#v}" >> "$GITHUB_OUTPUT"
|
||||
echo "source_sha=$SOURCE_SHA" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch and verify package candidate
|
||||
env:
|
||||
PACKAGE_RECEIPT_SHA256: ${{ needs.stage_package.outputs.receipt_sha256 }}
|
||||
RELEASE_BUILD_SERVER_URL: ${{ vars.RELEASE_BUILD_SERVER_URL }}
|
||||
PACKAGE_RECEIPT_SHA256: ${{ github.event.inputs.receipt_sha256 || needs.stage_package.outputs.receipt_sha256 }}
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
SOURCE_SHA: ${{ steps.release.outputs.source_sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
cd "$RELEASE_SOURCE_DIR"
|
||||
scripts/fetch-release-candidate.sh \
|
||||
artifacts/package artifacts "$VERSION" \
|
||||
"$PACKAGE_RECEIPT_SHA256" \
|
||||
/srv/release-trusted/.secrets/package-read-token
|
||||
python3 scripts/release-contract.py verify artifacts/package "$VERSION" "$GITHUB_SHA" "v$VERSION"
|
||||
# Builder identity is fixed independently of this runner's access URL.
|
||||
: "${RELEASE_BUILD_SERVER_URL:?Configure the internal build authority URL}"
|
||||
GITHUB_SERVER_URL="$RELEASE_BUILD_SERVER_URL" \
|
||||
python3 scripts/release-contract.py verify artifacts/package "$VERSION" "$SOURCE_SHA" "v$VERSION"
|
||||
|
||||
- name: Create Gitea release
|
||||
env:
|
||||
VERSION: ${{ steps.release.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
cd "$RELEASE_SOURCE_DIR"
|
||||
TAG="v$VERSION"
|
||||
API="${GITHUB_SERVER_URL}/api/v1"
|
||||
TOKEN_FILE=/srv/release-trusted/.secrets/gitea-redflag-release-token
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -29,6 +29,9 @@ go.work
|
|||
|
||||
# Dependency directories (remove comment if using vendoring)
|
||||
vendor/
|
||||
# Go vendoring only. The publication rail's vendored tools are tracked
|
||||
# source, and a new one must not be swallowed on its way into custody.
|
||||
!.publication/vendor/
|
||||
|
||||
# Go build cache
|
||||
.cache/
|
||||
|
|
|
|||
46
.publication/gitleaks.toml
Normal file
46
.publication/gitleaks.toml
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# Reviewed scanner decisions for the public source projection.
|
||||
#
|
||||
# The publication rail scans the candidate's whole reachable history, so a
|
||||
# finding in already-published history would block every future publication,
|
||||
# not only the one that introduced it. Nothing here can un-disclose bytes the
|
||||
# Forge already serves; each entry records that a specific already-public
|
||||
# location was read and judged not to be a credential.
|
||||
#
|
||||
# This file is part of the public surface on purpose: the decision should be
|
||||
# auditable by the same people who audit the code. It extends the default rule
|
||||
# set rather than replacing it, and every entry names one rule, one path and
|
||||
# one commit. Anything new, anywhere else, still blocks.
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[allowlists]]
|
||||
description = """
|
||||
Documentation example response body. A truncated JWT ending in '...', shown so
|
||||
a reader can see the shape of a registration response. Reviewed 2026-09-21.
|
||||
"""
|
||||
condition = "AND"
|
||||
targetRules = ["generic-api-key"]
|
||||
commits = ["67e26be2d99f09f97c841dced7dcc982536a5202"]
|
||||
paths = ['''^RAF/flows/01-registration\.md$''']
|
||||
|
||||
[[allowlists]]
|
||||
description = """
|
||||
Install instructions. The header value is the literal placeholder 'your-token',
|
||||
which the surrounding prose tells the reader to replace. Reviewed 2026-09-21.
|
||||
"""
|
||||
condition = "AND"
|
||||
targetRules = ["curl-auth-header"]
|
||||
commits = ["67e26be2d99f09f97c841dced7dcc982536a5202"]
|
||||
paths = ['''^README\.md$''']
|
||||
|
||||
[[allowlists]]
|
||||
description = """
|
||||
Test fixture. A JWT deliberately signed 'invalidsig' by the test that proves
|
||||
the auth middleware never writes a token to stdout. Its value is not a
|
||||
credential anywhere; that is the point of the test. Reviewed 2026-09-21.
|
||||
"""
|
||||
condition = "AND"
|
||||
targetRules = ["jwt"]
|
||||
commits = ["67e26be2d99f09f97c841dced7dcc982536a5202"]
|
||||
paths = ['''^server/internal/api/handlers/auth_middleware_leak_test\.go$''']
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
.publication/EPOCH
|
||||
.publication/commit-voice-allowlist.json
|
||||
.publication/commit_voice.py
|
||||
.publication/gitleaks.toml
|
||||
.publication/paths.txt
|
||||
.publication/surface.json
|
||||
.publication/surface_gate.py
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@
|
|||
],
|
||||
"review_required": [
|
||||
"RAF/README.md",
|
||||
"RAF/components/04-helper.md",
|
||||
"RAF/components/05-desktop.md",
|
||||
"RAF/core/02-architecture-decisions.md",
|
||||
"RAF/flows/06-update-lifecycle.md",
|
||||
|
|
|
|||
|
|
@ -26,13 +26,10 @@ every one of its commits was scanned can still be a tree that should not be
|
|||
public, because publication is a property of the tree, not of the diffs that
|
||||
built it.
|
||||
|
||||
The severity model is deliberately harsher than the incremental scanner's.
|
||||
There, a home path or a LAN address is a WARN: one line in one patch, and a
|
||||
human is reading the patch anyway. Here the same finding is a DENY, because
|
||||
nobody reads a whole tree, and because the finding means the path is standing
|
||||
in the public product right now, not that it passed through once.
|
||||
|
||||
Absence of known-secret content is not authorization to publish.
|
||||
Credential findings are hard boundaries. Private addresses and house hostnames
|
||||
are advisory topology findings; an allowlisted source file may name them without
|
||||
handing anyone a capability. Private identities, unadmitted paths, unsafe links,
|
||||
and credentials still block. Absence of known-secret content is not authorization to publish.
|
||||
|
||||
Exceptions are narrow and auditable: path, rule, reason, review date. A rule
|
||||
turned off globally is not an exception, it is a retreat, so this file has no
|
||||
|
|
@ -84,12 +81,11 @@ class Rule:
|
|||
self.severity = severity
|
||||
|
||||
|
||||
# Capabilities deny because publishing one hands control to whoever reads it
|
||||
# and no later cleanup takes it back. Topology denies here — see the module
|
||||
# docstring — because in a tree it is a standing disclosure, not a transit.
|
||||
# Capability rules cannot be waived by a path exception. Topology is advisory.
|
||||
HARD_RULES = {"private-key", "bearer-token"}
|
||||
CONTENT_RULES = (
|
||||
Rule("private-key", "private key material",
|
||||
r"-----BEGIN (?:RSA|DSA|EC|OPENSSH|PGP) PRIVATE KEY", DENY),
|
||||
r"-----BEGIN (?:(?:RSA|DSA|EC|OPENSSH|PGP|ENCRYPTED) )?PRIVATE KEY", DENY),
|
||||
# The value must be quoted. An unquoted run of letters after `authorization:`
|
||||
# is a type name in every language that has types, and matching it made the
|
||||
# gate cry wolf over `authorization: MutationAuthorization` on first run.
|
||||
|
|
@ -100,7 +96,7 @@ CONTENT_RULES = (
|
|||
Rule("rfc1918", "private LAN address",
|
||||
r"\b(?:10\.\d{1,3}\.\d{1,3}\.\d{1,3}"
|
||||
r"|192\.168\.\d{1,3}\.\d{1,3}"
|
||||
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b", DENY),
|
||||
r"|172\.(?:1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3})\b", NOTE),
|
||||
# Case-sensitive on purpose. Every rule here is otherwise IGNORECASE, and
|
||||
# a case-blind `/Users/` matched the phrase "sessions/users/seats" in a
|
||||
# comment about loginctl. A macOS home is capitalised; a POSIX path segment
|
||||
|
|
@ -108,7 +104,7 @@ CONTENT_RULES = (
|
|||
Rule("home-path", "developer home directory",
|
||||
r"/home/[a-z][a-z0-9_-]*|(?-i:/Users/[A-Za-z])|(?-i:C:\\\\Users\\\\)", DENY),
|
||||
Rule("internal-host", "internal hostname or forge",
|
||||
r"\bwiuf|\barchdev\b|\bwiufph\b", DENY),
|
||||
r"\bwiuf|\barchdev\b|\bwiufph\b", NOTE),
|
||||
# The internal domain only. An author's own public contact address is not a
|
||||
# leak — it is on the security page on purpose, and matching it made the
|
||||
# gate refuse commits for being signed by the person who wrote them.
|
||||
|
|
@ -144,12 +140,7 @@ class Finding:
|
|||
|
||||
def redact(text, match):
|
||||
"""Keep the shape, drop the value. A report is itself a publishable object."""
|
||||
s, e = match.span()
|
||||
line = text[max(0, s - 40):e + 40].replace("\n", " ").strip()
|
||||
hit = match.group(0)
|
||||
keep = 2 if len(hit) > 6 else 1
|
||||
masked = hit[:keep] + "*" * max(1, len(hit) - keep * 2) + (hit[-keep:] if len(hit) > 6 else "")
|
||||
return line.replace(hit, masked)[:150]
|
||||
return "[matched content withheld]"
|
||||
|
||||
|
||||
class Manifest:
|
||||
|
|
@ -203,6 +194,8 @@ class Manifest:
|
|||
|
||||
def excepted(self, path, rule_id):
|
||||
"""An exception names one path and one rule, and says why, and when."""
|
||||
if rule_id in HARD_RULES:
|
||||
return None
|
||||
for exc in self.exceptions:
|
||||
if exc.get("rule") != rule_id:
|
||||
continue
|
||||
|
|
@ -245,13 +238,15 @@ def top_level(repo, sha):
|
|||
|
||||
# ---------------------------------------------------------------- gate 1
|
||||
|
||||
def gate_path_authority(repo, sha, manifest, findings):
|
||||
def gate_path_authority(repo, sha, manifest, findings, previous=""):
|
||||
if run(repo, ["rev-parse", "--is-shallow-repository"]).strip() != "false":
|
||||
findings.append(Finding(
|
||||
"path-authority", DENY, "shallow-history", "-",
|
||||
"complete history is required to establish path authority"))
|
||||
return
|
||||
entries = tree_entries(repo, sha)
|
||||
prior = {e[4]: e[:3] for e in tree_entries(repo, previous)} if previous else {}
|
||||
changed = {e[4] for e in entries if prior.get(e[4]) != e[:3]}
|
||||
present = {entry[4] for entry in entries}
|
||||
origins = {path: sha for path in present}
|
||||
commits = run(repo, ["rev-list", sha]).splitlines()
|
||||
|
|
@ -277,7 +272,7 @@ def gate_path_authority(repo, sha, manifest, findings):
|
|||
"path-authority", DENY, "unlisted-path", path,
|
||||
f"no exact manifest entry admits this path in {location}"))
|
||||
review_pattern = manifest.needs_review(path)
|
||||
if review_pattern and path in present:
|
||||
if review_pattern and path in changed:
|
||||
findings.append(Finding(
|
||||
"path-authority", REVIEW, "review-path", path,
|
||||
f"manifest preserves human review under {review_pattern!r}"))
|
||||
|
|
@ -302,6 +297,21 @@ def gate_path_authority(repo, sha, manifest, findings):
|
|||
# ---------------------------------------------------------------- gate 2
|
||||
|
||||
def gate_new_surface(repo, sha, previous, manifest, findings):
|
||||
# Symlinks and submodules are surface changes disguised as files.
|
||||
for mode, otype, _oid, _size, path in tree_entries(repo, sha):
|
||||
if mode == "120000":
|
||||
target = run(repo, ["show", f"{sha}:{path}"]).strip()
|
||||
escapes = target.startswith("/") or ".." in target.split("/")
|
||||
findings.append(Finding(
|
||||
"new-surface", DENY if escapes else NOTE, "symlink", path,
|
||||
"symlink target leaves the public tree" if escapes
|
||||
else "symlink stays inside the public tree"))
|
||||
if otype == "commit":
|
||||
findings.append(Finding(
|
||||
"new-surface", REVIEW, "submodule", path,
|
||||
"submodule gitlink; its URL must resolve publicly"))
|
||||
|
||||
|
||||
if not previous:
|
||||
findings.append(Finding(
|
||||
"new-surface", NOTE, "no-baseline", "-",
|
||||
|
|
@ -326,19 +336,6 @@ def gate_new_surface(repo, sha, previous, manifest, findings):
|
|||
"new-surface", REVIEW, "new-dotfile", path,
|
||||
"new dotfile or dotdirectory entering the public tree"))
|
||||
|
||||
# Symlinks and submodules are surface changes disguised as files.
|
||||
for mode, otype, _oid, _size, path in tree_entries(repo, sha):
|
||||
if mode == "120000":
|
||||
target = run(repo, ["show", f"{sha}:{path}"]).strip()
|
||||
escapes = target.startswith("/") or ".." in target.split("/")
|
||||
findings.append(Finding(
|
||||
"new-surface", DENY if escapes else NOTE, "symlink", path,
|
||||
"symlink target leaves the public tree" if escapes
|
||||
else "symlink stays inside the public tree"))
|
||||
if otype == "commit":
|
||||
findings.append(Finding(
|
||||
"new-surface", REVIEW, "submodule", path,
|
||||
"submodule gitlink; its URL must resolve publicly"))
|
||||
|
||||
|
||||
def gate_submodule_urls(repo, sha, findings):
|
||||
|
|
@ -381,17 +378,14 @@ def gate_file_class(repo, sha, manifest, findings):
|
|||
|
||||
# ---------------------------------------------------------------- gate 4
|
||||
|
||||
def gate_content(repo, sha, manifest, findings, limit_per_rule=40):
|
||||
def gate_content(repo, sha, manifest, findings, limit_per_rule=40, seen=None):
|
||||
counts = {}
|
||||
seen = set() if seen is None else seen
|
||||
for _mode, otype, _oid, _size, path in tree_entries(repo, sha):
|
||||
if otype != "blob" or BINARY_HINT.search(path):
|
||||
continue
|
||||
try:
|
||||
text = run(repo, ["show", f"{sha}:{path}"])
|
||||
except RuntimeError:
|
||||
continue
|
||||
if "\0" in text[:8000]:
|
||||
if otype != "blob" or (_oid, path) in seen:
|
||||
continue
|
||||
seen.add((_oid, path))
|
||||
text = run(repo, ["cat-file", "blob", _oid])
|
||||
for rule in CONTENT_RULES:
|
||||
m = rule.pattern.search(text)
|
||||
if not m:
|
||||
|
|
@ -413,33 +407,25 @@ def gate_content(repo, sha, manifest, findings, limit_per_rule=40):
|
|||
# ---------------------------------------------------------------- gate 5
|
||||
|
||||
def gate_history(repo, sha, manifest, findings, limit_per_rule=25):
|
||||
sep = "\x1e"
|
||||
out = run(repo, ["log", f"--format=%H{sep}%an <%ae>{sep}%s{sep}%b\x1d", sha])
|
||||
counts = {}
|
||||
total = 0
|
||||
for record in out.split("\x1d"):
|
||||
record = record.strip("\n")
|
||||
if not record.strip():
|
||||
continue
|
||||
parts = record.split(sep)
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
h, ident, subject, body = parts[0], parts[1], parts[2], parts[3]
|
||||
total += 1
|
||||
blob = f"{ident}\n{subject}\n{body}"
|
||||
if run(repo, ["rev-parse", "--is-shallow-repository"]).strip() != "false":
|
||||
findings.append(Finding("history", DENY, "shallow-history", "-",
|
||||
"complete history is required"))
|
||||
return
|
||||
commits = run(repo, ["rev-list", sha]).splitlines()
|
||||
seen = set()
|
||||
for revision in commits:
|
||||
# Include deleted blobs, binary-named blobs and committer metadata.
|
||||
gate_content(repo, revision, manifest, findings, seen=seen)
|
||||
if revision != sha:
|
||||
gate_file_class(repo, revision, manifest, findings)
|
||||
blob = run(repo, ["cat-file", "commit", revision])
|
||||
for rule in MESSAGE_RULES:
|
||||
m = rule.pattern.search(blob)
|
||||
if not m:
|
||||
continue
|
||||
counts[rule.rule_id] = counts.get(rule.rule_id, 0) + 1
|
||||
if counts[rule.rule_id] > limit_per_rule:
|
||||
continue
|
||||
findings.append(Finding(
|
||||
"history", rule.severity, rule.rule_id, h[:12],
|
||||
f"{rule.description} in commit metadata: {subject[:60]}",
|
||||
excerpt=redact(blob, m)))
|
||||
if rule.pattern.search(blob):
|
||||
findings.append(Finding("history", rule.severity, rule.rule_id,
|
||||
revision[:12], rule.description + " in commit metadata"))
|
||||
findings.append(Finding("history", NOTE, "reachable", "-",
|
||||
f"{total} commits reachable from {sha[:12]}"))
|
||||
f"{len(commits)} commits reachable from {sha[:12]}"))
|
||||
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- gate 6
|
||||
|
|
@ -508,6 +494,8 @@ def main():
|
|||
ap.add_argument("--out", default="")
|
||||
ap.add_argument("--inventory-out", default="")
|
||||
ap.add_argument("--skip-history", action="store_true")
|
||||
ap.add_argument("--require-pass", action="store_true", help="fail on review findings as well as denials")
|
||||
ap.add_argument("--json-out", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
sha = run(args.repo, ["rev-parse", args.sha]).strip()
|
||||
|
|
@ -525,7 +513,7 @@ def main():
|
|||
manifest = Manifest(json.loads(manifest_text), manifest_rel, args.repo, sha)
|
||||
|
||||
findings = []
|
||||
gate_path_authority(args.repo, sha, manifest, findings)
|
||||
gate_path_authority(args.repo, sha, manifest, findings, previous)
|
||||
gate_new_surface(args.repo, sha, previous, manifest, findings)
|
||||
gate_submodule_urls(args.repo, sha, findings)
|
||||
gate_file_class(args.repo, sha, manifest, findings)
|
||||
|
|
@ -546,7 +534,15 @@ def main():
|
|||
fh.write(inventory(args.repo, sha))
|
||||
print(f"wrote {args.inventory_out}")
|
||||
|
||||
return 1 if any(f.severity == DENY for f in findings) else 0
|
||||
if args.json_out:
|
||||
with open(args.json_out, "w") as fh:
|
||||
json.dump({"candidate": sha, "previous": previous,
|
||||
"complete_history": not args.skip_history,
|
||||
"findings": [{"severity": f.severity, "gate": f.gate,
|
||||
"rule": f.rule, "path": f.path} for f in findings]},
|
||||
fh, sort_keys=True, indent=2)
|
||||
return 1 if any(f.severity == DENY or (args.require_pass and f.severity == REVIEW)
|
||||
for f in findings) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -84,6 +84,23 @@ class SurfaceGateTests(unittest.TestCase):
|
|||
self.assertEqual(0, result.returncode, result.stdout + result.stderr)
|
||||
self.assertIn("Verdict: PASS", result.stdout)
|
||||
|
||||
def test_review_is_required_only_for_changed_public_bytes(self):
|
||||
old = self.candidate({"product/main.go": "package main\n"})
|
||||
path = self.repo / ".publication/surface.json"
|
||||
policy = json.loads(path.read_text())
|
||||
policy["review_required"] = ["product/main.go"]
|
||||
path.write_text(json.dumps(policy, indent=2) + "\n")
|
||||
subprocess.run(["git", "-C", self.repo, "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", self.repo, "commit", "-qm", "test: mark review"], check=True)
|
||||
unchanged = self.run_gate("HEAD", "--previous", old, "--require-pass")
|
||||
self.assertEqual(0, unchanged.returncode, unchanged.stdout + unchanged.stderr)
|
||||
(self.repo / "product/main.go").write_text("package changed\n")
|
||||
subprocess.run(["git", "-C", self.repo, "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", self.repo, "commit", "-qm", "test: change reviewed bytes"], check=True)
|
||||
changed = self.run_gate("HEAD", "--previous", old, "--require-pass")
|
||||
self.assertEqual(1, changed.returncode)
|
||||
self.assertIn("review-path", changed.stdout)
|
||||
|
||||
def test_unlisted_file_under_product_directory_fails(self):
|
||||
sha = self.candidate(
|
||||
{"product/main.go": "package main\n", "product/private.txt": "not admitted\n"},
|
||||
|
|
|
|||
|
|
@ -16,6 +16,35 @@ the tip. Removing a path from disclosure therefore requires history that does
|
|||
not contain it; a deletion commit alone is insufficient. Shallow history fails
|
||||
closed. Internal development history is preserved separately.
|
||||
|
||||
Candidate preparation now runs separately from public ref movement. Its
|
||||
internal receipt binds the source commit, both policy files, previous public
|
||||
head, constructor and gate hashes, exact tree inventory, omitted source paths
|
||||
and retained object pack. The source check requires the exact successful run
|
||||
and every required job, including all cross-compile jobs; the candidate also
|
||||
requires a separate secret scan. These unsigned receipts are evidence for the
|
||||
trusted publisher to verify, not permission for a builder to publish.
|
||||
|
||||
Preparation records disclosure holds without failing otherwise successful
|
||||
product CI. The pack and receipt enter internal package custody under the
|
||||
source SHA and source run ID. Custody does not make a held candidate ready.
|
||||
|
||||
The shared contract now defines a fixed-command trusted publisher with
|
||||
READY, BLOCKED and PUBLISHED receipts. It independently reconstructs this same
|
||||
projection under protected tool/policy/workflow identities, rechecks source CI
|
||||
and the public parent, then accepts only an authenticated human request naming
|
||||
the exact READY receipt. A one-parent fast-forward compare-and-swap and fresh
|
||||
anonymous commit/tree readback are required before it reports PUBLISHED.
|
||||
The implementation and negative tests are not a claim that production
|
||||
publisher credentials, branch protections or human authentication are enrolled.
|
||||
The source workflow no longer contains public or mirror push jobs.
|
||||
|
||||
Private network addresses and house hostnames are advisory findings. Private
|
||||
identities, unadmitted paths, unsafe links and credential findings still block.
|
||||
Credential rules cannot be waived by a path exception. The gate scans reachable
|
||||
historical blobs as well as the current tree and commit metadata, and withholds
|
||||
matched content from reports. Policy-marked review paths require a new decision
|
||||
only when their mode or bytes differ from the previous public head.
|
||||
|
||||
Reproduce the tree digest from a checked-out public commit with:
|
||||
|
||||
```sh
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ web/src/
|
|||
│ ├── polling.ts # POLL.* constants — all intervals centralized
|
||||
│ ├── store.ts, queryParser.ts, vulnerabilities.ts
|
||||
│ └── client-logger.ts # Client errors ship to the server log (ETHOS #1)
|
||||
├── desktop/ # Tauri tray-app variant (vite.desktop.config.ts)
|
||||
├── desktop/ # Retained legacy Tauri web shell; not RedFlag Desktop
|
||||
└── types/ # Shared TS types mirroring server models
|
||||
```
|
||||
|
||||
|
|
@ -65,6 +65,10 @@ web/src/
|
|||
- Mobile layout usable, not optimized
|
||||
- Several UI coverage gaps tracked as `UI-*` tasks (not architecture — task tier)
|
||||
|
||||
The native RedFlag Desktop is the Qt/QML component described in
|
||||
[components/05-desktop](05-desktop.md). `web/src/desktop` is retained legacy source, not a
|
||||
second supported Desktop implementation or an architectural authority.
|
||||
|
||||
---
|
||||
|
||||
*Last reviewed: 2026-06-14*
|
||||
*Last reviewed: 2026-09-14*
|
||||
|
|
|
|||
|
|
@ -20,6 +20,28 @@ On Linux the agent invokes it through `sudo systemd-run --wait --property=Protec
|
|||
|
||||
### Windows Invocation (SEC-030, decided 2026-07-01)
|
||||
|
||||
**Current source boundary:** the native Windows payload now builds the Rust Helper. It
|
||||
answers its version and supports verification-only binary and `MutationEnvelope` checks.
|
||||
Windows-only source now opens each trust-path component without following reparse points,
|
||||
checks SYSTEM/Administrators ownership and denies write grants to other principals, rejects
|
||||
hard-linked trust files, and creates write-through, one-shot authorization claims. The fixed
|
||||
task dispatcher reads an envelope from its protected exchange, pins the installed authority
|
||||
placement, and uses the shared verifier. The wrapper carrying that envelope is decoded
|
||||
before the envelope itself, so a request the Helper could read but whose envelope it could
|
||||
not is refused under its own validated request id instead of leaving the caller an unknown
|
||||
outcome; a wrapper too malformed to establish an identity still writes no result, because
|
||||
the Helper does not invent one. A shared Go/Rust fixture pins that wrapper on both sides.
|
||||
The Agent reaches this path read-only through `redflag-agent -verify-envelope`, which mints
|
||||
nothing, admits no backend, and passes no command or path to the privileged process.
|
||||
Windows mint and backend execution still return typed refusals.
|
||||
|
||||
Native acceptance of the *installed* rail — ACL enforcement, service identity, task
|
||||
definition, and exchange roots — remains open: the transport is covered by tests, not by a
|
||||
witness on a provisioned host. Replay refusal is likewise open and deliberately unexercised,
|
||||
because verification must not consume an authorization; the claim belongs at an execution
|
||||
boundary that no admitted backend has yet. Low-privilege service migration and WUA / Winget
|
||||
executors remain required before mutation authority is enabled.
|
||||
|
||||
Windows has no `systemd-run` equivalent for spinning up an ad-hoc transient privileged unit, so the helper is invoked through a **Scheduled Task, configured to run once as SYSTEM**. The agent's own service account has no standing right to mutate anything — it only holds a delegated "AllowedToRun" ACE on this one task definition (`schtasks /run /tn RedFlagHelper`), the direct Windows analogue of the Linux sudoers line that grants exactly one `systemd-run` invocation and nothing else.
|
||||
|
||||
Two elevation-model alternatives were considered and rejected:
|
||||
|
|
@ -28,6 +50,54 @@ Two elevation-model alternatives were considered and rejected:
|
|||
|
||||
The task definition itself is provisioned **at agent-install time** by the (already-elevated) install script, the same moment Linux drops its sudoers entry — not self-provisioned by the agent on first gated operation, which would just relocate the "who grants the first elevation" problem rather than solve it.
|
||||
|
||||
`installer/windows/provision-helper.ps1` now prepares that installer-owned boundary. It
|
||||
creates one triggerless `\RedFlag\Helper` task with a fixed Helper path and `run-request`
|
||||
argument, grants the Agent service SID read/run rather than task mutation, and places the
|
||||
Agent-writable request child beneath a non-Agent-writable Helper root beside SYSTEM-owned
|
||||
trust, replay, and result directories. The staged
|
||||
payload validates this description but does not run the provisioning script.
|
||||
|
||||
`run-request` accepts no command-line arguments. Its machine exchange is fixed at
|
||||
`C:\ProgramData\RedFlag\helper`; the installer refuses alternate data roots for this
|
||||
transport. It opens each component relative to a retained directory handle, rejects
|
||||
reparse points and hard-linked files, bounds input size, and denies concurrent write/delete
|
||||
sharing while reading. The RedFlag and Helper roots, authority file, and result directory
|
||||
must pass owner/DACL checks. A handle lock serializes dispatcher instances.
|
||||
|
||||
The Agent writes `requests\pending.json`: `version`, a fresh UUID `request_id`, an action
|
||||
(`verify-envelope` or `execute-envelope`), and the unchanged `envelope`. Unknown envelope
|
||||
fields and duplicate struct fields are refused. Installer-owned `authority.json` contains
|
||||
`version`, `target_id`, `authority_kind` (`standalone` or `fleet-server`), `authority_id`, and
|
||||
`public_keys` (Ed25519 hex). This is local trust configuration. Provisioning accepts it
|
||||
through `-AuthorityFile`, but refuses to replace an existing different authority; enrollment
|
||||
and rotation need their own explicit transaction.
|
||||
|
||||
The result is a create-new `results\<request_id>.json` containing `version`, `request_id`,
|
||||
`envelope_verified`, and the common joined `receipt`. Read-only verification reports
|
||||
`decision=verified`, `executed=false`, and zero verified backend actions. Backend execution
|
||||
currently reports `backend_not_migrated`; neither path consumes replay authorization.
|
||||
Protected results are flushed and closed before the Agent can read them. Results stay as
|
||||
local evidence; retention and timed-out request reconciliation remain follow-up work.
|
||||
|
||||
The Agent's Windows envelope executor triggers only `\RedFlag\Helper`, serializes its one
|
||||
pending slot across processes, validates the full receipt join, and leaves an unresolved
|
||||
slot intact after task-start failure, timeout, cancellation, or an invalid response. Task
|
||||
start never counts as execution success. A later call first reads any pending request and
|
||||
its protected result. A valid joined result retires the slot and is returned when the caller
|
||||
retried the same action, authorization, and manifest; a different call may proceed while the
|
||||
old result stays as evidence. Missing, malformed, or mismatched results keep the slot blocked
|
||||
with its request ID for reconciliation. Windows standalone mint explicitly refuses until
|
||||
fresh StepUp exists. New Agent service installs select the virtual
|
||||
`NT SERVICE\RedFlagAgent` account and enable its service SID. Helper provisioning grants
|
||||
that SID modify access to Agent state/logs, modify access to the untrusted request directory,
|
||||
read access to results, and no access to authority/replay state. Existing SYSTEM services
|
||||
use the explicit `-MigrateAgentService` transaction: enable the service SID, prepare ACL/task
|
||||
ownership while LocalSystem is still available, stop the service, change its account, verify
|
||||
SCM state, and restore its prior running state. Only an observed LocalSystem service is
|
||||
migrated; another operator-owned identity is refused. If the new identity cannot start, the
|
||||
script restores LocalSystem and its prior running state while retaining the prepared narrow
|
||||
ACLs for a later retry. Native transport acceptance remains open.
|
||||
|
||||
The ACL lockdown described here is the v1 cut, not the final word — Casey's call ("that'll do for now"). Revisit if a real gap in the ACE-delegation model surfaces (see `docs/tasks/SEC-030-windows-privileged-mutation-helper.md` Open Questions for what's still unresolved: WUA's COM-driven install path, rollback ownership parity with Linux's `.bak` handling).
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -16,7 +16,12 @@ RedFlag's supply chain gate inverts the traditional defense model: instead of **
|
|||
token. Standalone pacman approval uses a signed `MutationEnvelope`, exact closure archives,
|
||||
detached package signatures, helper custody, and a joined receipt. Docker, Winget, and
|
||||
Windows Update still use the signed-command path. Fleet pacman envelope delivery and kernel
|
||||
enforcement against out-of-band root mutation are not wired.
|
||||
enforcement against out-of-band root mutation are not wired. The native Windows Helper
|
||||
can verify binaries and mutation envelopes but refuses mint and backend execution. A fixed
|
||||
task transport now joins Agent requests to Helper results using handle-relative custody and
|
||||
installer-owned authority selection. It has no admitted Windows mutation backend and lacks
|
||||
native acceptance; service privilege migration and execution remain open. Verification does
|
||||
not consume replay authority. See [Helper invocation](../components/04-helper.md).
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -51,12 +56,12 @@ the unprivileged agent-side consumer. Its *role* as a runtime allow/deny RPC is
|
|||
|
||||
### Why this model (two tests it has to pass)
|
||||
|
||||
**Cross-platform.** Linux eBPF and macOS ESF can pause an exec and ask a daemon "may this
|
||||
proceed?" Windows WDAC cannot — it is signature-based, with no runtime callback. A
|
||||
decision-daemon model therefore has no Windows mapping. A capability token maps onto all
|
||||
three identically: in every case the enforcement layer only needs to answer "is this
|
||||
execution authorized," and a verified token + a privileged executor that the OS trusts is
|
||||
platform-agnostic. We build for the platform with the tightest constraint.
|
||||
**Cross-platform.** Linux eBPF and macOS ESF can observe or mediate execution at a kernel
|
||||
boundary. Windows App Control (WDAC) instead decides which code may execute from policy; it
|
||||
does not authorize one package transaction or protect every file and registry mutation that
|
||||
an already-trusted process can make. The common primitive is the signed mutation
|
||||
authorization consumed by a privileged executor. Platform enforcement narrows bypasses
|
||||
around that executor, but each platform must state exactly what its kernel mechanism covers.
|
||||
|
||||
**Protects people.** Protection comes down to where the trust root lives. The signing key
|
||||
lives at the server (the human-approval authority), off the host. An attacker who fully owns
|
||||
|
|
@ -76,12 +81,15 @@ The two tests converge on the same answer, which is the signal it's right.
|
|||
| Platform | Enforcement Mechanism | What It Blocks |
|
||||
|----------|----------------------|----------------|
|
||||
| **Linux** | eBPF (syscalls/sys_enter_execve) or AppArmor | apt, dnf, yum, pip, npm, bun, docker (CLI) |
|
||||
| **Windows** | WDAC (Windows Defender Application Control) | winget, npm.cmd, pip.exe, choco, scoop |
|
||||
| **Windows** | App Control (WDAC) for executable trust; RedFlag kernel driver for covered mutation transactions | Untrusted mutation code; covered file/registry writes when the driver exists |
|
||||
| **macOS** | Endpoint Security Framework (ESF) | brew, pip, npm, bun, cargo |
|
||||
|
||||
**Target distinction:** kernel enforcement sits below userspace wrappers. The current eBPF
|
||||
scaffold is not connected to the capability model, so RedFlag does not yet claim that an
|
||||
out-of-band package-manager invocation is blocked.
|
||||
**Target distinction:** App Control and transaction enforcement are different Windows
|
||||
layers. WDAC constrains executable trust; it does not prove that trusted code performed an
|
||||
authorized RedFlag transaction. SEC-030 owns the driver and transaction context needed to
|
||||
deny covered file and registry mutations outside a verified envelope. Neither that driver
|
||||
nor the current Linux eBPF scaffold is connected to the capability model, so RedFlag does
|
||||
not yet claim kernel-level blocking on either platform.
|
||||
|
||||
### Trust Chain
|
||||
|
||||
|
|
@ -252,9 +260,10 @@ The token extends the existing Ed25519 infrastructure rather than introducing ne
|
|||
verifies the local Arch keyring, and refuses downgrades or operation-name lies. Legacy
|
||||
registry entries without local paths are not rehashed. The current unit is not
|
||||
network-isolated.
|
||||
- **Kernel layer (where applicable).** Linux eBPF / Windows WDAC / macOS ESF deny
|
||||
package-manager execution except via the trusted executor. This is defense-in-depth design;
|
||||
the present eBPF scaffold is not wired to the capability model.
|
||||
- **Kernel layer (where applicable).** Linux eBPF and macOS ESF mediate covered execution.
|
||||
Windows WDAC constrains executable trust, while a separate RedFlag driver is required for
|
||||
covered transaction writes. This is defense-in-depth design; none of these transaction
|
||||
enforcement paths is wired to the capability model today.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -283,9 +292,10 @@ runtime evidence support them.
|
|||
rotated, replicated, or held by a federation/guild node without touching the agent↔helper
|
||||
interface. This is the long-term cross-platform answer hidden in a one-line design choice.
|
||||
5. **Kernel stops are defense-in-depth, not a prerequisite.** The capability model protects on
|
||||
a host where eBPF/WDAC/ESF cannot be deployed (locked-down managed box, constrained
|
||||
container). Kernel enforcement raises the cost of bypass; it does not gate whether the model
|
||||
means anything. Partial deployment still moves a host out of the soft-target category.
|
||||
a host where eBPF, a Windows transaction driver, or ESF cannot be deployed (locked-down
|
||||
managed box, constrained container). Kernel enforcement raises the cost of bypass; it does
|
||||
not gate whether the model means anything. Partial deployment still moves a host out of the
|
||||
soft-target category.
|
||||
6. **No doctrinal knobs.** Signing required and forward-only (no downgrade) are ETHOS doctrine,
|
||||
not configurable. The token has no "skip verification" path.
|
||||
|
||||
|
|
@ -476,8 +486,9 @@ and kernel adapters remain.
|
|||
|
||||
## Footer: Assumptions & Connections
|
||||
|
||||
**Assumption:** The capability model is the floor; kernel-level primitives (eBPF, WDAC, ESF)
|
||||
are defense-in-depth on top, not a prerequisite. Userspace wrappers alone are bypassable.
|
||||
**Assumption:** The capability model is the floor; kernel-level transaction primitives
|
||||
(eBPF, a Windows driver, ESF) are defense-in-depth on top, not a prerequisite. WDAC is a
|
||||
separate executable-trust layer. Userspace wrappers alone are bypassable.
|
||||
|
||||
**Current:** The privileged executor has a narrow argv-only API, no shell, and a stripped
|
||||
environment; the Agent that hands it capabilities is unprivileged and holds no signing key.
|
||||
|
|
@ -494,6 +505,4 @@ the helper unit. Neither property applies globally until each migrated backend p
|
|||
|
||||
---
|
||||
|
||||
*Last reviewed: 2026-09-01*
|
||||
|
||||
*Last reviewed: 2026-08-26*
|
||||
*Last reviewed: 2026-09-14*
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ type CLI struct {
|
|||
Scan bool
|
||||
Status bool
|
||||
LocalStatus bool
|
||||
VerifyEnvelope string
|
||||
InitStandalone bool
|
||||
ListUpdates bool
|
||||
Version bool
|
||||
|
|
@ -49,6 +50,7 @@ func ParseFlags() *CLI {
|
|||
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
|
||||
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
|
||||
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
|
||||
flag.StringVar(&cli.VerifyEnvelope, "verify-envelope", "", "Verify a signed mutation envelope through the installed privileged Helper and print its receipt")
|
||||
flag.BoolVar(&cli.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
|
||||
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
|
||||
flag.BoolVar(&cli.Version, "version", false, "Show version information")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -48,6 +49,16 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
// Read-only proof of the privileged Helper transport (SEC-030). It runs
|
||||
// before config load for the same reason -local-status does: driving the
|
||||
// fixed task needs the Agent's own rights, not its protected config.
|
||||
if cli.VerifyEnvelope != "" {
|
||||
if err := HandleVerifyEnvelopeCommand(cli.VerifyEnvelope); err != nil {
|
||||
log.Fatal("Envelope verification failed: ", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Windows service management commands
|
||||
if HandleWindowsServiceCommands(cli) {
|
||||
return
|
||||
|
|
@ -91,8 +102,8 @@ func main() {
|
|||
|
||||
// Handle registration command
|
||||
if cli.Register {
|
||||
if cfg.IsStandalone() {
|
||||
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
|
||||
if cfg.IsStandalone() || cfg.PendingFleetJoin != nil {
|
||||
log.Fatal("Registration refused: use RedFlag Desktop to complete the standalone fleet join")
|
||||
}
|
||||
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
|
||||
log.Fatal("Registration failed:", err)
|
||||
|
|
@ -133,6 +144,17 @@ func main() {
|
|||
}
|
||||
defer unlock()
|
||||
|
||||
// A Server may have accepted this identity before the process stopped
|
||||
// during local authority replacement. Resume that exact transaction before
|
||||
// evaluating mode; the helper is idempotent for the same Server key.
|
||||
if cfg.PendingFleetJoin != nil {
|
||||
receipt, err := handlers.CompletePendingFleetJoin(context.Background(), cfg, configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] [agent] [fleet-join] recovery_failed request_id=%s error=%v", cfg.PendingFleetJoin.RequestID, err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [fleet-join] recovery_completed request_id=%s key_id=%s", receipt.RequestID, receipt.SigningKeyID)
|
||||
}
|
||||
|
||||
// Check if registered
|
||||
if !cfg.IsRegistered() && !cfg.IsStandalone() {
|
||||
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
|
||||
|
|
@ -147,7 +169,7 @@ func main() {
|
|||
}
|
||||
|
||||
// Start agent service (console mode)
|
||||
if err := agent.RunAgentLoop(cfg); err != nil {
|
||||
if err := agent.RunAgentLoop(cfg, configPath); err != nil {
|
||||
log.Fatal("Agent failed:", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ func newCircuitBreaker(name string, cfg config.CircuitBreakerConfig) *circuitbre
|
|||
}
|
||||
|
||||
// RunAgentLoop runs the main agent polling loop
|
||||
func RunAgentLoop(cfg *config.Config) error {
|
||||
func RunAgentLoop(cfg *config.Config, configPath string) error {
|
||||
// Panic recovery for the main agent loop [TD-002]
|
||||
defer recovery.Recover("agent_main_loop")
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
loopCtx, err := NewLoopContext(cfg, LoopContextOptions{
|
||||
Ctx: context.Background(),
|
||||
EnableDesktop: true,
|
||||
ConfigPath: configPath,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -89,6 +91,7 @@ type LoopContextOptions struct {
|
|||
Ctx context.Context
|
||||
StopCh <-chan struct{}
|
||||
EnableDesktop bool
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// NewLoopContext builds the canonical dependency graph for the agent polling
|
||||
|
|
@ -98,6 +101,9 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
|
|||
if opts.Ctx == nil {
|
||||
opts.Ctx = context.Background()
|
||||
}
|
||||
if opts.ConfigPath == "" {
|
||||
opts.ConfigPath = constants.GetAgentConfigPath()
|
||||
}
|
||||
|
||||
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
|
||||
|
||||
|
|
@ -284,6 +290,7 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
|
|||
TeeLogger: teeLogger,
|
||||
EventBuffer: teeLogger.Buffer(),
|
||||
StopCh: opts.StopCh,
|
||||
ConfigPath: opts.ConfigPath,
|
||||
CircuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
|
||||
"apt": aptCB,
|
||||
"dnf": dnfCB,
|
||||
|
|
@ -314,9 +321,10 @@ type LoopContext struct {
|
|||
EventBuffer *event.Buffer
|
||||
Ctx context.Context
|
||||
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
|
||||
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error, modeChanged <-chan struct{}) error {
|
||||
recordLocalAgentStatus(ctx.Cfg, "standalone", false)
|
||||
log.Printf("[INFO] [agent] [standalone] local_mode_started agent_id=%s", ctx.Cfg.AgentID)
|
||||
if err := triggerScan("standalone-startup"); err != nil {
|
||||
|
|
@ -335,6 +343,9 @@ func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
|
|||
return nil
|
||||
case <-ctx.StopCh:
|
||||
return nil
|
||||
case <-modeChanged:
|
||||
log.Printf("[INFO] [agent] [fleet-join] standalone_loop_stopped restart_required=true")
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := triggerScan("standalone-interval"); err != nil && !errors.Is(err, localapi.ErrScanInFlight) {
|
||||
ctx.TeeLogger.Warning("agent", "standalone", "scan", "periodic_scan_not_started", map[string]interface{}{"error": err.Error()})
|
||||
|
|
@ -351,6 +362,15 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
defer recovery.Recover("agent_polling_loop")
|
||||
|
||||
ctx := loopCtx
|
||||
modeChanged := make(chan struct{})
|
||||
var modeChangeOnce sync.Once
|
||||
var fleetJoinInFlight atomic.Bool
|
||||
var fleetJoinAccepted atomic.Bool
|
||||
fleetJoinRequestID := ctx.Cfg.FleetJoinRequestID
|
||||
stopStandaloneForFleet := func() {
|
||||
fleetJoinAccepted.Store(true)
|
||||
time.AfterFunc(750*time.Millisecond, func() { modeChangeOnce.Do(func() { close(modeChanged) }) })
|
||||
}
|
||||
|
||||
// FEAT-002 write path: single-flight scan trigger for the local API.
|
||||
// Authorization is the socket/pipe group ACL; the scan itself runs through
|
||||
|
|
@ -379,6 +399,9 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
// typed failures into localapi sentinels for honest HTTP codes.
|
||||
var approveInFlight atomic.Bool
|
||||
approveUpdate := func(body []byte) (interface{}, error) {
|
||||
if fleetJoinInFlight.Load() || fleetJoinAccepted.Load() {
|
||||
return nil, fmt.Errorf("%w: fleet authority transition is in progress", localapi.ErrApprovalConflict)
|
||||
}
|
||||
if !approveInFlight.CompareAndSwap(false, true) {
|
||||
return nil, fmt.Errorf("%w: approval already in flight", localapi.ErrApprovalConflict)
|
||||
}
|
||||
|
|
@ -415,6 +438,53 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}
|
||||
}
|
||||
|
||||
prepareFleetJoin := func() (interface{}, error) {
|
||||
if fleetJoinInFlight.Load() || fleetJoinAccepted.Load() {
|
||||
return nil, fmt.Errorf("%w: fleet join is already in progress", localapi.ErrFleetJoinConflict)
|
||||
}
|
||||
result, err := handlers.PrepareLocalFleetJoin(ctx.Cfg)
|
||||
switch {
|
||||
case err == nil:
|
||||
return result, nil
|
||||
case errors.Is(err, handlers.ErrFleetJoinMode):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
|
||||
case errors.Is(err, handlers.ErrFleetJoinUnavailable):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinUnavailable, err)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
joinFleet := func(body []byte) (interface{}, error) {
|
||||
if fleetJoinAccepted.Load() || !fleetJoinInFlight.CompareAndSwap(false, true) {
|
||||
return nil, fmt.Errorf("%w: fleet join is already in progress", localapi.ErrFleetJoinConflict)
|
||||
}
|
||||
defer fleetJoinInFlight.Store(false)
|
||||
var req handlers.LocalFleetJoinRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, fmt.Errorf("invalid fleet join request: %w", err)
|
||||
}
|
||||
next := *ctx.Cfg
|
||||
next.FleetJoinRequestID = fleetJoinRequestID
|
||||
result, err := handlers.HandleLocalFleetJoin(ctx.Ctx, &next, ctx.ConfigPath, req)
|
||||
fleetJoinRequestID = next.FleetJoinRequestID
|
||||
switch {
|
||||
case err == nil:
|
||||
stopStandaloneForFleet()
|
||||
return result, nil
|
||||
case errors.Is(err, handlers.ErrFleetJoinAccepted):
|
||||
stopStandaloneForFleet()
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
|
||||
case errors.Is(err, handlers.ErrFleetJoinMode):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
|
||||
case errors.Is(err, handlers.ErrFleetJoinUnavailable),
|
||||
errors.Is(err, supplychain.ErrFleetAuthorityUnavailable):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinUnavailable, err)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var onDesktopHealth func(version string, windowOpen bool)
|
||||
if ctx.DesktopManager != nil {
|
||||
onDesktopHealth = ctx.DesktopManager.RecordHealth
|
||||
|
|
@ -447,14 +517,16 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
return response, nil
|
||||
}
|
||||
localAPIServer, err := localapi.Start(localapi.Options{
|
||||
Config: ctx.Cfg,
|
||||
DesktopProvider: ctx.DesktopManager,
|
||||
TriggerScan: triggerScan,
|
||||
ScanRunning: scanInFlight.Load,
|
||||
ApproveUpdate: approveUpdate,
|
||||
OnDesktopHealth: onDesktopHealth,
|
||||
DockerProvider: dockerProvider,
|
||||
EventsProvider: ctx.EventBuffer.ReadHistory,
|
||||
Config: ctx.Cfg,
|
||||
DesktopProvider: ctx.DesktopManager,
|
||||
TriggerScan: triggerScan,
|
||||
ScanRunning: scanInFlight.Load,
|
||||
ApproveUpdate: approveUpdate,
|
||||
PrepareFleetJoin: prepareFleetJoin,
|
||||
JoinFleet: joinFleet,
|
||||
OnDesktopHealth: onDesktopHealth,
|
||||
DockerProvider: dockerProvider,
|
||||
EventsProvider: ctx.EventBuffer.ReadHistory,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
||||
|
|
@ -483,7 +555,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}
|
||||
|
||||
if ctx.Cfg.IsStandalone() {
|
||||
return runStandaloneLoop(ctx, triggerScan)
|
||||
return runStandaloneLoop(ctx, triggerScan, modeChanged)
|
||||
}
|
||||
|
||||
consecutiveFailures := 0
|
||||
|
|
|
|||
|
|
@ -338,6 +338,38 @@ type RegisterResponse struct {
|
|||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// FleetJoinRequest preserves a standalone Agent's UUID while registering that
|
||||
// same machine with a Server. The seed stays on the host; only its current TOTP
|
||||
// code crosses this endpoint.
|
||||
type FleetJoinRequest struct {
|
||||
RequestID uuid.UUID `json:"request_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
RegistrationToken string `json:"registration_token"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
Hostname string `json:"hostname"`
|
||||
OSType string `json:"os_type"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
MachineID string `json:"machine_id"`
|
||||
PublicKeyFingerprint string `json:"public_key_fingerprint"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
AvailableScanners []string `json:"available_scanners"`
|
||||
DeviceType string `json:"device_type"`
|
||||
DeviceModel string `json:"device_model"`
|
||||
OSDistro string `json:"os_distro"`
|
||||
}
|
||||
|
||||
type FleetJoinResponse struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
SigningPublicKey string `json:"signing_public_key"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// Register registers the agent with the server
|
||||
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
|
||||
|
|
@ -388,6 +420,36 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
|||
return &result, nil
|
||||
}
|
||||
|
||||
// JoinFleet calls the dedicated one-way standalone transition endpoint. It
|
||||
// does not mutate this Client's token because the caller must first persist the
|
||||
// returned credentials and replace local helper authority.
|
||||
func (c *Client) JoinFleet(req FleetJoinRequest) (*FleetJoinResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/fleet-join", c.baseURL)
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode fleet join request: %w", err)
|
||||
}
|
||||
httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create fleet join request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fleet join request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return nil, fmt.Errorf("fleet join failed: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
var result FleetJoinResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode fleet join response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// TokenRenewalRequest is the payload for token renewal using refresh token
|
||||
type TokenRenewalRequest struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
|
|
@ -1072,7 +1134,7 @@ type SystemInfoReport struct {
|
|||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Processes int `json:"processes,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
DeviceType string `json:"device_type,omitempty"` // Re-reported so reinstalls reclassify (DEVICE-001)
|
||||
DeviceType string `json:"device_type,omitempty"` // Re-reported so reinstalls reclassify (DEVICE-001)
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
OSDistro string `json:"os_distro,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
|
|
|||
|
|
@ -182,6 +182,13 @@ type Config struct {
|
|||
// Desktop App Configuration
|
||||
Desktop DesktopConfig `json:"desktop,omitempty"`
|
||||
|
||||
// Standalone to fleet transition. Non-nil means the Server has accepted
|
||||
// this identity but local authority replacement has not fully converged.
|
||||
PendingFleetJoin *PendingFleetJoin `json:"pending_fleet_join,omitempty"`
|
||||
// Stable across retries so a lost Server response cannot turn one join into
|
||||
// a second credential-minting request.
|
||||
FleetJoinRequestID string `json:"fleet_join_request_id,omitempty"`
|
||||
|
||||
// Process Explorer Configuration
|
||||
ProcessExplorer ProcessExplorerConfig `json:"process_explorer,omitempty"`
|
||||
|
||||
|
|
@ -199,6 +206,20 @@ type DesktopConfig struct {
|
|||
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
|
||||
}
|
||||
|
||||
// PendingFleetJoin is persisted only after the fleet server has accepted this
|
||||
// standalone identity. It is the crash-recovery bridge between remote
|
||||
// registration and the privileged helper replacing local authority.
|
||||
type PendingFleetJoin struct {
|
||||
RequestID string `json:"request_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
SigningPublicKey string `json:"signing_public_key"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
CheckInInterval int `json:"check_in_interval,omitempty"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
}
|
||||
|
||||
// Load reads configuration from multiple sources with priority order:
|
||||
// 1. CLI flags
|
||||
// 2. Environment variables
|
||||
|
|
@ -776,21 +797,21 @@ func (c *Config) SetDegradedMode(enabled bool) error {
|
|||
|
||||
// IsRegistered checks if the agent is registered
|
||||
func (c *Config) IsRegistered() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token != ""
|
||||
return c.AgentID != uuid.Nil && c.Token != "" && c.RefreshToken != "" && c.PendingFleetJoin == nil
|
||||
}
|
||||
|
||||
// IsStandalone reports whether this config has a stable local identity and no
|
||||
// fleet credential. A partial fleet enrollment is not standalone: refresh or
|
||||
// registration material must never silently become local mutation authority.
|
||||
func (c *Config) IsStandalone() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
|
||||
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == "" && c.PendingFleetJoin == nil
|
||||
}
|
||||
|
||||
// InitializeStandalone gives a local-only Agent one durable UUID. It is
|
||||
// idempotent, but refuses any fleet credential so provisioning cannot convert a
|
||||
// fleet host into local authority by accident.
|
||||
func (c *Config) InitializeStandalone() error {
|
||||
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
|
||||
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" || c.PendingFleetJoin != nil {
|
||||
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
|
||||
}
|
||||
if c.AgentID != uuid.Nil {
|
||||
|
|
@ -930,6 +951,13 @@ func mergeConfigPreservingDefaults(target, source *Config) {
|
|||
if source.Desktop != (DesktopConfig{}) {
|
||||
target.Desktop = source.Desktop
|
||||
}
|
||||
if source.PendingFleetJoin != nil {
|
||||
pending := *source.PendingFleetJoin
|
||||
target.PendingFleetJoin = &pending
|
||||
}
|
||||
if source.FleetJoinRequestID != "" {
|
||||
target.FleetJoinRequestID = source.FleetJoinRequestID
|
||||
}
|
||||
|
||||
// Version info
|
||||
if source.Version != "" {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ package crypto
|
|||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -156,6 +159,60 @@ func saveCacheMetadata(meta *CacheMetadata) error {
|
|||
return os.WriteFile(getPrimaryMetaPath(), data, 0644)
|
||||
}
|
||||
|
||||
// CacheFleetServerPublicKey installs the exact key returned by the fleet-join
|
||||
// transaction. This bypasses TOFU cache reuse: a standalone host may have an
|
||||
// unrelated stale cache, and that must not outrank the authority just adopted
|
||||
// by the privileged helper.
|
||||
func CacheFleetServerPublicKey(publicKeyHex, keyID string) error {
|
||||
raw, err := hex.DecodeString(publicKeyHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode joined Server public key: %w", err)
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("joined Server public key is %d bytes, want %d", len(raw), ed25519.PublicKeySize)
|
||||
}
|
||||
digest := sha256.Sum256(raw)
|
||||
computedID := hex.EncodeToString(digest[:16])
|
||||
if keyID == "" || keyID != computedID {
|
||||
return fmt.Errorf("joined Server key id mismatch: got %q want %q", keyID, computedID)
|
||||
}
|
||||
if err := removeCachedKeysExcept(keyID); err != nil {
|
||||
return fmt.Errorf("retire pre-join Server key cache: %w", err)
|
||||
}
|
||||
key := ed25519.PublicKey(raw)
|
||||
if err := cachePublicKey(key); err != nil {
|
||||
return fmt.Errorf("cache joined Server primary key: %w", err)
|
||||
}
|
||||
if err := CachePublicKeyByID(keyID, key); err != nil {
|
||||
return fmt.Errorf("cache joined Server key id: %w", err)
|
||||
}
|
||||
return saveCacheMetadata(&CacheMetadata{
|
||||
KeyID: keyID, Version: 1, CachedAt: time.Now().UTC(), TTLHours: defaultCacheTTLHours,
|
||||
})
|
||||
}
|
||||
|
||||
func removeCachedKeysExcept(keyID string) error {
|
||||
dir := getPublicKeyDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keep := filepath.Base(getKeyPathByID(keyID))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "server_public_key_") || name == keep {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchAndCacheServerPublicKey fetches the server's Ed25519 primary public key.
|
||||
// Uses a TTL+key_id cache: skips the fetch only if both TTL is valid AND key_id matches.
|
||||
// Implements Trust-On-First-Use (TOFU) with rotation awareness.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet", RefreshToken: "refresh"}, LocalApproveRequest{
|
||||
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
|
||||
})
|
||||
if !errors.Is(err, ErrApprovalFleetMode) {
|
||||
|
|
|
|||
|
|
@ -71,11 +71,15 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
|
|||
"desktop binary not found at %s — local operations console will not appear", desktopPath)
|
||||
}
|
||||
|
||||
// --- Autostart entry (Linux only) ---
|
||||
// --- Desktop session launchers (Linux only) ---
|
||||
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
|
||||
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
|
||||
check(fileExists(autostartPath),
|
||||
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
|
||||
|
||||
compositorUnitPath := "/etc/systemd/user/redflag-desktop-compositor.service"
|
||||
check(fileExists(compositorUnitPath),
|
||||
"desktop compositor session launcher not found at %s — RedFlag Desktop will not start in Hyprland/Sway/River sessions", compositorUnitPath)
|
||||
}
|
||||
|
||||
// --- Socket directory permissions ---
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ var (
|
|||
ErrApprovalConflict = errors.New("localapi: approval conflict")
|
||||
// ErrApprovalUnavailable → 503: no local authority on this host.
|
||||
ErrApprovalUnavailable = errors.New("localapi: approval unavailable")
|
||||
// ErrFleetJoinConflict maps mode, validation, and in-flight joins to 409.
|
||||
ErrFleetJoinConflict = errors.New("localapi: fleet join conflict")
|
||||
// ErrFleetJoinUnavailable maps a missing platform/helper path to 503.
|
||||
ErrFleetJoinUnavailable = errors.New("localapi: fleet join unavailable")
|
||||
)
|
||||
|
||||
// Options configures the local read-only API. Group, socket, and pipe defaults
|
||||
|
|
@ -75,6 +79,11 @@ type Options struct {
|
|||
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
|
||||
// ErrApprovalUnavailable to control the HTTP status.
|
||||
ApproveUpdate func(body []byte) (interface{}, error)
|
||||
// PrepareFleetJoin creates the host-held proof seed. JoinFleet consumes the
|
||||
// Server URL, one-seat join token, and that seed through the one-way helper
|
||||
// transition. Both are disabled outside standalone mode.
|
||||
PrepareFleetJoin func() (interface{}, error)
|
||||
JoinFleet func(body []byte) (interface{}, error)
|
||||
// OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
|
||||
// agent can track app liveness/version — on Linux Desktop is autostart-
|
||||
// launched and this is the only signal. Nil means reports are logged only.
|
||||
|
|
@ -159,24 +168,26 @@ func (s *Server) Stop() {
|
|||
}
|
||||
|
||||
type handler struct {
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
desktop DesktopStatusProvider
|
||||
triggerScan func(source string) error
|
||||
approveUpdate func(body []byte) (interface{}, error)
|
||||
onDesktopHealth func(version string, windowOpen bool)
|
||||
systemInfo func() (*system.SystemInfo, error)
|
||||
topProcesses func(limit int) ([]system.TopProcess, error)
|
||||
monitorSnapshot func() (*system.ResourceSnapshot, error)
|
||||
processes func() (*system.FullProcessSnapshot, error)
|
||||
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
software func() (*system.SoftwareSnapshot, error)
|
||||
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
|
||||
connections func() (*system.ConnectionSnapshot, error)
|
||||
services func() (*system.ServiceSnapshot, error)
|
||||
docker func() (*DockerResponse, error)
|
||||
events func() ([]*models.SystemEvent, error)
|
||||
scanRunning func() bool
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
desktop DesktopStatusProvider
|
||||
triggerScan func(source string) error
|
||||
approveUpdate func(body []byte) (interface{}, error)
|
||||
prepareFleetJoin func() (interface{}, error)
|
||||
joinFleet func(body []byte) (interface{}, error)
|
||||
onDesktopHealth func(version string, windowOpen bool)
|
||||
systemInfo func() (*system.SystemInfo, error)
|
||||
topProcesses func(limit int) ([]system.TopProcess, error)
|
||||
monitorSnapshot func() (*system.ResourceSnapshot, error)
|
||||
processes func() (*system.FullProcessSnapshot, error)
|
||||
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
software func() (*system.SoftwareSnapshot, error)
|
||||
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
|
||||
connections func() (*system.ConnectionSnapshot, error)
|
||||
services func() (*system.ServiceSnapshot, error)
|
||||
docker func() (*DockerResponse, error)
|
||||
events func() ([]*models.SystemEvent, error)
|
||||
scanRunning func() bool
|
||||
}
|
||||
|
||||
// DesktopStatusProvider allows the desktop manager to report its status.
|
||||
|
|
@ -196,6 +207,7 @@ type IdentityResponse struct {
|
|||
ConfigVersion string `json:"config_version,omitempty"`
|
||||
CheckInInterval int `json:"check_in_interval"`
|
||||
Registered bool `json:"registered"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
|
|
@ -277,24 +289,26 @@ type SecurityResponse struct {
|
|||
|
||||
func newHandler(opts Options) http.Handler {
|
||||
h := &handler{
|
||||
cfg: opts.Config,
|
||||
loadCache: opts.LoadCache,
|
||||
desktop: opts.DesktopProvider,
|
||||
triggerScan: opts.TriggerScan,
|
||||
approveUpdate: opts.ApproveUpdate,
|
||||
onDesktopHealth: opts.OnDesktopHealth,
|
||||
systemInfo: opts.SystemProvider,
|
||||
topProcesses: opts.ProcessProvider,
|
||||
monitorSnapshot: opts.MonitorProvider,
|
||||
processes: opts.ProcessesProvider,
|
||||
processDetail: opts.ProcessDetailProvider,
|
||||
software: opts.SoftwareProvider,
|
||||
packageDetail: opts.PackageDetailProvider,
|
||||
connections: opts.ConnectionsProvider,
|
||||
services: opts.ServicesProvider,
|
||||
docker: opts.DockerProvider,
|
||||
events: opts.EventsProvider,
|
||||
scanRunning: opts.ScanRunning,
|
||||
cfg: opts.Config,
|
||||
loadCache: opts.LoadCache,
|
||||
desktop: opts.DesktopProvider,
|
||||
triggerScan: opts.TriggerScan,
|
||||
approveUpdate: opts.ApproveUpdate,
|
||||
prepareFleetJoin: opts.PrepareFleetJoin,
|
||||
joinFleet: opts.JoinFleet,
|
||||
onDesktopHealth: opts.OnDesktopHealth,
|
||||
systemInfo: opts.SystemProvider,
|
||||
topProcesses: opts.ProcessProvider,
|
||||
monitorSnapshot: opts.MonitorProvider,
|
||||
processes: opts.ProcessesProvider,
|
||||
processDetail: opts.ProcessDetailProvider,
|
||||
software: opts.SoftwareProvider,
|
||||
packageDetail: opts.PackageDetailProvider,
|
||||
connections: opts.ConnectionsProvider,
|
||||
services: opts.ServicesProvider,
|
||||
docker: opts.DockerProvider,
|
||||
events: opts.EventsProvider,
|
||||
scanRunning: opts.ScanRunning,
|
||||
}
|
||||
if h.systemInfo == nil {
|
||||
h.systemInfo = func() (*system.SystemInfo, error) {
|
||||
|
|
@ -345,6 +359,8 @@ func newHandler(opts Options) http.Handler {
|
|||
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
||||
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
||||
mux.HandleFunc("/v1/actions/approve-update", h.approveUpdateAction)
|
||||
mux.HandleFunc("/v1/actions/prepare-fleet-join", h.prepareFleetJoinAction)
|
||||
mux.HandleFunc("/v1/actions/join-fleet", h.joinFleetAction)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
|
@ -566,6 +582,14 @@ func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
|
|||
log.Printf("[WARNING] [agent] [localapi] hostname_failed error=%v", err)
|
||||
}
|
||||
|
||||
mode := "incomplete"
|
||||
if h.cfg.IsRegistered() {
|
||||
mode = "fleet"
|
||||
} else if h.cfg.IsStandalone() {
|
||||
mode = "standalone"
|
||||
} else if h.cfg.PendingFleetJoin != nil {
|
||||
mode = "joining"
|
||||
}
|
||||
resp := IdentityResponse{
|
||||
AgentID: h.cfg.AgentID.String(),
|
||||
ServerURL: h.cfg.ServerURL,
|
||||
|
|
@ -578,6 +602,7 @@ func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
|
|||
ConfigVersion: h.cfg.Version,
|
||||
CheckInInterval: h.cfg.CheckInInterval,
|
||||
Registered: h.cfg.IsRegistered(),
|
||||
Mode: mode,
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
|
@ -762,6 +787,69 @@ func (h *handler) approveUpdateAction(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *handler) prepareFleetJoinAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.prepareFleetJoin == nil {
|
||||
http.Error(w, "fleet join unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
result, err := h.prepareFleetJoin()
|
||||
switch {
|
||||
case err == nil:
|
||||
writeJSON(w, result)
|
||||
case errors.Is(err, ErrFleetJoinConflict):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
case errors.Is(err, ErrFleetJoinUnavailable):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("[ERROR] [agent] [localapi] fleet_join_prepare_failed error=%v", err)
|
||||
http.Error(w, "fleet join preparation failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) joinFleetAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.joinFleet == nil {
|
||||
http.Error(w, "fleet join unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "request read failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := h.joinFleet(body)
|
||||
switch {
|
||||
case err == nil:
|
||||
writeJSON(w, result)
|
||||
case errors.Is(err, ErrFleetJoinConflict):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
case errors.Is(err, ErrFleetJoinUnavailable):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("[ERROR] [agent] [localapi] fleet_join_failed error=%v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
|
||||
localCache, err := h.loadCache()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -13,93 +13,18 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// RegisterAgent registers the agent with the server
|
||||
func RegisterAgent(cfg *config.Config, serverURL string) error {
|
||||
// Get detailed system information
|
||||
sysInfo, err := system.GetSystemInfo(version.Version)
|
||||
req, err := collectRegistrationRequest()
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
|
||||
// Fall back to basic detection
|
||||
hostname, _ := os.Hostname()
|
||||
osType, osVersion, osArch := client.DetectSystem()
|
||||
sysInfo = &system.SystemInfo{
|
||||
Hostname: hostname,
|
||||
OSType: osType,
|
||||
OSVersion: osVersion,
|
||||
OSArchitecture: osArch,
|
||||
AgentVersion: version.Version,
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Use registration token from config if available
|
||||
apiClient := client.NewClient(serverURL, cfg.RegistrationToken)
|
||||
|
||||
// Create metadata with system information
|
||||
metadata := map[string]string{
|
||||
"installation_time": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Add system info to metadata
|
||||
if sysInfo.CPUInfo.ModelName != "" {
|
||||
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
|
||||
}
|
||||
if sysInfo.CPUInfo.Cores > 0 {
|
||||
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
|
||||
}
|
||||
if sysInfo.MemoryInfo.Total > 0 {
|
||||
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
|
||||
}
|
||||
if sysInfo.RunningProcesses > 0 {
|
||||
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
|
||||
}
|
||||
if sysInfo.Uptime != "" {
|
||||
metadata["uptime"] = sysInfo.Uptime
|
||||
}
|
||||
|
||||
// Add disk information
|
||||
for i, disk := range sysInfo.DiskInfo {
|
||||
if i == 0 {
|
||||
metadata["disk_mount"] = disk.Mountpoint
|
||||
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
|
||||
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
|
||||
break // Only add primary disk info
|
||||
}
|
||||
}
|
||||
|
||||
// Get machine ID for binding
|
||||
machineID, err := system.GetMachineID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("machine_id_unavailable: %w - cannot register without consistent machine ID", err)
|
||||
}
|
||||
|
||||
// Get embedded public key fingerprint
|
||||
publicKeyFingerprint := system.GetPublicKeyFingerprint()
|
||||
if publicKeyFingerprint == "" {
|
||||
log.Printf("Warning: No embedded public key fingerprint found")
|
||||
}
|
||||
|
||||
// Detect available scanners for platform-specific subsystem creation
|
||||
availableScanners := scanner.DetectAvailable()
|
||||
log.Printf("[INFO] [agent] [registration] detected_scanners=%v", availableScanners)
|
||||
|
||||
req := client.RegisterRequest{
|
||||
Hostname: sysInfo.Hostname,
|
||||
OSType: sysInfo.OSType,
|
||||
OSVersion: sysInfo.OSVersion,
|
||||
OSArchitecture: sysInfo.OSArchitecture,
|
||||
AgentVersion: sysInfo.AgentVersion,
|
||||
MachineID: machineID,
|
||||
PublicKeyFingerprint: publicKeyFingerprint,
|
||||
Metadata: metadata,
|
||||
AvailableScanners: availableScanners,
|
||||
DeviceType: sysInfo.DeviceType,
|
||||
DeviceModel: sysInfo.DeviceModel,
|
||||
OSDistro: sysInfo.OSDistro,
|
||||
}
|
||||
|
||||
resp, err := apiClient.Register(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -136,9 +61,121 @@ func RegisterAgent(cfg *config.Config, serverURL string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func collectRegistrationRequest() (client.RegisterRequest, error) {
|
||||
// Get detailed system information
|
||||
sysInfo, err := system.GetSystemInfo(version.Version)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
|
||||
// Fall back to basic detection
|
||||
hostname, _ := os.Hostname()
|
||||
osType, osVersion, osArch := client.DetectSystem()
|
||||
sysInfo = &system.SystemInfo{
|
||||
Hostname: hostname,
|
||||
OSType: osType,
|
||||
OSVersion: osVersion,
|
||||
OSArchitecture: osArch,
|
||||
AgentVersion: version.Version,
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Create metadata with system information
|
||||
metadata := map[string]string{
|
||||
"installation_time": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Add system info to metadata
|
||||
if sysInfo.CPUInfo.ModelName != "" {
|
||||
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
|
||||
}
|
||||
if sysInfo.CPUInfo.Cores > 0 {
|
||||
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
|
||||
}
|
||||
if sysInfo.MemoryInfo.Total > 0 {
|
||||
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
|
||||
}
|
||||
if sysInfo.RunningProcesses > 0 {
|
||||
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
|
||||
}
|
||||
if sysInfo.Uptime != "" {
|
||||
metadata["uptime"] = sysInfo.Uptime
|
||||
}
|
||||
|
||||
// Add disk information
|
||||
for i, disk := range sysInfo.DiskInfo {
|
||||
if i == 0 {
|
||||
metadata["disk_mount"] = disk.Mountpoint
|
||||
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
|
||||
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
|
||||
break // Only add primary disk info
|
||||
}
|
||||
}
|
||||
|
||||
// Get machine ID for binding
|
||||
machineID, err := system.GetMachineID()
|
||||
if err != nil {
|
||||
return client.RegisterRequest{}, fmt.Errorf("machine_id_unavailable: %w - cannot register without consistent machine ID", err)
|
||||
}
|
||||
|
||||
// Get embedded public key fingerprint
|
||||
publicKeyFingerprint := system.GetPublicKeyFingerprint()
|
||||
if publicKeyFingerprint == "" {
|
||||
log.Printf("Warning: No embedded public key fingerprint found")
|
||||
}
|
||||
|
||||
// Detect available scanners for platform-specific subsystem creation
|
||||
availableScanners := scanner.DetectAvailable()
|
||||
log.Printf("[INFO] [agent] [registration] detected_scanners=%v", availableScanners)
|
||||
|
||||
return client.RegisterRequest{
|
||||
Hostname: sysInfo.Hostname,
|
||||
OSType: sysInfo.OSType,
|
||||
OSVersion: sysInfo.OSVersion,
|
||||
OSArchitecture: sysInfo.OSArchitecture,
|
||||
AgentVersion: sysInfo.AgentVersion,
|
||||
MachineID: machineID,
|
||||
PublicKeyFingerprint: publicKeyFingerprint,
|
||||
Metadata: metadata,
|
||||
AvailableScanners: availableScanners,
|
||||
DeviceType: sysInfo.DeviceType,
|
||||
DeviceModel: sysInfo.DeviceModel,
|
||||
OSDistro: sysInfo.OSDistro,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JoinFleet registers an existing standalone identity with the fleet Server.
|
||||
// It returns credentials without persisting them; the caller must first finish
|
||||
// the privileged authority replacement transaction.
|
||||
func JoinFleet(cfg *config.Config, requestID, serverURL, registrationToken, seed string) (*client.FleetJoinResponse, error) {
|
||||
if cfg == nil || !cfg.IsStandalone() {
|
||||
return nil, fmt.Errorf("fleet join requires a standalone Agent identity")
|
||||
}
|
||||
requestUUID, err := uuid.FromString(requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fleet join request id is invalid: %w", err)
|
||||
}
|
||||
base, err := collectRegistrationRequest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := fleetJoinTOTPCode(seed, time.Now().UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := client.FleetJoinRequest{
|
||||
RequestID: requestUUID, AgentID: cfg.AgentID,
|
||||
RegistrationToken: registrationToken, TOTPCode: code,
|
||||
Hostname: base.Hostname, OSType: base.OSType, OSVersion: base.OSVersion,
|
||||
OSArchitecture: base.OSArchitecture, AgentVersion: base.AgentVersion,
|
||||
MachineID: base.MachineID, PublicKeyFingerprint: base.PublicKeyFingerprint,
|
||||
Metadata: base.Metadata, AvailableScanners: base.AvailableScanners,
|
||||
DeviceType: base.DeviceType, DeviceModel: base.DeviceModel, OSDistro: base.OSDistro,
|
||||
}
|
||||
return client.NewClient(serverURL, "").JoinFleet(req)
|
||||
}
|
||||
|
||||
// FetchAndCachePublicKey fetches the server's Ed25519 public key and caches it locally
|
||||
func FetchAndCachePublicKey(serverURL string) error {
|
||||
_, err := crypto.FetchAndCacheServerPublicKey(serverURL)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/agent"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/recovery"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/debug"
|
||||
"golang.org/x/sys/windows/svc/eventlog"
|
||||
|
|
@ -188,10 +189,12 @@ func InstallService() error {
|
|||
|
||||
// Create service with proper configuration
|
||||
s, err = m.CreateService(serviceName, exePath, mgr.Config{
|
||||
DisplayName: "RedFlag Update Agent",
|
||||
Description: "RedFlag agent for automated system updates and monitoring",
|
||||
StartType: mgr.StartAutomatic,
|
||||
Dependencies: []string{"Tcpip", "Dnscache"},
|
||||
DisplayName: "RedFlag Update Agent",
|
||||
Description: "RedFlag agent for system observation and authorized mutation requests",
|
||||
StartType: mgr.StartAutomatic,
|
||||
Dependencies: []string{"Tcpip", "Dnscache"},
|
||||
ServiceStartName: `NT SERVICE\RedFlagAgent`,
|
||||
SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create service: %w", err)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/capability"
|
||||
|
|
@ -68,6 +69,9 @@ func (e *Executor) MintEnvelope(
|
|||
manifest capability.MutationManifest,
|
||||
gateEvidence GateEvidence,
|
||||
) (*capability.MutationEnvelope, string, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil, "", fmt.Errorf("Windows local mint requires authenticated StepUp; mint authority is unavailable")
|
||||
}
|
||||
requestID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("generate envelope request id: %w", err)
|
||||
|
|
@ -144,6 +148,24 @@ func (e *Executor) MintEnvelope(
|
|||
return &envelope, request.RequestID, nil
|
||||
}
|
||||
|
||||
// VerifyEnvelope drives the installed privileged verifier over the platform's
|
||||
// own transport and returns its joined receipt. Verification only: it mints
|
||||
// nothing, runs no backend, and consumes no execution authorization. SEC-030
|
||||
// requires read-only proof of the Windows transport before a backend can be
|
||||
// admitted, and this is the Agent's one way to ask for it.
|
||||
func (e *Executor) VerifyEnvelope(
|
||||
ctx context.Context,
|
||||
envelope *capability.MutationEnvelope,
|
||||
) (*capability.MutationReceipt, error) {
|
||||
if envelope == nil {
|
||||
return nil, fmt.Errorf("mutation envelope is nil")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil, fmt.Errorf("envelope verification transport is implemented on Windows only")
|
||||
}
|
||||
return verifyWindowsEnvelope(ctx, envelope)
|
||||
}
|
||||
|
||||
// ExecuteEnvelope hands one signed envelope to the privileged verifier and
|
||||
// returns its joined receipt, including denials and failed package execution.
|
||||
func (e *Executor) ExecuteEnvelope(
|
||||
|
|
@ -153,6 +175,9 @@ func (e *Executor) ExecuteEnvelope(
|
|||
if envelope == nil {
|
||||
return nil, fmt.Errorf("mutation envelope is nil")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return executeWindowsEnvelope(ctx, envelope)
|
||||
}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal mutation envelope: %w", err)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ ApplicationWindow {
|
|||
property string pendingProcessQuery: ""
|
||||
property var approval: parse(machine.approvalJson, {
|
||||
})
|
||||
property var fleetJoin: parse(machine.joinJson, {
|
||||
})
|
||||
// The capabilities that turn "a process" into "a process that can rewrite the
|
||||
// machine". Tinted so a wall of chips still reads at a glance.
|
||||
readonly property var sharpCapabilities: ["CAP_SYS_ADMIN", "CAP_SYS_MODULE", "CAP_SYS_RAWIO", "CAP_SYS_PTRACE", "CAP_SYS_BOOT", "CAP_BPF", "CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_DAC_READ_SEARCH", "CAP_SETUID", "CAP_SETGID"]
|
||||
|
|
@ -1781,7 +1783,6 @@ ApplicationWindow {
|
|||
font.pixelSize: Theme.fontMeta
|
||||
Layout.preferredWidth: 100
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -3004,6 +3005,243 @@ ApplicationWindow {
|
|||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
id: fleetJoinCard
|
||||
|
||||
visible: machine.enrollment === "standalone" || machine.enrollment === "joining fleet"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: visible ? fleetJoinColumn.implicitHeight + 30 : 0
|
||||
color: Theme.surface
|
||||
radius: Theme.radius
|
||||
border.width: 1
|
||||
border.color: machine.enrollment === "joining fleet" ? Theme.warn : Theme.divider
|
||||
|
||||
ColumnLayout {
|
||||
id: fleetJoinColumn
|
||||
|
||||
anchors.left: parent.left
|
||||
anchors.right: parent.right
|
||||
anchors.top: parent.top
|
||||
anchors.margins: 15
|
||||
spacing: 10
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
ColumnLayout {
|
||||
spacing: 2
|
||||
|
||||
Text {
|
||||
text: "JOIN THIS MACHINE TO A FLEET"
|
||||
color: Theme.text
|
||||
font.pixelSize: 11
|
||||
font.weight: 750
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "Keep this machine's identity. Replace its local signing authority with the fleet Server key."
|
||||
color: Theme.dim
|
||||
font.pixelSize: 10
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Item {
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
|
||||
Text {
|
||||
text: machine.enrollment === "joining fleet" ? "CONVERGING" : "ONE WAY"
|
||||
color: machine.enrollment === "joining fleet" ? Theme.warn : Theme.red
|
||||
font.pixelSize: 9
|
||||
font.weight: 750
|
||||
font.letterSpacing: 1
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 8
|
||||
|
||||
Button {
|
||||
text: machine.joinSeed.length ? "New host seed" : "Create host seed"
|
||||
enabled: !machine.joinRunning && machine.enrollment === "standalone"
|
||||
onClicked: machine.prepareFleetJoin()
|
||||
|
||||
background: Rectangle {
|
||||
color: parent.pressed ? Theme.hover : Theme.raised
|
||||
border.width: 1
|
||||
border.color: Theme.divider
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: parent.enabled ? Theme.text : Theme.faint
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.pixelSize: 11
|
||||
font.weight: 650
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: fleetJoinSeed
|
||||
|
||||
Layout.fillWidth: true
|
||||
text: machine.joinSeed
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
placeholderText: "Create the host proof seed first"
|
||||
color: Theme.cyan
|
||||
placeholderTextColor: Theme.faint
|
||||
font.family: Theme.mono
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.raised
|
||||
border.width: 1
|
||||
border.color: fleetJoinSeed.activeFocus ? Theme.cyan : Theme.divider
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "In RedFlag Web, create a one-seat fleet join key using this host seed. Then return here with the key."
|
||||
color: Theme.faint
|
||||
font.pixelSize: 10
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 2
|
||||
columnSpacing: 8
|
||||
rowSpacing: 4
|
||||
|
||||
Text {
|
||||
text: "SERVER URL"
|
||||
color: Theme.faint
|
||||
font.pixelSize: 9
|
||||
font.weight: 700
|
||||
}
|
||||
|
||||
Text {
|
||||
text: "ONE-SEAT JOIN KEY"
|
||||
color: Theme.faint
|
||||
font.pixelSize: 9
|
||||
font.weight: 700
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: fleetJoinServer
|
||||
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "https://redflag.example"
|
||||
color: Theme.text
|
||||
placeholderTextColor: Theme.faint
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.raised
|
||||
border.width: 1
|
||||
border.color: fleetJoinServer.activeFocus ? Theme.red : Theme.divider
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: fleetJoinToken
|
||||
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "Paste the join key"
|
||||
echoMode: TextInput.Password
|
||||
color: Theme.text
|
||||
placeholderTextColor: Theme.faint
|
||||
font.family: Theme.mono
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.raised
|
||||
border.width: 1
|
||||
border.color: fleetJoinToken.activeFocus ? Theme.red : Theme.divider
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
CheckBox {
|
||||
id: fleetJoinConfirm
|
||||
|
||||
enabled: machine.enrollment === "standalone" && !machine.joinRunning
|
||||
text: "Retire this machine's standalone signing key and local mint permission."
|
||||
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: parent.checked ? Theme.warn : Theme.dim
|
||||
font.pixelSize: 11
|
||||
leftPadding: parent.indicator.width + parent.spacing
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: app.fleetJoin.error || (app.fleetJoin.restart_required ? "Fleet authority installed. The Agent is restarting into fleet mode." : "")
|
||||
color: app.fleetJoin.error ? Theme.bad : Theme.good
|
||||
font.pixelSize: 10
|
||||
font.family: Theme.mono
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Button {
|
||||
text: machine.joinRunning ? "Joining…" : machine.enrollment === "joining fleet" ? "Restarting Agent…" : "Join fleet"
|
||||
enabled: machine.enrollment === "standalone" && !machine.joinRunning && fleetJoinConfirm.checked && fleetJoinSeed.text.length > 0 && fleetJoinServer.text.trim().length > 0 && fleetJoinToken.text.trim().length > 0
|
||||
onClicked: machine.joinFleet(fleetJoinServer.text.trim(), fleetJoinToken.text.trim(), fleetJoinSeed.text.trim())
|
||||
|
||||
background: Rectangle {
|
||||
color: !parent.enabled ? Theme.raised : parent.pressed ? Qt.darker(Theme.red, 1.15) : Theme.red
|
||||
radius: Theme.radiusSm
|
||||
}
|
||||
|
||||
contentItem: Text {
|
||||
text: parent.text
|
||||
color: parent.enabled ? "white" : Theme.faint
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
font.pixelSize: 11
|
||||
font.weight: 650
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Connections {
|
||||
function onJoinFinished(success) {
|
||||
if (success) {
|
||||
fleetJoinToken.text = "";
|
||||
fleetJoinConfirm.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
target: machine
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 100
|
||||
|
|
|
|||
|
|
@ -69,11 +69,14 @@ pub mod ffi {
|
|||
#[qproperty(bool, scan_status_loading)]
|
||||
#[qproperty(QString, operation_message)]
|
||||
#[qproperty(QString, approval_json)]
|
||||
#[qproperty(QString, join_seed)]
|
||||
#[qproperty(QString, join_json)]
|
||||
#[qproperty(i32, software_count)]
|
||||
#[qproperty(i32, software_explicit_count)]
|
||||
#[qproperty(i32, software_dependency_count)]
|
||||
#[qproperty(i32, software_foreign_count)]
|
||||
#[qproperty(bool, approval_running)]
|
||||
#[qproperty(bool, join_running)]
|
||||
#[qproperty(bool, telemetry_loading)]
|
||||
#[qproperty(bool, overview_loading)]
|
||||
#[qproperty(bool, processes_loading)]
|
||||
|
|
@ -106,6 +109,15 @@ pub mod ffi {
|
|||
available_version: &QString,
|
||||
override_reason: &QString,
|
||||
);
|
||||
#[qinvokable]
|
||||
fn prepare_fleet_join(self: Pin<&mut Machine>);
|
||||
#[qinvokable]
|
||||
fn join_fleet(
|
||||
self: Pin<&mut Machine>,
|
||||
server_url: &QString,
|
||||
registration_token: &QString,
|
||||
seed: &QString,
|
||||
);
|
||||
|
||||
#[qsignal]
|
||||
fn telemetry_updated(self: Pin<&mut Machine>);
|
||||
|
|
@ -125,6 +137,10 @@ pub mod ffi {
|
|||
fn operation_finished(self: Pin<&mut Machine>, success: bool);
|
||||
#[qsignal]
|
||||
fn approval_finished(self: Pin<&mut Machine>, success: bool);
|
||||
#[qsignal]
|
||||
fn join_prepared(self: Pin<&mut Machine>, success: bool);
|
||||
#[qsignal]
|
||||
fn join_finished(self: Pin<&mut Machine>, success: bool);
|
||||
}
|
||||
|
||||
impl cxx_qt::Threading for Machine {}
|
||||
|
|
@ -181,11 +197,14 @@ pub struct MachineRust {
|
|||
scan_status_loading: bool,
|
||||
operation_message: QString,
|
||||
approval_json: QString,
|
||||
join_seed: QString,
|
||||
join_json: QString,
|
||||
software_count: i32,
|
||||
software_explicit_count: i32,
|
||||
software_dependency_count: i32,
|
||||
software_foreign_count: i32,
|
||||
approval_running: bool,
|
||||
join_running: bool,
|
||||
telemetry_loading: bool,
|
||||
overview_loading: bool,
|
||||
processes_loading: bool,
|
||||
|
|
@ -245,11 +264,14 @@ impl Default for MachineRust {
|
|||
scan_status_loading: false,
|
||||
operation_message: QString::default(),
|
||||
approval_json: QString::from("{}"),
|
||||
join_seed: QString::default(),
|
||||
join_json: QString::from("{}"),
|
||||
software_count: 0,
|
||||
software_explicit_count: 0,
|
||||
software_dependency_count: 0,
|
||||
software_foreign_count: 0,
|
||||
approval_running: false,
|
||||
join_running: false,
|
||||
telemetry_loading: false,
|
||||
overview_loading: false,
|
||||
processes_loading: false,
|
||||
|
|
@ -427,6 +449,74 @@ impl ffi::Machine {
|
|||
self.approval_finished(success);
|
||||
}
|
||||
|
||||
pub fn prepare_fleet_join(mut self: Pin<&mut Self>) {
|
||||
if self.join_running {
|
||||
return;
|
||||
}
|
||||
self.as_mut().set_join_running(true);
|
||||
self.as_mut().set_join_json(QString::from("{}"));
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::post(
|
||||
"/v1/actions/prepare-fleet-join",
|
||||
&Value::Object(Default::default()),
|
||||
);
|
||||
let _ = thread.queue(move |machine| machine.apply_fleet_join_preparation(result));
|
||||
});
|
||||
}
|
||||
|
||||
pub fn join_fleet(
|
||||
mut self: Pin<&mut Self>,
|
||||
server_url: &QString,
|
||||
registration_token: &QString,
|
||||
seed: &QString,
|
||||
) {
|
||||
if self.join_running {
|
||||
return;
|
||||
}
|
||||
let request = serde_json::json!({
|
||||
"server_url": server_url.to_string(),
|
||||
"registration_token": registration_token.to_string(),
|
||||
"totp_seed": seed.to_string(),
|
||||
});
|
||||
self.as_mut().set_join_running(true);
|
||||
self.as_mut().set_join_json(QString::from("{}"));
|
||||
let thread = self.qt_thread();
|
||||
std::thread::spawn(move || {
|
||||
let result = local_api::post("/v1/actions/join-fleet", &request);
|
||||
let _ = thread.queue(move |machine| machine.apply_fleet_join(result));
|
||||
});
|
||||
}
|
||||
|
||||
fn apply_fleet_join_preparation(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
self.as_mut().set_join_running(false);
|
||||
let success = result.is_ok();
|
||||
let payload = match result {
|
||||
Ok(value) => {
|
||||
self.as_mut().set_join_seed(qtext(&value, "totp_seed"));
|
||||
value
|
||||
}
|
||||
Err(error) => serde_json::json!({ "error": error }),
|
||||
};
|
||||
self.as_mut().set_join_json(json_string(&payload));
|
||||
self.join_prepared(success);
|
||||
}
|
||||
|
||||
fn apply_fleet_join(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
self.as_mut().set_join_running(false);
|
||||
let success = result.is_ok();
|
||||
let payload = match result {
|
||||
Ok(value) => {
|
||||
self.as_mut().set_enrollment(QString::from("joining fleet"));
|
||||
self.as_mut().set_join_seed(QString::default());
|
||||
value
|
||||
}
|
||||
Err(error) => serde_json::json!({ "error": error }),
|
||||
};
|
||||
self.as_mut().set_join_json(json_string(&payload));
|
||||
self.join_finished(success);
|
||||
}
|
||||
|
||||
fn apply_telemetry(mut self: Pin<&mut Self>, result: Result<Value, String>) {
|
||||
self.as_mut().set_telemetry_loading(false);
|
||||
match result {
|
||||
|
|
@ -501,6 +591,14 @@ impl ffi::Machine {
|
|||
.or_else(|| text(info, "device_type"))
|
||||
.unwrap_or_else(|| "Computer".into());
|
||||
let registered = data.identity["registered"].as_bool().unwrap_or(false);
|
||||
let enrollment = match data.identity["mode"].as_str() {
|
||||
Some("fleet") => "fleet enrolled",
|
||||
Some("standalone") => "standalone",
|
||||
Some("joining") => "joining fleet",
|
||||
Some("incomplete") => "incomplete",
|
||||
_ if registered => "fleet enrolled",
|
||||
_ => "standalone",
|
||||
};
|
||||
let updates = data.updates["updates"]
|
||||
.as_array()
|
||||
.cloned()
|
||||
|
|
@ -536,11 +634,7 @@ impl ffi::Machine {
|
|||
.set_agent_version(qtext(&data.identity, "agent_version"));
|
||||
self.as_mut()
|
||||
.set_agent_status(qtext(&data.status, "agent_status"));
|
||||
self.as_mut().set_enrollment(QString::from(if registered {
|
||||
"fleet enrolled"
|
||||
} else {
|
||||
"standalone"
|
||||
}));
|
||||
self.as_mut().set_enrollment(QString::from(enrollment));
|
||||
self.as_mut().set_uptime(qtext(info, "uptime"));
|
||||
self.as_mut()
|
||||
.set_process_count(info["running_processes"].as_i64().unwrap_or(0) as i32);
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ publish = false
|
|||
[dependencies]
|
||||
ed25519-dalek = "2.1.1"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
serde_json = { version = "1.0", features = ["raw_value"] }
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
subtle = "2"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
// redflag-helper — capability-token executor (keystone).
|
||||
// Windows target: stub binary (self-upgrade not yet ported — handled by installer).
|
||||
#![cfg_attr(windows, allow(dead_code, unused_variables))]
|
||||
//
|
||||
// Reads one Ed25519-signed capability token from stdin, verifies it against a
|
||||
|
|
@ -35,6 +34,10 @@ use subtle::ConstantTimeEq;
|
|||
|
||||
#[allow(dead_code)]
|
||||
mod mutation_protocol;
|
||||
#[allow(dead_code)]
|
||||
mod task_dispatch;
|
||||
#[cfg(windows)]
|
||||
mod windows_custody;
|
||||
use mutation_protocol::{
|
||||
key_id_for, MutationAuthorization, MutationEnvelope, MutationManifest, MutationOutcome,
|
||||
MutationReceipt, MUTATION_PROTOCOL_VERSION,
|
||||
|
|
@ -42,11 +45,40 @@ use mutation_protocol::{
|
|||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
eprintln!(
|
||||
"redflag-helper: Windows self-upgrade not yet implemented. \
|
||||
Desktop and agent updates on Windows are handled by the installer."
|
||||
);
|
||||
std::process::exit(0);
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
if matches!(
|
||||
args.get(1).map(|s| s.as_str()),
|
||||
Some("--version") | Some("-V")
|
||||
) {
|
||||
println!("RedFlag helper v{}", env!("REDFLAG_VERSION"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Windows begins with the verification half of the shared RAF mutation
|
||||
// protocol. These paths have no mutation authority and consume no replay
|
||||
// state. Privileged execution stays fail-closed until Windows trust-path,
|
||||
// replay, and fixed-task transport are implemented and accepted together.
|
||||
if args.get(1).map(|s| s.as_str()) == Some("verify-binary") {
|
||||
std::process::exit(run_verify_binary(&args[2..]));
|
||||
}
|
||||
if args.get(1).map(|s| s.as_str()) == Some("verify-envelope") {
|
||||
std::process::exit(run_verify_envelope_cli(&args[2..]));
|
||||
}
|
||||
if args.get(1).map(|s| s.as_str()) == Some("run-request") {
|
||||
if args.len() != 2 {
|
||||
log_security("denied reason=task_arguments_forbidden");
|
||||
std::process::exit(EXIT_BAD_TOKEN);
|
||||
}
|
||||
std::process::exit(task_dispatch::run());
|
||||
}
|
||||
|
||||
let command = args.get(1).map(|s| s.as_str()).unwrap_or("<none>");
|
||||
log_security(&format!(
|
||||
"denied reason=windows_execution_unavailable command={} exit={}",
|
||||
command, EXIT_UNSUPPORTED_OP
|
||||
));
|
||||
std::process::exit(EXIT_UNSUPPORTED_OP);
|
||||
}
|
||||
|
||||
const SUPPORTED_TOKEN_VERSION: u32 = 1;
|
||||
|
|
@ -87,6 +119,12 @@ const DEFAULT_AGENT_MUTATION_ENVELOPE_DIR: &str = "/var/lib/redflag/agent/mutati
|
|||
#[cfg(unix)]
|
||||
const DEFAULT_AGENT_MUTATION_RECEIPT_DIR: &str = "/var/lib/redflag/agent/mutation-receipts";
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_AGENT_FLEET_JOIN_REQUEST_DIR: &str = "/var/lib/redflag/agent/fleet-join-requests";
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_AGENT_FLEET_JOIN_RECEIPT_DIR: &str = "/var/lib/redflag/agent/fleet-join-receipts";
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_STANDALONE_SUDOERS_FILE: &str = "/etc/sudoers.d/redflag-agent-mint";
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_AGENT_MINT_REQUEST_DIR: &str = "/var/lib/redflag/agent/mint";
|
||||
#[cfg(unix)]
|
||||
const DEFAULT_AGENT_TOKEN_DIR: &str = "/var/lib/redflag/agent/tokens";
|
||||
|
|
@ -1186,6 +1224,17 @@ fn create_staging_file_without_symlinks(staging: &Path) -> Result<fs::File, Deni
|
|||
Ok(unsafe { fs::File::from_raw_fd(descriptor) })
|
||||
}
|
||||
|
||||
// Symlink-proof staging needs openat and O_NOFOLLOW. Until Windows has its own
|
||||
// trust-path proof, staging there refuses rather than creating by path.
|
||||
#[cfg(not(unix))]
|
||||
fn create_staging_file_without_symlinks(staging: &Path) -> Result<fs::File, Denial> {
|
||||
Err(Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"stage_unavailable",
|
||||
format!("{}: unsupported platform", staging.display()),
|
||||
))
|
||||
}
|
||||
|
||||
// stage_and_verify_binary copies a source binary into a root-only staging file
|
||||
// and verifies its SHA-256 against the expected hash. Returns the staging path.
|
||||
// Run BEFORE the replay slot is consumed so a tampered binary does not burn the
|
||||
|
|
@ -1355,6 +1404,16 @@ fn install_staged_agent_binary(staged: &str) -> Result<(), Denial> {
|
|||
// comes up with the capability grant in place.
|
||||
reconcile_agent_unit_dropin();
|
||||
|
||||
// Agent updates are the normal fleet delivery path. Reconcile both tray
|
||||
// launch mechanisms here as well as on desktop-self updates, so an
|
||||
// existing host gains compositor-session support with its next agent
|
||||
// update instead of waiting for a separate desktop package update.
|
||||
let desktop_binary = env_or("REDFLAG_DESKTOP_BINARY", DEFAULT_DESKTOP_BINARY);
|
||||
#[cfg(unix)]
|
||||
reconcile_desktop_autostart(&desktop_binary);
|
||||
#[cfg(unix)]
|
||||
reconcile_compositor_session_launcher(&desktop_binary);
|
||||
|
||||
// Enqueue the restart with --no-block and return. The agent process is the
|
||||
// consumer blocked on this helper's stdout pipe; a synchronous restart would
|
||||
// SIGTERM it before we emit our result (broken pipe). --no-block lets this
|
||||
|
|
@ -1499,7 +1558,10 @@ fn install_desktop_binary(staged: &str) -> Result<(), Denial> {
|
|||
// launches — heal the session-start provisioning the token install can't
|
||||
// carry (UPDATE-002 gap 2), same reconciliation posture as the agent unit
|
||||
// drop-in.
|
||||
#[cfg(unix)]
|
||||
reconcile_desktop_autostart(&install);
|
||||
#[cfg(unix)]
|
||||
reconcile_compositor_session_launcher(&install);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -1513,6 +1575,7 @@ fn install_desktop_binary(staged: &str) -> Result<(), Denial> {
|
|||
// user to bind, and the tray's own socket-permission diagnostic tells the user
|
||||
// the exact usermod to run. Non-fatal on failure — the binary install already
|
||||
// succeeded and is journaled.
|
||||
#[cfg(unix)]
|
||||
fn reconcile_desktop_autostart(binary_path: &str) {
|
||||
const AUTOSTART_DIR: &str = "/etc/xdg/autostart";
|
||||
const AUTOSTART_PATH: &str = "/etc/xdg/autostart/redflag-desktop.desktop";
|
||||
|
|
@ -1560,6 +1623,140 @@ fn reconcile_desktop_autostart(binary_path: &str) {
|
|||
));
|
||||
}
|
||||
|
||||
// reconcile_compositor_session_launcher installs a user-systemd fallback for
|
||||
// compositors such as Hyprland, Sway, and River. Those sessions commonly do
|
||||
// not consume /etc/xdg/autostart entries, leaving the otherwise-installed tray
|
||||
// binary inert. Desktop environments continue using the XDG entry above: the
|
||||
// launcher exits without starting a second copy unless it detects a supported
|
||||
// compositor in the user manager's imported session environment.
|
||||
//
|
||||
// The helper is root-owned, so it places the unit globally rather than writing
|
||||
// into a particular user's dotfiles. This preserves user configuration and
|
||||
// supports the first graphical login after an agent update.
|
||||
#[cfg(unix)]
|
||||
fn reconcile_compositor_session_launcher(binary_path: &str) {
|
||||
const UNIT_DIR: &str = "/etc/systemd/user";
|
||||
const UNIT_PATH: &str = "/etc/systemd/user/redflag-desktop-compositor.service";
|
||||
const WANTS_DIR: &str = "/etc/systemd/user/default.target.wants";
|
||||
const WANTS_PATH: &str =
|
||||
"/etc/systemd/user/default.target.wants/redflag-desktop-compositor.service";
|
||||
const LAUNCHER_DIR: &str = "/usr/local/libexec";
|
||||
const LAUNCHER_PATH: &str = "/usr/local/libexec/redflag-desktop-compositor";
|
||||
|
||||
let launcher = format!(
|
||||
"#!/bin/sh\n\
|
||||
# Wait for the compositor to import its session variables into the user manager.\n\
|
||||
# Hyprland configurations commonly do this shortly after login.\n\
|
||||
attempts=0\n\
|
||||
while [ \"$attempts\" -lt 30 ]; do\n\
|
||||
session=\"$(systemctl --user show-environment 2>/dev/null || true)\"\n\
|
||||
desktop=\"$(printf '%s\\n' \"$session\" | sed -n 's/^XDG_CURRENT_DESKTOP=//p' | head -n 1)\"\n\
|
||||
wayland=\"$(printf '%s\\n' \"$session\" | sed -n 's/^WAYLAND_DISPLAY=//p' | head -n 1)\"\n\
|
||||
runtime=\"$(printf '%s\\n' \"$session\" | sed -n 's/^XDG_RUNTIME_DIR=//p' | head -n 1)\"\n\
|
||||
runtime=\"${{runtime:-${{XDG_RUNTIME_DIR:-}}}}\"\n\
|
||||
case \"$desktop\" in\n\
|
||||
*Hyprland*|*sway*|*Sway*|*river*)\n\
|
||||
if [ -n \"$wayland\" ] && [ -n \"$runtime\" ] && [ -S \"$runtime/$wayland\" ]; then\n\
|
||||
export XDG_CURRENT_DESKTOP=\"$desktop\" WAYLAND_DISPLAY=\"$wayland\" XDG_RUNTIME_DIR=\"$runtime\"\n\
|
||||
exec {}\n\
|
||||
fi\n\
|
||||
;;\n\
|
||||
?*) exit 0 ;;\n\
|
||||
esac\n\
|
||||
attempts=$((attempts + 1))\n\
|
||||
sleep 2\n\
|
||||
done\n\
|
||||
exit 0\n",
|
||||
binary_path
|
||||
);
|
||||
|
||||
let unit = format!(
|
||||
"[Unit]\n\
|
||||
Description=RedFlag desktop tray for compositor-only sessions\n\
|
||||
After=default.target\n\
|
||||
\n\
|
||||
[Service]\n\
|
||||
Type=simple\n\
|
||||
Environment=XDG_RUNTIME_DIR=%t\n\
|
||||
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus\n\
|
||||
ExecStart={}\n\
|
||||
Restart=on-failure\n\
|
||||
RestartSec=5\n\
|
||||
\n\
|
||||
[Install]\n\
|
||||
WantedBy=default.target\n",
|
||||
LAUNCHER_PATH
|
||||
);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(LAUNCHER_DIR) {
|
||||
log_security(&format!(
|
||||
"desktop_compositor_launcher_dir_failed path={} err={}",
|
||||
LAUNCHER_DIR, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::write(LAUNCHER_PATH, launcher) {
|
||||
log_security(&format!(
|
||||
"desktop_compositor_launcher_write_failed path={} err={}",
|
||||
LAUNCHER_PATH, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::set_permissions(LAUNCHER_PATH, fs::Permissions::from_mode(0o755)) {
|
||||
log_security(&format!(
|
||||
"desktop_compositor_launcher_chmod_failed path={} err={}",
|
||||
LAUNCHER_PATH, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = fs::create_dir_all(UNIT_DIR) {
|
||||
log_security(&format!(
|
||||
"desktop_compositor_unit_dir_failed path={} err={}",
|
||||
UNIT_DIR, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::write(UNIT_PATH, unit) {
|
||||
log_security(&format!(
|
||||
"desktop_compositor_unit_write_failed path={} err={}",
|
||||
UNIT_PATH, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
if let Err(e) = fs::set_permissions(UNIT_PATH, fs::Permissions::from_mode(0o644)) {
|
||||
log_security(&format!(
|
||||
"desktop_compositor_unit_chmod_failed path={} err={}",
|
||||
UNIT_PATH, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = fs::create_dir_all(WANTS_DIR) {
|
||||
log_security(&format!(
|
||||
"desktop_compositor_wants_dir_failed path={} err={}",
|
||||
WANTS_DIR, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
if !Path::new(WANTS_PATH).exists() {
|
||||
if let Err(e) =
|
||||
std::os::unix::fs::symlink("../redflag-desktop-compositor.service", WANTS_PATH)
|
||||
{
|
||||
log_security(&format!(
|
||||
"desktop_compositor_enable_failed path={} err={}",
|
||||
WANTS_PATH, e
|
||||
));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log_security(&format!(
|
||||
"desktop_compositor_launcher_reconciled unit={}",
|
||||
UNIT_PATH
|
||||
));
|
||||
}
|
||||
|
||||
fn run(
|
||||
token_file: Option<&str>,
|
||||
helper_file: Option<&str>,
|
||||
|
|
@ -1948,6 +2145,8 @@ const MINT_CLOCK_SKEW_SECS: i64 = 60;
|
|||
const MINT_TOKEN_TTL_SECS: i64 = 600; // matches the 10-minute command validity window
|
||||
const DEFAULT_MINT_KEY: &str = "/etc/redflag/authority_local.key";
|
||||
const DEFAULT_MINT_JOURNAL: &str = "/var/lib/redflag/journal/mint.log";
|
||||
#[cfg(unix)]
|
||||
const FLEET_AUTHORITY_VERSION: u32 = 1;
|
||||
// The standalone local API exposes legacy capability approval only for APT and
|
||||
// DNF. Keep the privileged minter equally narrow; pacman has its envelope path.
|
||||
const MINTABLE_PACKAGE_TYPES: &[&str] = &["apt", "dnf"];
|
||||
|
|
@ -1978,6 +2177,31 @@ struct MintRequest {
|
|||
gate_evidence: GateEvidence,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FleetAuthorityRequest {
|
||||
version: u32,
|
||||
request_id: String,
|
||||
agent_id: String,
|
||||
server_url: String,
|
||||
signing_public_key: String,
|
||||
signing_key_id: String,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Serialize)]
|
||||
struct FleetAuthorityReceipt {
|
||||
version: u32,
|
||||
request_id: String,
|
||||
agent_id: String,
|
||||
server_url: String,
|
||||
signing_key_id: String,
|
||||
server_authority_installed: bool,
|
||||
local_authority_retired: bool,
|
||||
already_applied: bool,
|
||||
applied_at: i64,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
|
|
@ -2770,13 +2994,14 @@ fn run_mint_retire_key(paths: &MintPaths) -> Result<(), Denial> {
|
|||
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
|
||||
let pub_path = keyring_dir.join("authority_local.pub");
|
||||
if pub_path.exists() {
|
||||
if let Err(e) = fs::remove_file(&pub_path) {
|
||||
log_error(&format!(
|
||||
"keyring_pub_remove_failed path={} error={}",
|
||||
pub_path.display(),
|
||||
e
|
||||
));
|
||||
}
|
||||
validate_trusted_path(&pub_path)?;
|
||||
fs::remove_file(&pub_path).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"keyring_pub_remove_failed",
|
||||
format!("{}: {}", pub_path.display(), error),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
mint_journal_append(
|
||||
|
|
@ -2787,6 +3012,475 @@ fn run_mint_retire_key(paths: &MintPaths) -> Result<(), Denial> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_fleet_server_url(value: &str) -> Result<(), Denial> {
|
||||
let value = value.trim();
|
||||
let rest = value
|
||||
.strip_prefix("https://")
|
||||
.or_else(|| value.strip_prefix("http://"))
|
||||
.ok_or_else(|| {
|
||||
Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"fleet_authority_server_url_invalid",
|
||||
"server_url must use http or https",
|
||||
)
|
||||
})?;
|
||||
if rest.is_empty()
|
||||
|| value.len() > 2048
|
||||
|| value.ends_with('/')
|
||||
|| value
|
||||
.bytes()
|
||||
.any(|byte| byte.is_ascii_control() || byte.is_ascii_whitespace())
|
||||
|| value.contains('@')
|
||||
|| value.contains('?')
|
||||
|| value.contains('#')
|
||||
{
|
||||
return Err(Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"fleet_authority_server_url_invalid",
|
||||
value,
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn validate_fleet_authority_request(req: &FleetAuthorityRequest) -> Result<Vec<u8>, Denial> {
|
||||
if req.version != FLEET_AUTHORITY_VERSION {
|
||||
return Err(Denial::new(
|
||||
EXIT_VERSION,
|
||||
"fleet_authority_version_unsupported",
|
||||
format!("version={}", req.version),
|
||||
));
|
||||
}
|
||||
if !is_valid_uuid_v4(&req.request_id) {
|
||||
return Err(Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"fleet_authority_request_id_invalid",
|
||||
&req.request_id,
|
||||
));
|
||||
}
|
||||
if !is_valid_uuid_v4(&req.agent_id) {
|
||||
return Err(Denial::new(
|
||||
EXIT_AGENT_MISMATCH,
|
||||
"fleet_authority_agent_id_invalid",
|
||||
&req.agent_id,
|
||||
));
|
||||
}
|
||||
let local_id = local_agent_id()?;
|
||||
if req.agent_id != local_id {
|
||||
return Err(Denial::new(
|
||||
EXIT_AGENT_MISMATCH,
|
||||
"fleet_authority_agent_mismatch",
|
||||
format!("request={} local={}", req.agent_id, local_id),
|
||||
));
|
||||
}
|
||||
validate_fleet_server_url(&req.server_url)?;
|
||||
let public_key = hex::decode(req.signing_public_key.trim()).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_SIGNATURE,
|
||||
"fleet_authority_public_key_invalid",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
if public_key.len() != 32 {
|
||||
return Err(Denial::new(
|
||||
EXIT_SIGNATURE,
|
||||
"fleet_authority_public_key_invalid",
|
||||
format!("bytes={} expected=32", public_key.len()),
|
||||
));
|
||||
}
|
||||
let key_bytes: [u8; 32] = public_key.as_slice().try_into().map_err(|_| {
|
||||
Denial::new(
|
||||
EXIT_SIGNATURE,
|
||||
"fleet_authority_public_key_invalid",
|
||||
"expected 32 bytes",
|
||||
)
|
||||
})?;
|
||||
let computed_id = key_id_for(&key_bytes);
|
||||
if req.signing_key_id != computed_id {
|
||||
return Err(Denial::new(
|
||||
EXIT_SIGNATURE,
|
||||
"fleet_authority_key_id_mismatch",
|
||||
format!("request={} computed={}", req.signing_key_id, computed_id),
|
||||
));
|
||||
}
|
||||
Ok(public_key)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn require_fleet_exchange_name(
|
||||
path: &str,
|
||||
expected_dir: &str,
|
||||
request_id: &str,
|
||||
) -> Result<(), Denial> {
|
||||
validate_agent_exchange_path(path, expected_dir)?;
|
||||
let expected = format!("{}.json", request_id);
|
||||
let actual = Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("");
|
||||
if actual != expected {
|
||||
return Err(Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"fleet_authority_exchange_join_mismatch",
|
||||
format!("actual={} expected={}", actual, expected),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn read_trusted_text(path: &Path) -> Result<String, Denial> {
|
||||
validate_trusted_path(path)?;
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options
|
||||
.read(true)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
|
||||
let mut file = options.open(path).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"fleet_authority_key_open_failed",
|
||||
format!("{}: {}", path.display(), error),
|
||||
)
|
||||
})?;
|
||||
let mut value = String::new();
|
||||
file.read_to_string(&mut value).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"fleet_authority_key_read_failed",
|
||||
format!("{}: {}", path.display(), error),
|
||||
)
|
||||
})?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn install_fleet_public_key(
|
||||
keyring_dir: &Path,
|
||||
request_id: &str,
|
||||
public_key: &[u8],
|
||||
) -> Result<bool, Denial> {
|
||||
if !keyring_dir.exists() {
|
||||
fs::create_dir_all(keyring_dir).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_keyring_create_failed",
|
||||
format!("{}: {}", keyring_dir.display(), error),
|
||||
)
|
||||
})?;
|
||||
fs::set_permissions(keyring_dir, fs::Permissions::from_mode(0o755)).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_keyring_mode_failed",
|
||||
format!("{}: {}", keyring_dir.display(), error),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
validate_trusted_path(keyring_dir)?;
|
||||
|
||||
let expected = hex::encode(public_key);
|
||||
let destination = keyring_dir.join("server.pub");
|
||||
if destination.exists() {
|
||||
let current = read_trusted_text(&destination)?;
|
||||
if current.trim() != expected {
|
||||
return Err(Denial::new(
|
||||
EXIT_SIGNATURE,
|
||||
"fleet_authority_server_key_conflict",
|
||||
destination.display().to_string(),
|
||||
));
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let temporary = keyring_dir.join(format!(".server.{}.tmp", request_id));
|
||||
if temporary.exists() {
|
||||
let staged = read_trusted_text(&temporary)?;
|
||||
if staged.trim() != expected {
|
||||
return Err(Denial::new(
|
||||
EXIT_SIGNATURE,
|
||||
"fleet_authority_staged_key_conflict",
|
||||
temporary.display().to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o644)
|
||||
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC);
|
||||
let mut file = options.open(&temporary).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_key_stage_failed",
|
||||
format!("{}: {}", temporary.display(), error),
|
||||
)
|
||||
})?;
|
||||
std::io::Write::write_all(&mut file, expected.as_bytes()).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_key_stage_write_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
file.sync_all().map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_key_stage_sync_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
fs::rename(&temporary, &destination).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_key_install_failed",
|
||||
format!("{}: {}", destination.display(), error),
|
||||
)
|
||||
})?;
|
||||
let directory = open_directory_without_symlinks(keyring_dir)?;
|
||||
directory.sync_all().map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_keyring_sync_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
validate_trusted_path(&destination)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn remove_standalone_sudoers(path: &Path) -> Result<(), Denial> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
validate_trusted_path(path)?;
|
||||
fs::remove_file(path).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_sudoers_remove_failed",
|
||||
format!("{}: {}", path.display(), error),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn retain_only_fleet_server_key(keyring_dir: &Path) -> Result<(), Denial> {
|
||||
validate_trusted_path(keyring_dir)?;
|
||||
let entries = fs::read_dir(keyring_dir).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"fleet_authority_keyring_read_failed",
|
||||
format!("{}: {}", keyring_dir.display(), error),
|
||||
)
|
||||
})?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"fleet_authority_keyring_entry_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})?;
|
||||
let name = entry.file_name();
|
||||
if name == "server.pub" {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
let stale_trust = path.extension().and_then(|value| value.to_str()) == Some("pub")
|
||||
|| name.to_string_lossy().starts_with(".server.");
|
||||
if !stale_trust {
|
||||
continue;
|
||||
}
|
||||
validate_trusted_path(&path)?;
|
||||
if !entry
|
||||
.file_type()
|
||||
.map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"fleet_authority_key_type_failed",
|
||||
format!("{}: {}", path.display(), error),
|
||||
)
|
||||
})?
|
||||
.is_file()
|
||||
{
|
||||
return Err(Denial::new(
|
||||
EXIT_TRUST_PATH,
|
||||
"fleet_authority_key_not_regular",
|
||||
path.display().to_string(),
|
||||
));
|
||||
}
|
||||
fs::remove_file(&path).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_stale_key_remove_failed",
|
||||
format!("{}: {}", path.display(), error),
|
||||
)
|
||||
})?;
|
||||
}
|
||||
open_directory_without_symlinks(keyring_dir)?
|
||||
.sync_all()
|
||||
.map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_INTERNAL,
|
||||
"fleet_authority_keyring_sync_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn adopt_fleet_authority(req: &FleetAuthorityRequest) -> Result<FleetAuthorityReceipt, Denial> {
|
||||
let public_key = validate_fleet_authority_request(req)?;
|
||||
let paths = MintPaths::from_env();
|
||||
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
|
||||
let local_public = keyring_dir.join("authority_local.pub");
|
||||
let server_public = keyring_dir.join("server.pub");
|
||||
let local_private_exists = paths.key_path.exists();
|
||||
|
||||
if !local_private_exists {
|
||||
if !server_public.exists() {
|
||||
return Err(Denial::new(
|
||||
EXIT_MINT_KEY,
|
||||
"fleet_authority_partial_state",
|
||||
"local private key is absent without an installed Server authority",
|
||||
));
|
||||
}
|
||||
let existing = read_trusted_text(&server_public)?;
|
||||
if existing.trim() != hex::encode(&public_key) {
|
||||
return Err(Denial::new(
|
||||
EXIT_SIGNATURE,
|
||||
"fleet_authority_server_key_conflict",
|
||||
server_public.display().to_string(),
|
||||
));
|
||||
}
|
||||
retain_only_fleet_server_key(&keyring_dir)?;
|
||||
remove_standalone_sudoers(Path::new(DEFAULT_STANDALONE_SUDOERS_FILE))?;
|
||||
mint_journal_append(
|
||||
&paths.journal_path,
|
||||
&serde_json::json!({
|
||||
"ts": now_unix(),
|
||||
"event": "fleet_authority_reconciled",
|
||||
"request_id": req.request_id,
|
||||
"agent_id": req.agent_id,
|
||||
"server_url": req.server_url,
|
||||
"key_id": req.signing_key_id,
|
||||
}),
|
||||
)?;
|
||||
return Ok(FleetAuthorityReceipt {
|
||||
version: FLEET_AUTHORITY_VERSION,
|
||||
request_id: req.request_id.clone(),
|
||||
agent_id: req.agent_id.clone(),
|
||||
server_url: req.server_url.clone(),
|
||||
signing_key_id: req.signing_key_id.clone(),
|
||||
server_authority_installed: true,
|
||||
local_authority_retired: true,
|
||||
already_applied: true,
|
||||
applied_at: now_unix(),
|
||||
});
|
||||
}
|
||||
|
||||
validate_trusted_path(&paths.key_path)?;
|
||||
if local_public.exists() {
|
||||
validate_trusted_path(&local_public)?;
|
||||
}
|
||||
install_fleet_public_key(&keyring_dir, &req.request_id, &public_key)?;
|
||||
run_mint_retire_key(&paths)?;
|
||||
retain_only_fleet_server_key(&keyring_dir)?;
|
||||
remove_standalone_sudoers(Path::new(DEFAULT_STANDALONE_SUDOERS_FILE))?;
|
||||
mint_journal_append(
|
||||
&paths.journal_path,
|
||||
&serde_json::json!({
|
||||
"ts": now_unix(),
|
||||
"event": "fleet_authority_adopted",
|
||||
"request_id": req.request_id,
|
||||
"agent_id": req.agent_id,
|
||||
"server_url": req.server_url,
|
||||
"key_id": req.signing_key_id,
|
||||
}),
|
||||
)?;
|
||||
|
||||
Ok(FleetAuthorityReceipt {
|
||||
version: FLEET_AUTHORITY_VERSION,
|
||||
request_id: req.request_id.clone(),
|
||||
agent_id: req.agent_id.clone(),
|
||||
server_url: req.server_url.clone(),
|
||||
signing_key_id: req.signing_key_id.clone(),
|
||||
server_authority_installed: true,
|
||||
local_authority_retired: true,
|
||||
already_applied: false,
|
||||
applied_at: now_unix(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run_adopt_fleet_cli(args: &[String]) -> i32 {
|
||||
if args.len() != 4 || args[0] != "--request-file" || args[2] != "--receipt-file" {
|
||||
log_error("adopt-fleet usage: redflag-helper adopt-fleet --request-file <path> --receipt-file <path>");
|
||||
return EXIT_BAD_TOKEN;
|
||||
}
|
||||
let request_file = &args[1];
|
||||
let receipt_file = &args[3];
|
||||
let result = read_agent_exchange_file(request_file, DEFAULT_AGENT_FLEET_JOIN_REQUEST_DIR)
|
||||
.and_then(|raw| {
|
||||
serde_json::from_str::<FleetAuthorityRequest>(&raw).map_err(|error| {
|
||||
Denial::new(
|
||||
EXIT_BAD_TOKEN,
|
||||
"fleet_authority_request_parse_failed",
|
||||
error.to_string(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.and_then(|request| {
|
||||
require_fleet_exchange_name(
|
||||
request_file,
|
||||
DEFAULT_AGENT_FLEET_JOIN_REQUEST_DIR,
|
||||
&request.request_id,
|
||||
)?;
|
||||
require_fleet_exchange_name(
|
||||
receipt_file,
|
||||
DEFAULT_AGENT_FLEET_JOIN_RECEIPT_DIR,
|
||||
&request.request_id,
|
||||
)?;
|
||||
adopt_fleet_authority(&request)
|
||||
});
|
||||
|
||||
match result {
|
||||
Ok(receipt) => match write_agent_exchange_json(
|
||||
&receipt,
|
||||
receipt_file,
|
||||
DEFAULT_AGENT_FLEET_JOIN_RECEIPT_DIR,
|
||||
) {
|
||||
Ok(()) => {
|
||||
log_security(&format!(
|
||||
"fleet_authority_adopted request_id={} agent_id={} key_id={} already_applied={}",
|
||||
receipt.request_id,
|
||||
receipt.agent_id,
|
||||
receipt.signing_key_id,
|
||||
receipt.already_applied
|
||||
));
|
||||
EXIT_OK
|
||||
}
|
||||
Err(denial) => {
|
||||
log_error(&format!(
|
||||
"fleet_authority_receipt_failed reason={} detail={}",
|
||||
denial.reason, denial.detail
|
||||
));
|
||||
denial.code
|
||||
}
|
||||
},
|
||||
Err(denial) => {
|
||||
log_security(&format!(
|
||||
"denied reason={} detail={} exit={}",
|
||||
denial.reason, denial.detail, denial.code
|
||||
));
|
||||
denial.code
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// CLI: redflag-helper mint --request-file <p> --token-out <p>
|
||||
// redflag-helper mint --init-key
|
||||
// redflag-helper mint --retire-key
|
||||
|
|
@ -2937,14 +3631,17 @@ mod tests {
|
|||
));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
#[cfg(unix)]
|
||||
fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn own_uid(dir: &Path) -> u32 {
|
||||
fs::symlink_metadata(dir).unwrap().uid()
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_path_accepts_owned_unwritable() {
|
||||
let dir = trust_fixture("ok");
|
||||
|
|
@ -2958,6 +3655,7 @@ mod tests {
|
|||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_path_rejects_group_or_other_writable() {
|
||||
let dir = trust_fixture("writable");
|
||||
|
|
@ -2978,6 +3676,7 @@ mod tests {
|
|||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_path_rejects_wrong_owner() {
|
||||
let dir = trust_fixture("owner");
|
||||
|
|
@ -2994,6 +3693,7 @@ mod tests {
|
|||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_path_rejects_symlink() {
|
||||
let dir = trust_fixture("symlink");
|
||||
|
|
@ -3009,6 +3709,7 @@ mod tests {
|
|||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_path_rejects_missing() {
|
||||
let dir = trust_fixture("missing");
|
||||
|
|
@ -3074,6 +3775,7 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn mint_fixture(name: &str) -> MintFixture {
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("redflag-mint-test-{}-{}", name, std::process::id()));
|
||||
|
|
@ -3144,6 +3846,7 @@ mod tests {
|
|||
|
||||
// The full loop: mint a token, then verify it with the exact same functions
|
||||
// the execute path uses. If this passes, a minted token is executable.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_round_trips_through_execute_verification() {
|
||||
let fx = mint_fixture("roundtrip");
|
||||
|
|
@ -3181,6 +3884,7 @@ mod tests {
|
|||
assert!(journal.contains(&token.token_id));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_rejects_stale_evidence() {
|
||||
let fx = mint_fixture("stale");
|
||||
|
|
@ -3191,6 +3895,7 @@ mod tests {
|
|||
assert_eq!(d.code, EXIT_MINT_STALE);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_rejects_future_dated_evidence() {
|
||||
let fx = mint_fixture("future");
|
||||
|
|
@ -3201,6 +3906,7 @@ mod tests {
|
|||
assert_eq!(d.code, EXIT_MINT_STALE);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_vulnerable_requires_override_reason() {
|
||||
let fx = mint_fixture("vuln");
|
||||
|
|
@ -3218,6 +3924,7 @@ mod tests {
|
|||
assert!(journal.contains(&token.token_id));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_duplicate_request_id_denied() {
|
||||
let fx = mint_fixture("dup");
|
||||
|
|
@ -3235,6 +3942,7 @@ mod tests {
|
|||
.is_file());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_loose_key_permissions_denied() {
|
||||
let fx = mint_fixture("perms");
|
||||
|
|
@ -3244,6 +3952,7 @@ mod tests {
|
|||
assert_eq!(d.code, EXIT_MINT_KEY);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_rejects_agent_self_and_bad_hashes() {
|
||||
let fx = mint_fixture("shape");
|
||||
|
|
@ -3271,6 +3980,7 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn mint_rejects_package_types_outside_local_api() {
|
||||
let fx = mint_fixture("package-types");
|
||||
|
|
@ -4631,7 +5341,22 @@ fn verify_envelope(
|
|||
now: i64,
|
||||
) -> Result<MutationEnvelope, Box<(Option<MutationEnvelope>, Denial)>> {
|
||||
let envelope = read_envelope_from_file(envelope_file).map_err(|d| Box::new((None, d)))?;
|
||||
verify_envelope_with_context(envelope, now, local_agent_id, || {
|
||||
load_keyring(&PathBuf::from(env_or(
|
||||
"REDFLAG_HELPER_KEYRING",
|
||||
DEFAULT_KEYRING_DIR,
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
// Platform transports supply independently protected identity and keys. The
|
||||
// signed-byte verification and denial order remain one implementation.
|
||||
fn verify_envelope_with_context(
|
||||
envelope: MutationEnvelope,
|
||||
now: i64,
|
||||
read_identity: impl FnOnce() -> Result<String, Denial>,
|
||||
read_keys: impl FnOnce() -> Result<Vec<(String, VerifyingKey)>, Denial>,
|
||||
) -> Result<MutationEnvelope, Box<(Option<MutationEnvelope>, Denial)>> {
|
||||
// Evaluate the denial before moving the envelope into the return — every
|
||||
// detail string reads from it.
|
||||
macro_rules! deny {
|
||||
|
|
@ -4671,7 +5396,7 @@ fn verify_envelope(
|
|||
// target_id is the RedFlag agent identity. Both copies are signed and the
|
||||
// verifier requires them equal; both are compared anyway, because the cost
|
||||
// is a string compare and the failure would be silent.
|
||||
let local = match local_agent_id() {
|
||||
let local = match read_identity() {
|
||||
Ok(v) => v,
|
||||
Err(d) => deny!(d),
|
||||
};
|
||||
|
|
@ -4744,8 +5469,7 @@ fn verify_envelope(
|
|||
));
|
||||
}
|
||||
|
||||
let keyring_dir = PathBuf::from(env_or("REDFLAG_HELPER_KEYRING", DEFAULT_KEYRING_DIR));
|
||||
let keyring = match load_keyring(&keyring_dir) {
|
||||
let keyring = match read_keys() {
|
||||
Ok(k) => k,
|
||||
Err(d) => deny!(d),
|
||||
};
|
||||
|
|
@ -5192,6 +5916,11 @@ fn main() {
|
|||
std::process::exit(run_mint_cli(&args[2..]));
|
||||
}
|
||||
|
||||
// One-way standalone-to-fleet authority replacement.
|
||||
if args.get(1).map(|s| s.as_str()) == Some("adopt-fleet") {
|
||||
std::process::exit(run_adopt_fleet_cli(&args[2..]));
|
||||
}
|
||||
|
||||
// Standalone mutation authority: signs a fully resolved pacman manifest
|
||||
// only after the helper has verified the package identities and signatures.
|
||||
if args.get(1).map(|s| s.as_str()) == Some("mint-envelope") {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const AUTHORIZATION_DOMAIN: &str = "redflag.mutation-authorization";
|
|||
const RECEIPT_DOMAIN: &str = "redflag.mutation-receipt";
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResolvedAction {
|
||||
pub kind: String,
|
||||
pub identity: String,
|
||||
|
|
@ -28,6 +29,7 @@ pub struct ResolvedAction {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Evidence {
|
||||
pub kind: String,
|
||||
pub digest: String,
|
||||
|
|
@ -36,6 +38,7 @@ pub struct Evidence {
|
|||
/// `target_id` MUST be the locally provisioned RedFlag agent identity. The
|
||||
/// generic name is deliberate: a later protocol may define another namespace.
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MutationManifest {
|
||||
pub protocol_version: u32,
|
||||
pub operation_id: String,
|
||||
|
|
@ -47,6 +50,7 @@ pub struct MutationManifest {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MutationAuthorization {
|
||||
pub protocol_version: u32,
|
||||
pub authorization_id: String,
|
||||
|
|
@ -63,6 +67,7 @@ pub struct MutationAuthorization {
|
|||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MutationEnvelope {
|
||||
pub manifest: MutationManifest,
|
||||
pub authorization: MutationAuthorization,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ try {
|
|||
& go build -trimpath -ldflags "-X github.com/Fimeg/RedFlag/agent/internal/version.Version=$Version" -o (Join-Path $output 'redflag-agent.exe') ./cmd/agent/
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Agent build failed.' }
|
||||
} finally { Pop-Location }
|
||||
Push-Location (Join-Path $root 'helper')
|
||||
$previousVersion = $env:REDFLAG_RELEASE_VERSION
|
||||
try {
|
||||
$env:REDFLAG_RELEASE_VERSION = $Version
|
||||
& cargo build --release --locked
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Helper build failed.' }
|
||||
Copy-Item 'target/release/redflag-helper.exe' $output
|
||||
} finally { $env:REDFLAG_RELEASE_VERSION = $previousVersion; Pop-Location }
|
||||
$desktop = Join-Path $output 'redflag-desktop.exe'
|
||||
& windeployqt --release --qmldir (Join-Path $root 'desktop/qml') --dir $output $desktop
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Qt runtime deployment failed.' }
|
||||
|
|
@ -42,6 +50,7 @@ foreach ($relative in @('Qt6Core.dll', 'Qt6Gui.dll', 'Qt6Qml.dll', 'Qt6Quick.dll
|
|||
}
|
||||
Copy-Item (Join-Path $root 'LICENSE') (Join-Path $output 'LICENSE.txt')
|
||||
Copy-Item (Join-Path $root 'THIRD_PARTY_LICENSES.md') $output
|
||||
Copy-Item (Join-Path $root 'installer/windows/provision-helper.ps1') $output
|
||||
# Remove the build toolchain from DLL lookup for the version check. A clean
|
||||
# Windows VM must additionally launch QML and exercise the Agent pipe.
|
||||
$buildPath = $env:PATH
|
||||
|
|
@ -51,6 +60,33 @@ try {
|
|||
if ($LASTEXITCODE -ne 0 -or $reportedVersion -ne "RedFlag v$expectedVersion") {
|
||||
throw 'Staged Desktop failed its version check without the Qt build PATH.'
|
||||
}
|
||||
$helper = Join-Path $output 'redflag-helper.exe'
|
||||
$helperVersion = & $helper --version
|
||||
if ($LASTEXITCODE -ne 0 -or $helperVersion -ne "RedFlag helper v$expectedVersion") {
|
||||
throw 'Staged Helper failed its version check.'
|
||||
}
|
||||
& $helper execute-envelope
|
||||
if ($LASTEXITCODE -ne 18) {
|
||||
throw 'Windows Helper did not fail closed for unavailable execution.'
|
||||
}
|
||||
# The fixed task entry point takes no runtime arguments, and its exchange
|
||||
# root is not negotiable. Both checks are read-only: this worker carries no
|
||||
# RedFlag installation, so run-request can only fail to open the fixed root.
|
||||
# Neither touches service identity, ACLs, or the task scheduler.
|
||||
& $helper run-request extra-argument
|
||||
if ($LASTEXITCODE -ne 10) {
|
||||
throw 'Windows Helper accepted arguments on its fixed task entry point.'
|
||||
}
|
||||
& $helper run-request
|
||||
if ($LASTEXITCODE -ne 26) {
|
||||
throw 'Windows Helper did not refuse an unprovisioned exchange root.'
|
||||
}
|
||||
$helperRail = & (Join-Path $output 'provision-helper.ps1') -Describe | ConvertFrom-Json
|
||||
if ($helperRail.principal -ne 'SYSTEM' -or
|
||||
$helperRail.runnable_by -ne 'NT SERVICE\RedFlagAgent' -or
|
||||
$helperRail.arguments -ne 'run-request') {
|
||||
throw 'Staged Helper task description does not preserve the fixed authority boundary.'
|
||||
}
|
||||
} finally { $env:PATH = $buildPath }
|
||||
$files = @(Get-ChildItem $output -Recurse -File | Sort-Object FullName | ForEach-Object {
|
||||
[ordered]@{
|
||||
|
|
@ -59,6 +95,6 @@ $files = @(Get-ChildItem $output -Recurse -File | Sort-Object FullName | ForEach
|
|||
size = $_.Length
|
||||
}
|
||||
})
|
||||
[ordered]@{ desktop_version = $reportedVersion; files = $files } |
|
||||
[ordered]@{ desktop_version = $reportedVersion; helper_version = $helperVersion; files = $files } |
|
||||
ConvertTo-Json -Depth 4 | Set-Content (Join-Path $output 'payload.json') -Encoding UTF8
|
||||
Write-Output "Desktop payload staged at $output; this is not yet an installer or an installation test."
|
||||
|
|
|
|||
|
|
@ -10,6 +10,21 @@ spec.loader.exec_module(license_module)
|
|||
|
||||
|
||||
class BuildInputs(unittest.TestCase):
|
||||
def test_helper_task_is_fixed_and_separates_agent_input(self):
|
||||
script = pathlib.Path(__file__).with_name("provision-helper.ps1").read_text()
|
||||
self.assertIn("$serviceAccount = 'NT SERVICE\\RedFlagAgent'", script)
|
||||
self.assertIn("$requestPath = Join-Path $helperState 'requests'", script)
|
||||
self.assertIn("-Execute $helperPath -Argument 'run-request'", script)
|
||||
self.assertIn("$agentService.StartName -ne $serviceAccount", script)
|
||||
self.assertIn("[switch]$MigrateAgentService", script)
|
||||
self.assertIn("Refusing to replace operator-owned Agent service identity", script)
|
||||
self.assertIn("'sidtype', 'RedFlagAgent', 'unrestricted'", script)
|
||||
self.assertIn("'config', 'RedFlagAgent', 'obj=', $serviceAccount", script)
|
||||
self.assertIn("'config', 'RedFlagAgent', 'obj=', 'LocalSystem'", script)
|
||||
self.assertIn("Set-RedFlagDirectoryAcl -Path $requestPath -AgentAccess Modify", script)
|
||||
self.assertIn("Set-RedFlagDirectoryAcl -Path $resultPath -AgentAccess Read", script)
|
||||
self.assertNotIn("$(Arg", script)
|
||||
|
||||
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]
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@ if [[ "$(git rev-parse --is-shallow-repository)" != "false" ]]; then
|
|||
fi
|
||||
git rev-parse --verify "${ref}^{commit}" >/dev/null
|
||||
|
||||
# RedFlag legitimately manages private networks. This gate is deliberately
|
||||
# Casey-specific: it catches known home infrastructure and author metadata,
|
||||
# not every RFC1918 address a fleet-management tool needs in examples/tests.
|
||||
# RedFlag legitimately manages private networks. House topology is advisory in
|
||||
# the surface gate; this legacy check retains private home/identity checks.
|
||||
#
|
||||
# It excludes itself, because it has to write down what it is looking for. That
|
||||
# was invisible while the file arrived in an old commit already on the
|
||||
|
|
@ -26,12 +25,12 @@ git log "$ref" -p --no-ext-diff --no-color --format='@@COMMIT@@%H' \
|
|||
-- . ':(exclude)scripts/check-public-history.sh' |
|
||||
awk '
|
||||
/^@@COMMIT@@/ { sha=substr($0,11); next }
|
||||
/10\.10\.20\.[0-9]{1,3}|172\.16\.42\.[0-9]{1,3}|wiuf-docker|gitea\.wiuf\.net|\/home\/casey/ { print sha }
|
||||
/\/home\/casey/ { print sha }
|
||||
' >"$tmpdir/content"
|
||||
|
||||
git log "$ref" --format='%H%x09%an%x09%ae%x09%cn%x09%ce' |
|
||||
awk -F '\t' '
|
||||
tolower($0) ~ /@wiuf\.net|@wifu\.net|10\.10\.20\.|172\.16\.42\.|\/home\/casey/ { print $1 }
|
||||
tolower($0) ~ /@wiuf\.net|@wifu\.net|\/home\/casey/ { print $1 }
|
||||
' >"$tmpdir/metadata"
|
||||
|
||||
sed -E '/^[[:space:]]*(#|$)/d' "$allowlist" | sort -u >"$tmpdir/allowed"
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ TOKENS_DIR="/var/lib/redflag/agent/tokens"
|
|||
MUTATION_REQUEST_DIR="/var/lib/redflag/agent/mutation-requests"
|
||||
MUTATION_ENVELOPE_DIR="/var/lib/redflag/agent/mutation-envelopes"
|
||||
MUTATION_RECEIPT_DIR="/var/lib/redflag/agent/mutation-receipts"
|
||||
FLEET_JOIN_REQUEST_DIR="/var/lib/redflag/agent/fleet-join-requests"
|
||||
FLEET_JOIN_RECEIPT_DIR="/var/lib/redflag/agent/fleet-join-receipts"
|
||||
SUDOERS_FILE="/etc/sudoers.d/redflag-agent-mint"
|
||||
|
||||
log() { echo "[INFO] [provision] [standalone-authority] $*"; }
|
||||
|
|
@ -66,8 +68,9 @@ install -d -m 0750 -o "$AGENT_USER" -g "$LOCAL_GROUP" "$MINT_REQUEST_DIR"
|
|||
log "mint request dir ready: $MINT_REQUEST_DIR"
|
||||
|
||||
install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" \
|
||||
"$MUTATION_REQUEST_DIR" "$MUTATION_ENVELOPE_DIR" "$MUTATION_RECEIPT_DIR"
|
||||
log "mutation exchange dirs ready"
|
||||
"$MUTATION_REQUEST_DIR" "$MUTATION_ENVELOPE_DIR" "$MUTATION_RECEIPT_DIR" \
|
||||
"$FLEET_JOIN_REQUEST_DIR" "$FLEET_JOIN_RECEIPT_DIR"
|
||||
log "mutation and fleet-join exchange dirs ready"
|
||||
|
||||
# Tokens dir should already exist from the base install; ensure it does.
|
||||
[ -d "$TOKENS_DIR" ] || install -d -m 0700 -o "$AGENT_USER" -g "$AGENT_USER" "$TOKENS_DIR"
|
||||
|
|
@ -91,9 +94,10 @@ cat > "$SUDOERS_FILE" <<EOF
|
|||
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint --request-file $MINT_REQUEST_DIR/* --token-out $TOKENS_DIR/*
|
||||
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN mint-envelope --request-file $MUTATION_REQUEST_DIR/* --envelope-out $MUTATION_ENVELOPE_DIR/*
|
||||
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN execute-envelope --envelope-file $MUTATION_ENVELOPE_DIR/* --receipt-file $MUTATION_RECEIPT_DIR/*
|
||||
$AGENT_USER ALL=(root) NOPASSWD: /usr/bin/systemd-run --wait --property=ProtectSystem=no -- $HELPER_BIN adopt-fleet --request-file $FLEET_JOIN_REQUEST_DIR/* --receipt-file $FLEET_JOIN_RECEIPT_DIR/*
|
||||
EOF
|
||||
chmod 0440 "$SUDOERS_FILE"
|
||||
visudo -c -f "$SUDOERS_FILE" >/dev/null || fail "sudoers validation failed for $SUDOERS_FILE"
|
||||
log "sudoers installed: $SUDOERS_FILE"
|
||||
|
||||
log "standalone authority provisioned — fleet join is not implemented; do not add fleet credentials beside this key"
|
||||
log "standalone authority provisioned — Desktop may retire it through the one-way fleet join"
|
||||
|
|
|
|||
|
|
@ -463,7 +463,14 @@ func runServer(ctx context.Context, ready func(), migrate bool) {
|
|||
registrationTokenHandler := handlers.NewRegistrationTokenHandler(registrationTokenQueries, agentQueries, cfg)
|
||||
var fleetJoinHandler *handlers.FleetJoinHandler
|
||||
if signingService != nil && signingService.IsEnabled() {
|
||||
fleetJoinHandler = handlers.NewFleetJoinHandler(db.DB, registrationTokenQueries, agentQueries, signingService.GetPublicKey(), cfg)
|
||||
fleetJoinHandler = handlers.NewFleetJoinHandler(
|
||||
db.DB,
|
||||
registrationTokenQueries,
|
||||
agentQueries,
|
||||
signingService.GetPublicKey(),
|
||||
signingService.GetCurrentKeyID(),
|
||||
cfg,
|
||||
)
|
||||
}
|
||||
maintenanceWindowHandler := handlers.NewMaintenanceWindowHandler(maintenanceWindowQueries)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,10 +7,14 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/api/middleware"
|
||||
"github.com/Fimeg/RedFlag/server/internal/config"
|
||||
"github.com/Fimeg/RedFlag/server/internal/database/queries"
|
||||
"github.com/Fimeg/RedFlag/server/internal/models"
|
||||
|
|
@ -20,22 +24,26 @@ import (
|
|||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
const fleetJoinRequestMetadataKey = "redflag_fleet_join_request_id"
|
||||
|
||||
// FleetJoinHandler handles standalone-to-fleet migration (SEC-025).
|
||||
type FleetJoinHandler struct {
|
||||
db *sqlx.DB
|
||||
tokenQueries *queries.RegistrationTokenQueries
|
||||
agentQueries *queries.AgentQueries
|
||||
signingPublicKey string
|
||||
signingPublicKey string
|
||||
signingKeyID string
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
// NewFleetJoinHandler creates a FleetJoinHandler.
|
||||
func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, signingPublicKey string, cfg *config.Config) *FleetJoinHandler {
|
||||
func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQueries, agentQueries *queries.AgentQueries, signingPublicKey, signingKeyID string, cfg *config.Config) *FleetJoinHandler {
|
||||
return &FleetJoinHandler{
|
||||
db: db,
|
||||
tokenQueries: tokenQueries,
|
||||
agentQueries: agentQueries,
|
||||
signingPublicKey: signingPublicKey,
|
||||
signingKeyID: signingKeyID,
|
||||
config: cfg,
|
||||
}
|
||||
}
|
||||
|
|
@ -44,27 +52,35 @@ func NewFleetJoinHandler(db *sqlx.DB, tokenQueries *queries.RegistrationTokenQue
|
|||
// seed never crosses this channel — the server holds it encrypted from token
|
||||
// creation and only the 6-digit code travels here.
|
||||
type FleetJoinRequest struct {
|
||||
RegistrationToken string `json:"registration_token" binding:"required"`
|
||||
TOTPCode string `json:"totp_code" binding:"required"`
|
||||
RequestID uuid.UUID `json:"request_id" binding:"required"`
|
||||
AgentID uuid.UUID `json:"agent_id" binding:"required"`
|
||||
RegistrationToken string `json:"registration_token" binding:"required"`
|
||||
TOTPCode string `json:"totp_code" binding:"required"`
|
||||
|
||||
// Standard agent registration fields (same as RegisterAgent).
|
||||
Hostname string `json:"hostname" binding:"required"`
|
||||
OSType string `json:"os_type" binding:"required"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
MachineID string `json:"machine_id"`
|
||||
PublicKeyFingerprint string `json:"public_key_fingerprint"`
|
||||
AvailableScanners []string `json:"available_scanners"`
|
||||
Hostname string `json:"hostname" binding:"required"`
|
||||
OSType string `json:"os_type" binding:"required"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
MachineID string `json:"machine_id"`
|
||||
PublicKeyFingerprint string `json:"public_key_fingerprint"`
|
||||
AvailableScanners []string `json:"available_scanners"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
DeviceType string `json:"device_type"`
|
||||
DeviceModel string `json:"device_model"`
|
||||
OSDistro string `json:"os_distro"`
|
||||
}
|
||||
|
||||
// FleetJoinResponse is the server's response on successful fleet join.
|
||||
type FleetJoinResponse struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
SigningPublicKey string `json:"signing_public_key"`
|
||||
ServerURL string `json:"server_url"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
SigningPublicKey string `json:"signing_public_key"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// JoinFleet handles POST /api/v1/fleet-join. Validates the registration token
|
||||
|
|
@ -75,9 +91,13 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.RequestID == uuid.Nil || req.AgentID == uuid.Nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "request_id and agent_id must be UUIDs"})
|
||||
return
|
||||
}
|
||||
|
||||
// Step 1: Validate registration token.
|
||||
tokenInfo, err := h.tokenQueries.ValidateRegistrationToken(req.RegistrationToken)
|
||||
tokenInfo, err := h.tokenQueries.GetFleetJoinToken(req.RegistrationToken)
|
||||
if err != nil || tokenInfo == nil {
|
||||
log.Printf("[SECURITY] [server] [fleet-join] invalid_token error=%v", err)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid or expired registration token"})
|
||||
|
|
@ -90,6 +110,12 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
c.JSON(http.StatusBadRequest, gin.H{"error": "this token does not require 2FA — use the standard registration endpoint"})
|
||||
return
|
||||
}
|
||||
var tokenMetadata map[string]interface{}
|
||||
if err := json.Unmarshal(tokenInfo.Metadata, &tokenMetadata); err != nil || tokenMetadata["fleet_join"] != true {
|
||||
log.Printf("[SECURITY] [server] [fleet-join] wrong_token_class token_id=%s", tokenInfo.ID)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "this token is not a fleet join key"})
|
||||
return
|
||||
}
|
||||
|
||||
// Step 3: Validate the TOTP code against the server-held seed. The seed
|
||||
// was stored encrypted at token creation and never crosses this channel;
|
||||
|
|
@ -105,11 +131,49 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid TOTP code"})
|
||||
return
|
||||
}
|
||||
recovering, err := h.tokenQueries.FleetJoinTokenUsedBy(tokenInfo.ID, req.AgentID)
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [server] [fleet-join] token_usage_lookup_failed request_id=%s error=%q", req.RequestID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
|
||||
return
|
||||
}
|
||||
seatAvailable := tokenInfo.Status == "active" && tokenInfo.SeatsUsed < tokenInfo.MaxSeats
|
||||
if !seatAvailable && !recovering {
|
||||
log.Printf("[SECURITY] [server] [fleet-join] spent_token_wrong_agent request_id=%s agent_id=%s", req.RequestID, req.AgentID)
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "fleet join token belongs to another Agent"})
|
||||
return
|
||||
}
|
||||
|
||||
existingByID, idErr := h.agentQueries.GetAgentByID(req.AgentID)
|
||||
if recovering {
|
||||
if idErr != nil || existingByID == nil {
|
||||
log.Printf("[ERROR] [server] [fleet-join] recovery_agent_missing request_id=%s agent_id=%s error=%v", req.RequestID, req.AgentID, idErr)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "fleet join recovery cannot find the registered Agent"})
|
||||
return
|
||||
}
|
||||
if req.MachineID != "" && (existingByID.MachineID == nil || *existingByID.MachineID != req.MachineID) {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "fleet join recovery machine ID mismatch"})
|
||||
return
|
||||
}
|
||||
storedRequestID, _ := existingByID.Metadata[fleetJoinRequestMetadataKey].(string)
|
||||
if storedRequestID == "" || storedRequestID != req.RequestID.String() {
|
||||
log.Printf("[SECURITY] [server] [fleet-join] recovery_request_mismatch request_id=%s agent_id=%s", req.RequestID, req.AgentID)
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "fleet join recovery request does not match the accepted transaction"})
|
||||
return
|
||||
}
|
||||
} else if idErr == nil {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "Agent ID is already registered"})
|
||||
return
|
||||
} else if !errors.Is(idErr, sql.ErrNoRows) {
|
||||
log.Printf("[ERROR] [server] [fleet-join] agent_lookup_failed request_id=%s error=%q", req.RequestID, idErr)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
|
||||
return
|
||||
}
|
||||
|
||||
// Step 5: Check machine ID isn't already registered (same as RegisterAgent).
|
||||
if req.MachineID != "" {
|
||||
existing, err := h.agentQueries.GetAgentByMachineID(req.MachineID)
|
||||
if err == nil && existing != nil && existing.ID.String() != "" {
|
||||
if err == nil && existing != nil && existing.ID != req.AgentID {
|
||||
c.JSON(http.StatusConflict, gin.H{
|
||||
"error": "machine ID already registered to another agent",
|
||||
"existing_agent_id": existing.ID.String(),
|
||||
|
|
@ -139,9 +203,21 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
if req.PublicKeyFingerprint != "" {
|
||||
pubKeyFP = &req.PublicKeyFingerprint
|
||||
}
|
||||
deviceType := req.DeviceType
|
||||
if !models.ValidDeviceType(deviceType) {
|
||||
deviceType = "server"
|
||||
}
|
||||
var deviceModel *string
|
||||
if req.DeviceModel != "" {
|
||||
deviceModel = &req.DeviceModel
|
||||
}
|
||||
var osDistro *string
|
||||
if req.OSDistro != "" {
|
||||
osDistro = &req.OSDistro
|
||||
}
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
ID: req.AgentID,
|
||||
Hostname: req.Hostname,
|
||||
OSType: req.OSType,
|
||||
OSVersion: req.OSVersion,
|
||||
|
|
@ -150,6 +226,9 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
CurrentVersion: req.AgentVersion,
|
||||
MachineID: machineID,
|
||||
PublicKeyFingerprint: pubKeyFP,
|
||||
DeviceType: deviceType,
|
||||
DeviceModel: deviceModel,
|
||||
OSDistro: osDistro,
|
||||
LastSeen: time.Now().UTC(),
|
||||
Status: "online",
|
||||
Metadata: models.JSONB{},
|
||||
|
|
@ -160,30 +239,75 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
agent.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
createQuery := `
|
||||
INSERT INTO agents (
|
||||
id, hostname, os_type, os_version, os_architecture,
|
||||
agent_version, current_version, machine_id, public_key_fingerprint,
|
||||
last_seen, status, metadata
|
||||
) VALUES (
|
||||
:id, :hostname, :os_type, :os_version, :os_architecture,
|
||||
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
||||
:last_seen, :status, :metadata
|
||||
)`
|
||||
if _, err := tx.NamedExec(createQuery, agent); err != nil {
|
||||
log.Printf("[ERROR] [server] [fleet-join] create_agent_failed error=%q", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"})
|
||||
return
|
||||
if recovering {
|
||||
for k, v := range existingByID.Metadata {
|
||||
if _, exists := agent.Metadata[k]; !exists {
|
||||
agent.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
if agent.MachineID == nil {
|
||||
agent.MachineID = existingByID.MachineID
|
||||
}
|
||||
if agent.PublicKeyFingerprint == nil {
|
||||
agent.PublicKeyFingerprint = existingByID.PublicKeyFingerprint
|
||||
}
|
||||
if req.DeviceType == "" {
|
||||
agent.DeviceType = existingByID.DeviceType
|
||||
}
|
||||
if agent.DeviceModel == nil {
|
||||
agent.DeviceModel = existingByID.DeviceModel
|
||||
}
|
||||
if agent.OSDistro == nil {
|
||||
agent.OSDistro = existingByID.OSDistro
|
||||
}
|
||||
}
|
||||
agent.Metadata[fleetJoinRequestMetadataKey] = req.RequestID.String()
|
||||
|
||||
// Mark token as used.
|
||||
var tokenSuccess bool
|
||||
tokenHash := queries.HashRegistrationToken(req.RegistrationToken)
|
||||
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", tokenHash, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
|
||||
log.Printf("[ERROR] [server] [fleet-join] mark_token_failed error=%v success=%v", err, tokenSuccess)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed"})
|
||||
return
|
||||
if recovering {
|
||||
updateQuery := `
|
||||
UPDATE agents SET
|
||||
hostname = :hostname, os_type = :os_type, os_version = :os_version,
|
||||
os_architecture = :os_architecture, agent_version = :agent_version,
|
||||
current_version = :current_version, machine_id = :machine_id,
|
||||
public_key_fingerprint = :public_key_fingerprint,
|
||||
device_type = :device_type, device_model = :device_model,
|
||||
os_distro = :os_distro, last_seen = :last_seen,
|
||||
status = :status, metadata = :metadata
|
||||
WHERE id = :id`
|
||||
if _, err := tx.NamedExec(updateQuery, agent); err != nil {
|
||||
log.Printf("[ERROR] [server] [fleet-join] recover_agent_failed request_id=%s error=%q", req.RequestID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to recover fleet join"})
|
||||
return
|
||||
}
|
||||
if _, err := tx.Exec("UPDATE refresh_tokens SET revoked = TRUE WHERE agent_id = $1 AND NOT revoked", agent.ID); err != nil {
|
||||
log.Printf("[ERROR] [server] [fleet-join] recover_revoke_tokens_failed request_id=%s error=%q", req.RequestID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to recover fleet join"})
|
||||
return
|
||||
}
|
||||
} else {
|
||||
createQuery := `
|
||||
INSERT INTO agents (
|
||||
id, hostname, os_type, os_version, os_architecture,
|
||||
agent_version, current_version, machine_id, public_key_fingerprint,
|
||||
device_type, device_model, os_distro, last_seen, status, metadata
|
||||
) VALUES (
|
||||
:id, :hostname, :os_type, :os_version, :os_architecture,
|
||||
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
||||
:device_type, :device_model, :os_distro, :last_seen, :status, :metadata
|
||||
)`
|
||||
if _, err := tx.NamedExec(createQuery, agent); err != nil {
|
||||
log.Printf("[ERROR] [server] [fleet-join] create_agent_failed request_id=%s error=%q", req.RequestID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to register agent"})
|
||||
return
|
||||
}
|
||||
|
||||
var tokenSuccess bool
|
||||
tokenHash := queries.HashRegistrationToken(req.RegistrationToken)
|
||||
if err := tx.QueryRow("SELECT mark_registration_token_used($1, $2)", tokenHash, agent.ID).Scan(&tokenSuccess); err != nil || !tokenSuccess {
|
||||
log.Printf("[ERROR] [server] [fleet-join] mark_token_failed request_id=%s error=%v success=%v", req.RequestID, err, tokenSuccess)
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "registration token could not be consumed"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Generate refresh token.
|
||||
|
|
@ -202,36 +326,53 @@ func (h *FleetJoinHandler) JoinFleet(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Create subsystems for available scanners.
|
||||
if len(req.AvailableScanners) > 0 {
|
||||
for _, scanner := range req.AvailableScanners {
|
||||
// Initial join creates the same observed scanner rows as registration.
|
||||
if !recovering {
|
||||
scanners := append([]string{}, req.AvailableScanners...)
|
||||
scanners = append(scanners, "storage", "system")
|
||||
for _, scanner := range scanners {
|
||||
interval := 60
|
||||
if scanner == "storage" || scanner == "system" {
|
||||
interval = 5
|
||||
}
|
||||
sub := models.AgentSubsystem{
|
||||
AgentID: agent.ID,
|
||||
Subsystem: scanner,
|
||||
Enabled: true,
|
||||
AutoRun: true,
|
||||
IntervalMinutes: 60,
|
||||
IntervalMinutes: interval,
|
||||
}
|
||||
subQuery := `INSERT INTO agent_subsystems (agent_id, subsystem, enabled, auto_run, interval_minutes) VALUES (:agent_id, :subsystem, :enabled, :auto_run, :interval_minutes)`
|
||||
subQuery := `INSERT INTO agent_subsystems (agent_id, subsystem, enabled, auto_run, interval_minutes) VALUES (:agent_id, :subsystem, :enabled, :auto_run, :interval_minutes) ON CONFLICT (agent_id, subsystem) DO NOTHING`
|
||||
if _, err := tx.NamedExec(subQuery, sub); err != nil {
|
||||
log.Printf("[WARNING] [server] [fleet-join] create_subsystem_failed scanner=%s error=%q", scanner, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
accessToken, err := middleware.GenerateAgentToken(agent.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate access token"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
log.Printf("[ERROR] [server] [fleet-join] commit_failed error=%q", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "fleet join failed"})
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [fleet-join] agent_registered agent_id=%s hostname=%s machine_id=%s",
|
||||
agent.ID, agent.Hostname, req.MachineID)
|
||||
log.Printf("[INFO] [server] [fleet-join] completed request_id=%s agent_id=%s hostname=%s recovered=%v",
|
||||
req.RequestID, agent.ID, agent.Hostname, recovering)
|
||||
|
||||
c.JSON(http.StatusCreated, FleetJoinResponse{
|
||||
AgentID: agent.ID,
|
||||
Token: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
SigningPublicKey: h.signingPublicKey,
|
||||
SigningKeyID: h.signingKeyID,
|
||||
ServerURL: resolveServerURL(c, h.config, "fleet-join"),
|
||||
Config: map[string]interface{}{
|
||||
"check_in_interval": h.config.CheckInInterval,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/server/internal/config"
|
||||
|
|
@ -44,10 +45,10 @@ func NewRegistrationTokenHandler(tokenQueries *queries.RegistrationTokenQueries,
|
|||
// GenerateRegistrationToken creates a new registration token
|
||||
func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
||||
var request struct {
|
||||
Label string `json:"label" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Label string `json:"label" binding:"required"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d", "168h"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
// FleetJoin + TOTPSeed: SEC-025 fleet-join 2FA. When the operator
|
||||
// creates a fleet-join token, they enter the host's TOTP seed
|
||||
// (displayed on the standalone host). This is the one admin-
|
||||
|
|
@ -110,18 +111,23 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
|||
}
|
||||
metadata["server_url"] = resolveServerURL(c, h.config, "registration-tokens")
|
||||
metadata["expires_in"] = expiresIn
|
||||
metadata["fleet_join"] = request.FleetJoin
|
||||
|
||||
// Default max_seats to 1 if not provided or invalid
|
||||
maxSeats := request.MaxSeats
|
||||
if maxSeats < 1 {
|
||||
maxSeats = 1
|
||||
}
|
||||
if request.FleetJoin {
|
||||
maxSeats = 1
|
||||
}
|
||||
|
||||
// Store token in database. For fleet-join tokens (SEC-025), the operator
|
||||
// provides the TOTP seed from the standalone host; it must be valid
|
||||
// base32 or neither an authenticator nor join validation can use it.
|
||||
var totpSeed string
|
||||
if request.FleetJoin {
|
||||
request.TOTPSeed = strings.ToUpper(strings.TrimSpace(request.TOTPSeed))
|
||||
if request.TOTPSeed == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "fleet-join tokens require a TOTP seed"})
|
||||
return
|
||||
|
|
@ -145,6 +151,9 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
|||
// SEC-002: token travels in a header, never the URL — query strings land in
|
||||
// shell history, process lists, and access logs.
|
||||
installCommand := fmt.Sprintf("curl -sfL -H \"X-Registration-Token: %s\" \"%s/api/v1/install/linux\" | sudo bash", token, serverURL)
|
||||
if request.FleetJoin {
|
||||
installCommand = ""
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"token": token,
|
||||
|
|
@ -152,6 +161,7 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
|||
"expires_at": expiresAt,
|
||||
"install_command": installCommand,
|
||||
"metadata": metadata,
|
||||
"fleet_join": request.FleetJoin,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, response)
|
||||
|
|
|
|||
|
|
@ -29,35 +29,35 @@ type RegistrationTokenQueries struct {
|
|||
}
|
||||
|
||||
type RegistrationToken struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
TokenHash string `json:"-" db:"token_hash"`
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
TokenHash string `json:"-" db:"token_hash"`
|
||||
// TokenEncrypted is the AES-256-GCM ciphertext (nonce||ct) of the plaintext
|
||||
// token. Never serialised; decrypted into Token for live tokens only.
|
||||
TokenEncrypted []byte `json:"-" db:"token_encrypted"`
|
||||
TokenEncrypted []byte `json:"-" db:"token_encrypted"`
|
||||
// Token is the decrypted plaintext, populated only for live tokens so the
|
||||
// operator UI can rebuild the install one-liner. Empty for spent/expired/
|
||||
// revoked tokens and for legacy one-way-hashed tokens.
|
||||
Token string `json:"token,omitempty" db:"-"`
|
||||
Label *string `json:"label" db:"label"`
|
||||
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UsedAt *time.Time `json:"used_at" db:"used_at"`
|
||||
UsedByAgentID *uuid.UUID `json:"used_by_agent_id" db:"used_by_agent_id"`
|
||||
Revoked bool `json:"revoked" db:"revoked"`
|
||||
RevokedAt *time.Time `json:"revoked_at" db:"revoked_at"`
|
||||
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
|
||||
Status string `json:"status" db:"status"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
Metadata json.RawMessage `json:"metadata" db:"metadata"`
|
||||
MaxSeats int `json:"max_seats" db:"max_seats"`
|
||||
SeatsUsed int `json:"seats_used" db:"seats_used"`
|
||||
Token string `json:"token,omitempty" db:"-"`
|
||||
Label *string `json:"label" db:"label"`
|
||||
ExpiresAt time.Time `json:"expires_at" db:"expires_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UsedAt *time.Time `json:"used_at" db:"used_at"`
|
||||
UsedByAgentID *uuid.UUID `json:"used_by_agent_id" db:"used_by_agent_id"`
|
||||
Revoked bool `json:"revoked" db:"revoked"`
|
||||
RevokedAt *time.Time `json:"revoked_at" db:"revoked_at"`
|
||||
RevokedReason *string `json:"revoked_reason" db:"revoked_reason"`
|
||||
Status string `json:"status" db:"status"`
|
||||
CreatedBy string `json:"created_by" db:"created_by"`
|
||||
Metadata json.RawMessage `json:"metadata" db:"metadata"`
|
||||
MaxSeats int `json:"max_seats" db:"max_seats"`
|
||||
SeatsUsed int `json:"seats_used" db:"seats_used"`
|
||||
// TOTPSeedEncrypted is the TOTP seed for fleet-join 2FA (SEC-025), stored
|
||||
// as AES-256-GCM ciphertext (nonce||ct) of the base32 seed. Nil for
|
||||
// standard registration tokens; set only when the operator creates a
|
||||
// fleet-join token that requires the standalone host to prove possession
|
||||
// of the seed via a TOTP code. Never serialised; decrypted only at join
|
||||
// validation. The join request carries the code, never the seed.
|
||||
TOTPSeedEncrypted []byte `json:"-" db:"totp_seed_encrypted"`
|
||||
TOTPSeedEncrypted []byte `json:"-" db:"totp_seed_encrypted"`
|
||||
}
|
||||
|
||||
// isLive reports whether a token is still usable for enrollment — the only state
|
||||
|
|
@ -81,17 +81,17 @@ func (q *RegistrationTokenQueries) revealPlaintext(t *RegistrationToken) {
|
|||
}
|
||||
|
||||
type TokenRequest struct {
|
||||
Label string `json:"label"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token (default: 1)
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
Label string `json:"label"`
|
||||
ExpiresIn string `json:"expires_in"` // e.g., "24h", "7d"
|
||||
MaxSeats int `json:"max_seats"` // Number of agents that can use this token (default: 1)
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
}
|
||||
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
InstallCommand string `json:"install_command"`
|
||||
Token string `json:"token"`
|
||||
Label string `json:"label"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
InstallCommand string `json:"install_command"`
|
||||
}
|
||||
|
||||
// NewRegistrationTokenQueries builds the query handle. encKeyB64 is the base64
|
||||
|
|
@ -205,6 +205,45 @@ func (q *RegistrationTokenQueries) ValidateRegistrationToken(token string) (*Reg
|
|||
return ®Token, nil
|
||||
}
|
||||
|
||||
// GetFleetJoinToken resolves the raw join secret even after its one seat was
|
||||
// consumed. The fleet-join handler uses that spent state only to recover the
|
||||
// same Agent ID after a response was lost; standard enrollment never calls it.
|
||||
func (q *RegistrationTokenQueries) GetFleetJoinToken(token string) (*RegistrationToken, error) {
|
||||
var regToken RegistrationToken
|
||||
query := `
|
||||
SELECT id, token_hash, label, expires_at, created_at, used_at, used_by_agent_id,
|
||||
revoked, revoked_at, revoked_reason, status, created_by, metadata,
|
||||
max_seats, seats_used, totp_seed_encrypted
|
||||
FROM registration_tokens
|
||||
WHERE token_hash = $1
|
||||
AND status IN ('active', 'used')
|
||||
AND NOT revoked
|
||||
AND expires_at > NOW()
|
||||
AND totp_seed_encrypted IS NOT NULL
|
||||
`
|
||||
if err := q.db.Get(®Token, query, HashRegistrationToken(token)); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("invalid, expired, or revoked fleet join token")
|
||||
}
|
||||
return nil, fmt.Errorf("failed to resolve fleet join token: %w", err)
|
||||
}
|
||||
return ®Token, nil
|
||||
}
|
||||
|
||||
func (q *RegistrationTokenQueries) FleetJoinTokenUsedBy(tokenID, agentID uuid.UUID) (bool, error) {
|
||||
var used bool
|
||||
err := q.db.Get(&used, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM registration_token_usage
|
||||
WHERE token_id = $1 AND agent_id = $2
|
||||
)
|
||||
`, tokenID, agentID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to resolve fleet join token use: %w", err)
|
||||
}
|
||||
return used, nil
|
||||
}
|
||||
|
||||
// MarkTokenUsed marks a token as used by an agent.
|
||||
// The caller passes the plaintext token; this function hashes it before calling
|
||||
// the stored procedure (which now matches on token_hash).
|
||||
|
|
@ -448,4 +487,4 @@ func (q *RegistrationTokenQueries) GetTokenUsageStats() (map[string]int, error)
|
|||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,12 +26,22 @@ func TestGo_RunsAllSubmittedTasks(t *testing.T) {
|
|||
if got != n {
|
||||
t.Fatalf("ran %d tasks, want %d", got, n)
|
||||
}
|
||||
s := r.Snapshot()
|
||||
if s.Submitted != n {
|
||||
if s := r.Snapshot(); s.Submitted != n {
|
||||
t.Errorf("submitted=%d, want %d", s.Submitted, n)
|
||||
}
|
||||
if s.Completed != n {
|
||||
t.Errorf("completed=%d, want %d", s.Completed, n)
|
||||
|
||||
// Completed is incremented after the task body returns, so the WaitGroup
|
||||
// above cannot cover it: the last worker may still be accounting when Wait
|
||||
// releases. Wait for the counter actually asserted, as the panic test does.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if r.Snapshot().Completed >= n {
|
||||
break
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
if c := r.Snapshot().Completed; c != n {
|
||||
t.Errorf("completed=%d, want %d", c, n)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -204,13 +204,15 @@ const AgentsEnrollment: React.FC = () => {
|
|||
const [installPlatform, setInstallPlatform] = useState<string>('linux');
|
||||
const [installTokenId, setInstallTokenId] = useState<string>('');
|
||||
const [copiedCommand, setCopiedCommand] = useState<string | null>(null);
|
||||
const [createdToken, setCreatedToken] = useState<{ token: string; label: string } | null>(null);
|
||||
const [createdToken, setCreatedToken] = useState<{ token: string; label: string; fleetJoin: boolean } | null>(null);
|
||||
|
||||
// Create-key form
|
||||
const [formData, setFormData] = useState<CreateRegistrationTokenRequest>({
|
||||
label: '',
|
||||
expires_in: '168h',
|
||||
max_seats: 1,
|
||||
fleet_join: false,
|
||||
totp_seed: '',
|
||||
});
|
||||
|
||||
// Signing keys (preserved from the old Agent Management page — now its own
|
||||
|
|
@ -249,7 +251,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
const availableTokens = React.useMemo(
|
||||
() =>
|
||||
allTokens.filter(
|
||||
(t) => !t.revoked && t.status === 'active' && t.seats_used < t.max_seats,
|
||||
(t) => !t.revoked && t.status === 'active' && t.seats_used < t.max_seats && !t.metadata?.fleet_join,
|
||||
),
|
||||
[allTokens],
|
||||
);
|
||||
|
|
@ -293,11 +295,17 @@ const AgentsEnrollment: React.FC = () => {
|
|||
|
||||
const handleCreateToken = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
createToken.mutate(formData, {
|
||||
const request: CreateRegistrationTokenRequest = {
|
||||
...formData,
|
||||
max_seats: formData.fleet_join ? 1 : formData.max_seats,
|
||||
totp_seed: formData.fleet_join ? formData.totp_seed?.trim().toUpperCase() : undefined,
|
||||
};
|
||||
createToken.mutate(request, {
|
||||
onSuccess: (data: any) => {
|
||||
const label = formData.label || data.label;
|
||||
setFormData({ label: '', expires_in: '168h', max_seats: 1 });
|
||||
setCreatedToken({ token: data.token, label });
|
||||
const fleetJoin = Boolean(formData.fleet_join);
|
||||
setFormData({ label: '', expires_in: '168h', max_seats: 1, fleet_join: false, totp_seed: '' });
|
||||
setCreatedToken({ token: data.token, label, fleetJoin });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
|
@ -435,8 +443,10 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<div className="space-y-4">
|
||||
<div className="alert alert-success flex items-start justify-between gap-3">
|
||||
<p className="text-sm text-success-800">
|
||||
<span className="font-medium">Key "{createdToken.label}" created.</span> Copy the
|
||||
token now — it cannot be retrieved again, only a hash is stored.
|
||||
<span className="font-medium">Key "{createdToken.label}" created.</span>{' '}
|
||||
{createdToken.fleetJoin
|
||||
? 'Return to the standalone Desktop and enter this token.'
|
||||
: 'Copy the token now, then choose the target platform and copy its install command.'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setCreatedToken(null)}
|
||||
|
|
@ -460,13 +470,21 @@ const AgentsEnrollment: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
|
||||
<InstallCommandBox
|
||||
command={installCommand}
|
||||
platform={installPlatform}
|
||||
copied={copiedCommand === 'install'}
|
||||
onCopy={() => copyToClipboard(installCommand, 'install')}
|
||||
/>
|
||||
{createdToken.fleetJoin ? (
|
||||
<div className="rounded-lg border border-primary-200 bg-primary-50 p-4 text-sm text-primary-900">
|
||||
This one-seat key can join only the standalone Agent whose host seed you entered. It is not an install key.
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<PlatformPicker value={installPlatform} onChange={setInstallPlatform} />
|
||||
<InstallCommandBox
|
||||
command={installCommand}
|
||||
platform={installPlatform}
|
||||
copied={copiedCommand === 'install'}
|
||||
onCopy={() => copyToClipboard(installCommand, 'install')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -551,6 +569,18 @@ const AgentsEnrollment: React.FC = () => {
|
|||
)
|
||||
) : (
|
||||
<form onSubmit={handleCreateToken} className="space-y-4">
|
||||
<label className="flex items-start gap-3 rounded-lg border border-gray-200 bg-gray-50 p-4 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(formData.fleet_join)}
|
||||
onChange={(e) => setFormData({ ...formData, fleet_join: e.target.checked, max_seats: e.target.checked ? 1 : formData.max_seats })}
|
||||
className="mt-1"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-sm font-medium text-gray-900">Join an existing standalone machine</span>
|
||||
<span className="block text-xs text-gray-500 mt-1">Creates one key for one existing Agent ID. Generate the host seed in RedFlag Desktop first.</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Label *</label>
|
||||
|
|
@ -584,13 +614,29 @@ const AgentsEnrollment: React.FC = () => {
|
|||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
value={formData.max_seats || 1}
|
||||
value={formData.fleet_join ? 1 : formData.max_seats || 1}
|
||||
disabled={Boolean(formData.fleet_join)}
|
||||
onChange={(e) => setFormData({ ...formData, max_seats: parseInt(e.target.value) || 1 })}
|
||||
className="form-input"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Number of agents that can enroll with this key</p>
|
||||
<p className="mt-1 text-xs text-gray-500">{formData.fleet_join ? 'Fleet join keys always have one seat' : 'Number of agents that can enroll with this key'}</p>
|
||||
</div>
|
||||
</div>
|
||||
{formData.fleet_join && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">Standalone host seed *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={formData.totp_seed || ''}
|
||||
onChange={(e) => setFormData({ ...formData, totp_seed: e.target.value.toUpperCase().replace(/\s/g, '') })}
|
||||
placeholder="Paste the seed shown by RedFlag Desktop"
|
||||
className="form-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<button type="submit" disabled={createToken.isPending} className="btn-primary">
|
||||
{createToken.isPending ? 'Creating...' : 'Create key'}
|
||||
</button>
|
||||
|
|
@ -690,6 +736,7 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<div className="mt-1 flex items-center gap-1.5 text-xs text-gray-500">
|
||||
<span className={cn('inline-block w-2 h-2 rounded-full', STATUS_DOT[t.status] || 'bg-gray-400')} />
|
||||
<span>{getStatusText(t)}</span>
|
||||
{t.metadata?.fleet_join && <span className="text-primary-700 font-medium">Fleet join</span>}
|
||||
<span>·</span>
|
||||
<span>
|
||||
{t.seats_used}/{t.max_seats} seats
|
||||
|
|
@ -728,10 +775,13 @@ const AgentsEnrollment: React.FC = () => {
|
|||
<span className={cn('badge badge-lg', tokenStatusColor(selectedToken.status))}>
|
||||
{getStatusText(selectedToken)}
|
||||
</span>
|
||||
{selectedToken.metadata?.fleet_join && (
|
||||
<span className="badge badge-lg ml-2 bg-primary-100 text-primary-800">Fleet join</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{selectedToken.status === 'active' && selectedToken.seats_used < selectedToken.max_seats && (
|
||||
{selectedToken.status === 'active' && selectedToken.seats_used < selectedToken.max_seats && !selectedToken.metadata?.fleet_join && (
|
||||
<button
|
||||
onClick={() => installWithKey(selectedToken.id)}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm text-primary-700 bg-primary-50 border border-primary-200 rounded-md hover:bg-primary-100"
|
||||
|
|
|
|||
|
|
@ -525,6 +525,8 @@ export interface CreateRegistrationTokenRequest {
|
|||
expires_in?: string;
|
||||
max_seats?: number;
|
||||
metadata?: Record<string, any>;
|
||||
fleet_join?: boolean;
|
||||
totp_seed?: string;
|
||||
}
|
||||
|
||||
export interface RegistrationTokenStats {
|
||||
|
|
|
|||
Loading…
Reference in a new issue