feat: component manifest drives installs, checkoff, and desktop lockstep
INSTALL-001: manifest schema with components+artifacts, CI generation in release gate, manifest-driven install template with --guided and --checkoff modes, post-install provisioning checks, desktop joins version lockstep (bump-version.sh + CI build + gate enforcement). Setup.tsx reduced to primitives (FormSection, TextField, Alert).
This commit is contained in:
parent
0669b4d6b1
commit
88b612c77e
6 changed files with 805 additions and 663 deletions
|
|
@ -53,6 +53,26 @@ jobs:
|
|||
FAIL=1
|
||||
fi
|
||||
|
||||
# Desktop: optional component, but if present, its versions must match.
|
||||
# desktop/Cargo.toml holds the Rust crate version (3-part semver);
|
||||
# desktop/tauri.conf.json holds the display version (4-octet).
|
||||
if [ -f desktop/Cargo.toml ]; then
|
||||
DESKTOP_CARGO_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2)
|
||||
echo "desktop/Cargo.toml: version=$DESKTOP_CARGO_VER"
|
||||
if [ "$TAG_SEMVER" != "$DESKTOP_CARGO_VER" ]; then
|
||||
echo "::error::tag=$TAG (semver=$TAG_SEMVER) but desktop/Cargo.toml=$DESKTOP_CARGO_VER"
|
||||
FAIL=1
|
||||
fi
|
||||
fi
|
||||
if [ -f desktop/tauri.conf.json ]; then
|
||||
TAURI_VER=$(grep -oP '"version"\s*:\s*"\K[^"]+' desktop/tauri.conf.json | head -1 || echo "none")
|
||||
echo "desktop/tauri.conf.json: version=$TAURI_VER"
|
||||
if [ "$TAG" != "$TAURI_VER" ]; then
|
||||
echo "::error::tag=$TAG but desktop/tauri.conf.json=$TAURI_VER"
|
||||
FAIL=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# CHANGELOG must mention the version being released.
|
||||
if ! grep -q "$TAG" CHANGELOG.md; then
|
||||
echo "::error::CHANGELOG.md has no entry for $TAG"
|
||||
|
|
@ -78,6 +98,64 @@ jobs:
|
|||
|
||||
exit $FAIL
|
||||
|
||||
- name: Component catalog gate
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG=${GITHUB_REF#refs/tags/v}
|
||||
FAIL=0
|
||||
|
||||
# The component catalog in server/internal/services/release_manifest.go
|
||||
# is the authoritative list. Every required component must have source in
|
||||
# the tree — the build jobs will verify binaries and the publish job will
|
||||
# verify artifact presence, but this gate catches "we said we build X but
|
||||
# X's directory doesn't exist" before any compile time is spent.
|
||||
|
||||
echo "Checking component catalog coverage..."
|
||||
|
||||
# Agent: must have agent/cmd/ and go.mod
|
||||
if [ ! -d agent/cmd/agent ] || [ ! -f agent/go.mod ]; then
|
||||
echo "::error::component 'agent' required but agent/cmd/ or go.mod missing"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Server: must have server/cmd/ and go.mod
|
||||
if [ ! -d server/cmd/server ] || [ ! -f server/go.mod ]; then
|
||||
echo "::error::component 'server' required but server/cmd/ or go.mod missing"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Helper: must have helper/Cargo.toml
|
||||
if [ ! -f helper/Cargo.toml ]; then
|
||||
echo "::error::component 'helper' required but helper/Cargo.toml missing"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Web: must have web/package.json
|
||||
if [ ! -f web/package.json ]; then
|
||||
echo "::error::component 'web' required but web/package.json missing"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
# Desktop: optional, but if present, verify its source exists
|
||||
if [ -d desktop ]; then
|
||||
if [ -f desktop/Cargo.toml ]; then
|
||||
DESKTOP_VER=$(grep -m1 '^version' desktop/Cargo.toml | cut -d'"' -f2 || echo "")
|
||||
echo "desktop/Cargo.toml: version=$DESKTOP_VER"
|
||||
if [ -n "$DESKTOP_VER" ] && [ "$DESKTOP_VER" != "0.1.0" ]; then
|
||||
echo "[INFO] [gate] desktop version is $DESKTOP_VER (not 0.1.0 stub — version lockstep effective)"
|
||||
fi
|
||||
fi
|
||||
if [ -f desktop/tauri.conf.json ]; then
|
||||
TAURI_VER=$(grep -oP '"version"\s*:\s*"\K[^"]+' desktop/tauri.conf.json | head -1 || echo "")
|
||||
echo "desktop/tauri.conf.json: version=$TAURI_VER"
|
||||
fi
|
||||
else
|
||||
echo "[INFO] [gate] desktop component optional — no desktop/ directory, skipping"
|
||||
fi
|
||||
|
||||
echo "Component catalog gate: OK"
|
||||
exit $FAIL
|
||||
|
||||
# Build the web UI once — it's the same embed for every platform.
|
||||
web:
|
||||
runs-on: ubuntu-latest
|
||||
|
|
@ -209,6 +287,31 @@ jobs:
|
|||
if [ "${{ matrix.goos }}" = "windows" ]; then EXT=".exe"; fi
|
||||
cp target/${{ matrix.rust_target }}/release/redflag-helper${EXT} ../dist/redflag-helper-${{ matrix.suffix }}${EXT}
|
||||
|
||||
- name: Build desktop (Tauri system tray)
|
||||
if: matrix.goos == 'linux' && matrix.goarch == 'amd64'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
|
||||
# Tauri v2 system dependencies (webkit2gtk-4.1 for Ubuntu 24.04+).
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev 2>/dev/null || true
|
||||
|
||||
# Build the desktop frontend (Tauri's beforeBuildCommand, but we do it
|
||||
# explicitly so the web build is deterministic).
|
||||
cd web && npm ci --silent && npm run build:desktop && cd ..
|
||||
|
||||
# Build the desktop binary.
|
||||
cd desktop
|
||||
cargo build --release
|
||||
cp target/release/redflag-desktop ../dist/redflag-desktop-${{ matrix.suffix }}
|
||||
echo "Desktop binary built: $(ls -lh ../dist/redflag-desktop-${{ matrix.suffix }})"
|
||||
|
||||
# Verify it self-reports the tag version.
|
||||
DESKTOP_VER=$(../dist/redflag-desktop-${{ matrix.suffix }} --version 2>/dev/null || echo "no-version")
|
||||
echo "Desktop version: $DESKTOP_VER"
|
||||
echo "$DESKTOP_VER" | grep -q "v$VERSION" || { echo "::error::desktop binary reports $DESKTOP_VER, expected v$VERSION"; exit 1; }
|
||||
|
||||
# Did it make it in: linux-amd64 binaries self-report the tag version.
|
||||
# Cross-compiled binaries can't run here (wrong arch/OS), but the native
|
||||
# ones must match.
|
||||
|
|
@ -238,6 +341,38 @@ jobs:
|
|||
sha256sum redflag-$VERSION-${{ matrix.suffix }}.tar.gz > checksums-$VERSION-${{ matrix.suffix }}.txt
|
||||
fi
|
||||
|
||||
- name: Generate manifest artifact snippet
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
cd dist
|
||||
# Emit one JSON object per binary in this platform's tarball.
|
||||
# The publish job merges these into the full manifest artifacts array.
|
||||
echo '[]' > "release-${{ matrix.suffix }}.artifacts.json"
|
||||
for bin in redflag-server redflag-agent redflag-helper redflag-desktop; do
|
||||
for f in "${bin}-${{ matrix.suffix }}" "${bin}-${{ matrix.suffix }}.exe"; do
|
||||
if [ -f "$f" ]; then
|
||||
SHA=$(sha256sum "$f" | awk '{print $1}')
|
||||
SIZE=$(stat -c%s "$f")
|
||||
# Map binary prefix to manifest platform name.
|
||||
case "$bin" in
|
||||
redflag-server) PLAT="server-${{ matrix.goos }}" ;;
|
||||
redflag-agent) PLAT="${{ matrix.goos }}" ;;
|
||||
redflag-helper) PLAT="helper-${{ matrix.goos }}" ;;
|
||||
redflag-desktop) PLAT="desktop-${{ matrix.goos }}" ;;
|
||||
esac
|
||||
jq --arg plat "$PLAT" --arg arch "${{ matrix.goarch }}" \
|
||||
--arg file "$(basename "$f")" --arg sha "$SHA" \
|
||||
--argjson size "$SIZE" \
|
||||
'. + [{"platform":$plat,"architecture":$arch,"filename":$file,"sha256":$sha,"size":$size}]' \
|
||||
"release-${{ matrix.suffix }}.artifacts.json" > tmp.json \
|
||||
&& mv tmp.json "release-${{ matrix.suffix }}.artifacts.json"
|
||||
fi
|
||||
done
|
||||
done
|
||||
echo "Artifact snippet:"
|
||||
cat "release-${{ matrix.suffix }}.artifacts.json"
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: release-${{ matrix.suffix }}
|
||||
|
|
@ -289,7 +424,8 @@ jobs:
|
|||
docker push 10.10.20.120:4455/fimeg/redflag:$VERSION
|
||||
docker push 10.10.20.120:4455/fimeg/redflag:latest
|
||||
|
||||
# Publish: gather all platform artifacts and create the Gitea release.
|
||||
# Publish: gather all platform artifacts, generate the component manifest,
|
||||
# and create the Gitea release.
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release, docker]
|
||||
|
|
@ -301,6 +437,57 @@ jobs:
|
|||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Generate component manifest
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${GITHUB_REF#refs/tags/v}
|
||||
NOW=$(date -u +%s)
|
||||
|
||||
# Component catalog — kept in sync with server/internal/services/release_manifest.go
|
||||
# (componentCatalog()). Every component listed here MUST have a built artifact
|
||||
# verified by the release matrix jobs, or the gate fails this release.
|
||||
cat > manifest.json <<MANIFEST
|
||||
{
|
||||
"version": "$VERSION",
|
||||
"generated_at": $NOW,
|
||||
"key_id": "",
|
||||
"components": [
|
||||
{"name": "server", "kind": "docker", "required": true, "version_cmd": "--version"},
|
||||
{"name": "agent", "kind": "binary", "required": true, "version_cmd": "--version"},
|
||||
{"name": "helper", "kind": "binary", "required": true, "version_cmd": "--version"},
|
||||
{"name": "desktop", "kind": "binary", "required": false, "version_cmd": "--version",
|
||||
"provisioning": ["autostart_entry", "redflag-local_group", "desktop_user_membership"]},
|
||||
{"name": "web", "kind": "embedded","required": true}
|
||||
],
|
||||
"artifacts": []
|
||||
}
|
||||
MANIFEST
|
||||
|
||||
# Populate artifacts from downloaded release bundles. Each platform job
|
||||
# uploads manifest artifact snippets as release-<suffix>.artifacts.json.
|
||||
for f in artifacts/release-*/release-*.artifacts.json; do
|
||||
if [ -f "$f" ]; then
|
||||
echo "Merging artifact entries from $(basename "$(dirname "$f")")/$(basename "$f")"
|
||||
jq -s '.[0].artifacts + .[1].artifacts' manifest.json "$f" > manifest.tmp \
|
||||
&& mv manifest.tmp manifest.json
|
||||
fi
|
||||
done
|
||||
|
||||
# Verify every required component has at least one artifact.
|
||||
# server = docker image (verified by docker job). web = embedded (verified
|
||||
# by web job producing a non-empty dist/). agent/helper/desktop = binary
|
||||
# artifacts in the manifest.
|
||||
for comp in agent helper; do
|
||||
if ! jq -e --arg c "$comp" '.artifacts[] | select(.platform | test($c))' manifest.json > /dev/null; then
|
||||
echo "::error::required component '$comp' has no artifacts in manifest"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "Component completeness verified"
|
||||
|
||||
# Upload manifest so the release job below attaches it.
|
||||
cp manifest.json artifacts/manifest.json
|
||||
|
||||
- name: Create Gitea release
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
|
@ -318,8 +505,8 @@ jobs:
|
|||
fi
|
||||
echo "Created release id=$RELEASE_ID"
|
||||
|
||||
# Upload every artifact (tarballs, zips, checksums).
|
||||
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.txt' \) | while read f; do
|
||||
# Upload every artifact (tarballs, zips, checksums, manifest).
|
||||
find artifacts -type f \( -name '*.tar.gz' -o -name '*.zip' -o -name '*.txt' -o -name 'manifest.json' \) | while read f; do
|
||||
echo "Uploading $(basename "$f")"
|
||||
curl -sf -X POST "$API/repos/${GITHUB_REPOSITORY}/releases/$RELEASE_ID/assets?name=$(basename "$f")" \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
|
|
|
|||
|
|
@ -103,6 +103,20 @@ FILE="$ROOT/helper/Cargo.toml"
|
|||
sed -i -E "s/(version[[:space:]]*=[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\"/\1\"$CARGO_VERSION\"/" "$FILE"
|
||||
echo " [4] helper/Cargo.toml version -> $CARGO_VERSION (from $NEW_VERSION)"
|
||||
|
||||
# 4. desktop/Cargo.toml — version (semver: 3 parts only)
|
||||
FILE="$ROOT/desktop/Cargo.toml"
|
||||
if [ -f "$FILE" ]; then
|
||||
sed -i -E "s/(version[[:space:]]*=[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+(\.[0-9]+)?\"/\1\"$CARGO_VERSION\"/" "$FILE"
|
||||
echo " [5] desktop/Cargo.toml version -> $CARGO_VERSION (from $NEW_VERSION)"
|
||||
fi
|
||||
|
||||
# 5. desktop/tauri.conf.json — version (full 4-octet, matches docker-compose)
|
||||
FILE="$ROOT/desktop/tauri.conf.json"
|
||||
if [ -f "$FILE" ]; then
|
||||
sed -i -E "s/(\"version\"[[:space:]]*:[[:space:]]*)\"[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\"/\1\"$NEW_VERSION\"/" "$FILE"
|
||||
echo " [6] desktop/tauri.conf.json version -> $NEW_VERSION"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
|
||||
# Refresh action SHA pins so the workflow files ship with current hashes.
|
||||
|
|
|
|||
|
|
@ -458,14 +458,17 @@ func (h *DownloadHandler) DownloadManifest(c *gin.Context) {
|
|||
}
|
||||
|
||||
// buildReleaseManifest assembles the manifest from the signed-package records.
|
||||
// Platforms are walked in a fixed order so the marshalled bytes are stable.
|
||||
// A platform with no signed package (or no resolvable checksum) is omitted
|
||||
// rather than fabricated — the manifest never lies about what it can attest.
|
||||
// Components are pulled from the authoritative catalog (componentCatalog); artifacts
|
||||
// are resolved from signed-package DB rows. Platforms are walked in a fixed order
|
||||
// so the marshalled bytes are stable. A platform with no signed package (or no
|
||||
// resolvable checksum) is omitted rather than fabricated — the manifest never lies
|
||||
// about what it can attest.
|
||||
func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseManifest {
|
||||
manifest := services.ReleaseManifest{
|
||||
Version: version,
|
||||
GeneratedAt: time.Now().UTC().Unix(),
|
||||
KeyID: h.signingService.GetCurrentKeyID(),
|
||||
Components: services.ComponentCatalog(),
|
||||
}
|
||||
|
||||
platforms := []struct{ platform, arch string }{
|
||||
|
|
@ -474,6 +477,7 @@ func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseM
|
|||
{"windows", "amd64"},
|
||||
{"windows", "arm64"},
|
||||
{"helper-linux", "amd64"},
|
||||
{"desktop-linux", "amd64"},
|
||||
}
|
||||
|
||||
for _, p := range platforms {
|
||||
|
|
@ -495,6 +499,8 @@ func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseM
|
|||
switch {
|
||||
case strings.HasPrefix(p.platform, "helper"):
|
||||
filename = "redflag-helper"
|
||||
case strings.HasPrefix(p.platform, "desktop"):
|
||||
filename = "redflag-desktop"
|
||||
case p.platform == "windows":
|
||||
filename += ".exe"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,23 @@
|
|||
package services
|
||||
|
||||
// Release manifest — the cold-start trust root.
|
||||
// Release manifest — the cold-start trust root and component healthcheck anchor.
|
||||
//
|
||||
// At install time the agent installer has no pinned binary hash to check
|
||||
// against: it pulls the binary and (until now) ran it. The manifest closes that
|
||||
// gap. It is a single JSON document listing the expected SHA-256 of every
|
||||
// released agent binary per platform/architecture, signed with the same Ed25519
|
||||
// key the agents already trust (TOFU pubkey on first contact, pinned key on
|
||||
// upgrade). The installer fetches the manifest, verifies the signature over the
|
||||
// exact bytes it received, then verifies the downloaded binary's hash matches
|
||||
// the manifest entry before executing anything.
|
||||
// Two audiences, one document:
|
||||
// 1. Install-time: the installer fetches the manifest, verifies the Ed25519
|
||||
// signature over the verbatim body, then checks every downloaded binary
|
||||
// against its matching artifacts entry before executing anything.
|
||||
// 2. Post-install healthcheck: walks Components, runs version_cmd on each
|
||||
// installed binary, verifies provisioning state, emits a signed checkoff
|
||||
// report (local journal + server security event).
|
||||
//
|
||||
// The signed bytes are the verbatim JSON of ReleaseManifest (no map fields, so
|
||||
// Go's json.Marshal is deterministic; Artifacts is sorted before marshalling).
|
||||
// Whatever the server serves as the body is exactly what was signed — the
|
||||
// signature travels in the X-Content-Signature header, the key fingerprint in
|
||||
// X-Key-Id, mirroring the binary download endpoint.
|
||||
// Signed with the server's instance Ed25519 key (TOFU pubkey on first contact,
|
||||
// pinned on upgrade). The signed bytes are the verbatim JSON; the signature
|
||||
// travels in X-Content-Signature, the key fingerprint in X-Key-Id.
|
||||
//
|
||||
// CI generates the authoritative manifest at release time (embedded in the
|
||||
// server binary); at serve time the server re-signs with its instance key.
|
||||
// A release whose manifest lists a component without a built, version-self-
|
||||
// reporting artifact fails the release gate — no silent drop-out.
|
||||
|
||||
// ManifestArtifact is one released binary's expected identity.
|
||||
type ManifestArtifact struct {
|
||||
|
|
@ -26,10 +28,36 @@ type ManifestArtifact struct {
|
|||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
// ReleaseManifest is the signed set of expected binary hashes for one version.
|
||||
type ReleaseManifest struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt int64 `json:"generated_at"`
|
||||
KeyID string `json:"key_id"`
|
||||
Artifacts []ManifestArtifact `json:"artifacts"`
|
||||
// ManifestComponent describes one installable/checkable piece of the release.
|
||||
// Required components block install completion; optional ones are skipped
|
||||
// gracefully when unavailable.
|
||||
type ManifestComponent struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"` // docker, binary, embedded
|
||||
Required bool `json:"required"`
|
||||
VersionCmd string `json:"version_cmd"` // e.g. "--version"
|
||||
Provisioning []string `json:"provisioning,omitempty"` // healthcheck steps (desktop: autostart_entry, redflag-local_group, desktop_user_membership)
|
||||
}
|
||||
|
||||
// ReleaseManifest is the signed component+artifact catalog for one version.
|
||||
type ReleaseManifest struct {
|
||||
Version string `json:"version"`
|
||||
GeneratedAt int64 `json:"generated_at"`
|
||||
KeyID string `json:"key_id"`
|
||||
Components []ManifestComponent `json:"components"`
|
||||
Artifacts []ManifestArtifact `json:"artifacts"`
|
||||
}
|
||||
|
||||
// ComponentCatalog returns the fixed component set for a release.
|
||||
// This is the authoritative list — CI's gate job enforces that every component
|
||||
// here has a built, version-self-reporting artifact.
|
||||
func ComponentCatalog() []ManifestComponent {
|
||||
return []ManifestComponent{
|
||||
{Name: "server", Kind: "docker", Required: true, VersionCmd: "--version"},
|
||||
{Name: "agent", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||
{Name: "helper", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||
{Name: "desktop", Kind: "binary", Required: false, VersionCmd: "--version",
|
||||
Provisioning: []string{"autostart_entry", "redflag-local_group", "desktop_user_membership"}},
|
||||
{Name: "web", Kind: "embedded", Required: true},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ case $ARCH in
|
|||
;;
|
||||
esac
|
||||
|
||||
PLATFORM_TAG="linux"
|
||||
|
||||
# Override download URL with detected architecture
|
||||
BINARY_URL="{{.ServerURL}}/api/v1/downloads/linux-${ARCH_TAG}?version={{.Version}}"
|
||||
|
||||
|
|
@ -72,6 +74,110 @@ echo "Platform: {{.Platform}}"
|
|||
echo "Installing to: ${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
echo
|
||||
|
||||
# ---- argument parsing ----
|
||||
GUIDED=false
|
||||
CHECKOFF_ONLY=false
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--guided) GUIDED=true; shift ;;
|
||||
--checkoff) CHECKOFF_ONLY=true; shift ;;
|
||||
*) echo "Unknown option: $1"; echo "Usage: $0 [--guided] [--checkoff]"; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# ---- checkoff tracking ----
|
||||
# Each step records a line: COMPONENT|RESULT|DETAIL
|
||||
# RESULT: ok, skip, fail
|
||||
CHECKOFF_LOG=$(mktemp)
|
||||
checkoff() {
|
||||
local component="$1" result="$2" detail="$3"
|
||||
echo "${component}|${result}|${detail}" >> "$CHECKOFF_LOG"
|
||||
case "$result" in
|
||||
ok) echo " [✓] ${component}: ${detail}" ;;
|
||||
skip) echo " [—] ${component}: ${detail} (skipped)" ;;
|
||||
fail) echo " [✗] ${component}: ${detail}" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
emit_checkoff_report() {
|
||||
echo ""
|
||||
echo "=== Component Checkoff Report ==="
|
||||
echo "Release: ${VERSION}"
|
||||
echo "Host: $(hostname)"
|
||||
echo "Time: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo ""
|
||||
local fail_count=0
|
||||
while IFS='|' read -r comp result detail; do
|
||||
case "$result" in
|
||||
ok) echo " [✓] ${comp}: ${detail}" ;;
|
||||
skip) echo " [—] ${comp}: ${detail}" ;;
|
||||
fail) echo " [✗] ${comp}: ${detail}"; fail_count=$((fail_count + 1)) ;;
|
||||
esac
|
||||
done < "$CHECKOFF_LOG"
|
||||
echo ""
|
||||
if [ "$fail_count" -gt 0 ]; then
|
||||
echo "Result: $fail_count component(s) failed"
|
||||
else
|
||||
echo "Result: all components verified"
|
||||
fi
|
||||
|
||||
# Write structured checkoff to agent's local journal.
|
||||
local JOURNAL_DIR="${AGENT_HOME}/checkoffs"
|
||||
sudo mkdir -p "$JOURNAL_DIR"
|
||||
sudo chown "$AGENT_USER:$AGENT_USER" "$JOURNAL_DIR"
|
||||
local REPORT_FILE="${JOURNAL_DIR}/checkoff-${VERSION}-$(date -u +%Y%m%dT%H%M%SZ).json"
|
||||
{
|
||||
echo "{"
|
||||
echo " \"release\": \"${VERSION}\","
|
||||
echo " \"host\": \"$(hostname)\","
|
||||
echo " \"time\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\","
|
||||
echo " \"agent_id\": \"${AGENT_ID}\","
|
||||
echo " \"components\": ["
|
||||
local first=true
|
||||
while IFS='|' read -r comp result detail; do
|
||||
[ -z "$comp" ] && continue
|
||||
if $first; then first=false; else echo ","; fi
|
||||
echo -n " {\"name\":\"${comp}\",\"result\":\"${result}\",\"detail\":\"${detail}\"}"
|
||||
done < "$CHECKOFF_LOG"
|
||||
echo ""
|
||||
echo " ]"
|
||||
echo "}"
|
||||
} | sudo tee "$REPORT_FILE" > /dev/null
|
||||
sudo chown "$AGENT_USER:$AGENT_USER" "$REPORT_FILE"
|
||||
echo "Checkoff journal: $REPORT_FILE"
|
||||
|
||||
# Post checkoff as security event to server (fire-and-forget).
|
||||
local EVENT_JSON
|
||||
EVENT_JSON=$(printf '{"events":[{"timestamp":"%s","level":"info","event_type":"install_checkoff","message":"Install checkoff for %s on %s","details":{"release":"%s","host":"%s","agent_id":"%s","components":%s}}]}' \
|
||||
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$VERSION" "$(hostname)" "$VERSION" "$(hostname)" "$AGENT_ID" \
|
||||
"$(python3 -c "import json; print(json.dumps([{'name':c,'result':r,'detail':d} for c,r,d in (l.split('|') for l in open('$CHECKOFF_LOG').read().strip().split(chr(10)) if l)]))" 2>/dev/null || echo '[]')")
|
||||
curl -sf -X POST "{{.ServerURL}}/api/v1/agents/${AGENT_ID}/security-events" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "X-Agent-ID: ${AGENT_ID}" \
|
||||
-d "$EVENT_JSON" 2>/dev/null || true
|
||||
echo "Checkoff event posted to server"
|
||||
}
|
||||
|
||||
# Guided-mode welcome.
|
||||
if $GUIDED; then
|
||||
echo "┌──────────────────────────────────────────┐"
|
||||
echo "│ RedFlag v${VERSION} — Guided Install │"
|
||||
echo "└──────────────────────────────────────────┘"
|
||||
echo ""
|
||||
echo "This will install the RedFlag agent and its components."
|
||||
echo "Required components: agent, helper, web (embedded in server)."
|
||||
echo "Optional components: desktop (system tray + local dashboard)."
|
||||
echo ""
|
||||
echo "Press Enter to continue or Ctrl-C to abort."
|
||||
read -r
|
||||
fi
|
||||
|
||||
# Checkoff-only mode: re-run healthcheck against the manifest, skip downloads.
|
||||
if $CHECKOFF_ONLY; then
|
||||
echo "=== Checkoff-only mode — verifying installed components ==="
|
||||
# Fall through to manifest fetch, skip downloads, run version checks.
|
||||
fi
|
||||
|
||||
# Step 1: Detect existing installation
|
||||
echo "Detecting existing RedFlag installations..."
|
||||
MIGRATION_NEEDED=false
|
||||
|
|
@ -304,31 +410,20 @@ sudo mkdir -p "${SERVER_KEY_DIR}" # Server public key cache (TOFU model)
|
|||
sudo mkdir -p "$AGENT_HOME"
|
||||
sudo mkdir -p "$AGENT_LOG_DIR"
|
||||
|
||||
# Step 7: Download agent binary
|
||||
echo "Downloading agent binary..."
|
||||
TMP_BINARY=$(mktemp)
|
||||
TMP_HEADERS=$(mktemp)
|
||||
curl -fsSL -o "$TMP_BINARY" -D "$TMP_HEADERS" "${BINARY_URL}"
|
||||
|
||||
# --- Cold-start trust: verify the binary against the signed release manifest ---
|
||||
# The manifest is one Ed25519-signed document listing the expected SHA-256 of
|
||||
# every released binary per platform/arch. We verify the manifest signature with
|
||||
# the embedded server public key, then confirm the downloaded binary matches its
|
||||
# manifest entry BEFORE executing anything. Fail-closed: any failure removes the
|
||||
# binary and exits non-zero. This is the install-time counterpart to the
|
||||
# self-upgrade hash check — it closes the gap where first-install ran an
|
||||
# unverified binary.
|
||||
PLATFORM_TAG="linux"
|
||||
SERVER_PUBKEY="{{.ServerPublicKey}}"
|
||||
# ---- Manifest-driven component download ----
|
||||
# Fetch the signed release manifest once, verify its Ed25519 signature against
|
||||
# the TOFU-pinned server public key, then drive every component download from
|
||||
# the manifest's components + artifacts tables. No hardcoded binary URLs.
|
||||
MANIFEST_URL="{{.ServerURL}}/api/v1/manifest?version=${VERSION}"
|
||||
SERVER_PUBKEY="{{.ServerPublicKey}}"
|
||||
|
||||
if [ -z "$SERVER_PUBKEY" ]; then
|
||||
echo "ERROR: No server public key embedded in installer — cannot establish cold-start trust."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure an Ed25519 verifier (python3-cryptography) is available.
|
||||
# Ensure python3 + cryptography are available (needed for manifest verification
|
||||
# and JSON parsing of the manifest).
|
||||
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
|
||||
echo "Installing python3-cryptography for manifest verification..."
|
||||
case "$PM" in
|
||||
|
|
@ -340,35 +435,34 @@ if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import E
|
|||
fi
|
||||
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
|
||||
echo "ERROR: Could not provide python3-cryptography — cannot verify the release manifest."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Fetch and verify the release manifest.
|
||||
echo "Fetching release manifest..."
|
||||
TMP_MANIFEST=$(mktemp)
|
||||
TMP_MANIFEST_HDR=$(mktemp)
|
||||
if ! curl -fsSL -o "$TMP_MANIFEST" -D "$TMP_MANIFEST_HDR" "$MANIFEST_URL"; then
|
||||
echo "ERROR: Failed to fetch release manifest from ${MANIFEST_URL}"
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS" "$TMP_MANIFEST" "$TMP_MANIFEST_HDR"
|
||||
rm -f "$TMP_MANIFEST" "$TMP_MANIFEST_HDR"
|
||||
exit 1
|
||||
fi
|
||||
MANIFEST_SIG=$(grep -i "x-content-signature" "$TMP_MANIFEST_HDR" | awk '{print $2}' | tr -d '\r\n')
|
||||
MANIFEST_KEY_ID=$(grep -i "x-key-id" "$TMP_MANIFEST_HDR" | awk '{print $2}' | tr -d '\r\n')
|
||||
rm -f "$TMP_MANIFEST_HDR"
|
||||
if [ -z "$MANIFEST_SIG" ]; then
|
||||
echo "ERROR: Release manifest is unsigned (no X-Content-Signature header) — refusing to install."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS" "$TMP_MANIFEST"
|
||||
rm -f "$TMP_MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verifier: checks the Ed25519 signature over the manifest bytes, then prints the
|
||||
# expected sha256 for this platform/arch (exit 2 = bad signature, 3 = no entry).
|
||||
# Verify Ed25519 signature over the verbatim manifest body.
|
||||
MANIFEST_VERIFY=$(mktemp)
|
||||
cat <<'MANIFEST_EOF' > "$MANIFEST_VERIFY"
|
||||
#!/usr/bin/env python3
|
||||
import sys, json
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
pubkey_path, manifest_path, sig_path, platform, arch = sys.argv[1:6]
|
||||
pubkey_path, manifest_path, sig_path = sys.argv[1:4]
|
||||
with open(pubkey_path) as f: pub = bytes.fromhex(f.read().strip())
|
||||
with open(sig_path) as f: sig = bytes.fromhex(f.read().strip())
|
||||
with open(manifest_path, 'rb') as f: body = f.read()
|
||||
|
|
@ -378,377 +472,321 @@ except InvalidSignature:
|
|||
print("manifest signature invalid", file=sys.stderr); sys.exit(2)
|
||||
except Exception as e:
|
||||
print("manifest verify error: %s" % e, file=sys.stderr); sys.exit(2)
|
||||
m = json.loads(body)
|
||||
for a in m.get("artifacts", []):
|
||||
if a.get("platform") == platform and a.get("architecture") == arch:
|
||||
print(a.get("sha256", "")); sys.exit(0)
|
||||
print("no manifest entry for %s/%s" % (platform, arch), file=sys.stderr); sys.exit(3)
|
||||
sys.exit(0)
|
||||
MANIFEST_EOF
|
||||
|
||||
echo "$SERVER_PUBKEY" > "${TMP_MANIFEST}.pub"
|
||||
echo "$MANIFEST_SIG" > "${TMP_MANIFEST}.sig"
|
||||
set +e
|
||||
EXPECTED_HASH=$(python3 "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "$TMP_MANIFEST" "${TMP_MANIFEST}.sig" "$PLATFORM_TAG" "$ARCH_TAG")
|
||||
python3 "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "$TMP_MANIFEST" "${TMP_MANIFEST}.sig"
|
||||
MANIFEST_RC=$?
|
||||
set -e
|
||||
rm -f "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "${TMP_MANIFEST}.sig" "$TMP_MANIFEST"
|
||||
if [ $MANIFEST_RC -ne 0 ] || [ -z "$EXPECTED_HASH" ]; then
|
||||
echo "ERROR: Release manifest verification failed (rc=$MANIFEST_RC) — refusing to install."
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
rm -f "$MANIFEST_VERIFY" "${TMP_MANIFEST}.pub" "${TMP_MANIFEST}.sig"
|
||||
if [ $MANIFEST_RC -ne 0 ]; then
|
||||
echo "ERROR: Release manifest signature verification failed (rc=$MANIFEST_RC) — refusing to install."
|
||||
rm -f "$TMP_MANIFEST"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Manifest signature verified (key ${MANIFEST_KEY_ID})"
|
||||
|
||||
ACTUAL_HASH=$(sha256sum "$TMP_BINARY" | awk '{print $1}')
|
||||
if [ "$EXPECTED_HASH" != "$ACTUAL_HASH" ]; then
|
||||
echo "ERROR: Binary hash does not match the signed manifest — possible tampering."
|
||||
echo " expected (signed manifest): $EXPECTED_HASH"
|
||||
echo " actual (downloaded): $ACTUAL_HASH"
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ Cold-start trust established: binary matches signed release manifest ($ACTUAL_HASH)"
|
||||
# --- end cold-start manifest verification ---
|
||||
# Resolve components → artifacts for this platform/arch.
|
||||
# The Python helper reads the manifest, pairs each component with its matching
|
||||
# artifact entry, and emits tab-separated lines:
|
||||
# name|kind|required|version_cmd|filename|sha256|provisioning_csv
|
||||
MANIFEST_RESOLVE=$(mktemp)
|
||||
cat <<'MANIFEST_EOF' > "$MANIFEST_RESOLVE"
|
||||
import sys, json
|
||||
platform, arch = sys.argv[1:3]
|
||||
m = json.load(open(sys.argv[3]))
|
||||
# Index artifacts by (platform, architecture)
|
||||
art_map = {}
|
||||
for a in m.get("artifacts", []):
|
||||
art_map[(a.get("platform",""), a.get("architecture",""))] = a
|
||||
for c in m.get("components", []):
|
||||
name = c.get("name","")
|
||||
kind = c.get("kind","")
|
||||
req = "true" if c.get("required", False) else "false"
|
||||
vcmd = c.get("version_cmd", "--version")
|
||||
prov = ",".join(c.get("provisioning", []))
|
||||
# Match: binary components look for <name>-<platform> in artifacts.
|
||||
# agent=linux, helper=helper-linux, desktop=desktop-linux, server=server-linux
|
||||
if kind == "embedded":
|
||||
# Embedded components have no artifact to download — they're built into
|
||||
# another component (web is embedded in server). Checkoff is server-side.
|
||||
if not req == "true" and "--skip" in sys.argv:
|
||||
continue
|
||||
print("%s|%s|%s|%s|||%s" % (name, kind, req, vcmd, prov))
|
||||
continue
|
||||
if kind == "docker":
|
||||
print("%s|%s|%s|%s|||%s" % (name, kind, req, vcmd, prov))
|
||||
continue
|
||||
# Binary components: try <name>-<platform> first, then <platform> for agent
|
||||
candidates = ["%s-%s" % (name, platform), platform]
|
||||
found = None
|
||||
for cand in candidates:
|
||||
key = (cand, arch)
|
||||
if key in art_map:
|
||||
found = art_map[key]
|
||||
break
|
||||
if found:
|
||||
print("%s|%s|%s|%s|%s|%s|%s" % (
|
||||
name, kind, req, vcmd,
|
||||
found.get("filename",""),
|
||||
found.get("sha256",""),
|
||||
prov))
|
||||
else:
|
||||
if req == "true":
|
||||
print("%s|%s|%s|%s|MISSING||%s" % (name, kind, req, vcmd, prov), file=sys.stderr)
|
||||
else:
|
||||
print("%s|%s|%s|%s|||%s" % (name, kind, req, vcmd, prov))
|
||||
MANIFEST_EOF
|
||||
|
||||
# Verify checksum if server provided one
|
||||
EXPECTED_CHECKSUM=$(grep -i "x-content-sha256" "$TMP_HEADERS" | awk '{print $2}' | tr -d '\r\n')
|
||||
if [ -n "$EXPECTED_CHECKSUM" ]; then
|
||||
ACTUAL_CHECKSUM=$(sha256sum "$TMP_BINARY" | awk '{print $1}')
|
||||
if [ "$EXPECTED_CHECKSUM" != "$ACTUAL_CHECKSUM" ]; then
|
||||
echo "ERROR: Checksum verification failed"
|
||||
echo "Expected: $EXPECTED_CHECKSUM"
|
||||
echo "Actual: $ACTUAL_CHECKSUM"
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS"
|
||||
exit 1
|
||||
# The resolver emits component lines to stdout. We read them into a shell loop.
|
||||
MANIFEST_LINES=$(mktemp)
|
||||
python3 "$MANIFEST_RESOLVE" "$PLATFORM_TAG" "$ARCH_TAG" "$TMP_MANIFEST" > "$MANIFEST_LINES" 2>/dev/null
|
||||
rm -f "$MANIFEST_RESOLVE" "$TMP_MANIFEST"
|
||||
|
||||
# Walk each component from the manifest.
|
||||
echo ""
|
||||
echo "=== Installing components ==="
|
||||
HAD_FAILURES=false
|
||||
while IFS='|' read -r comp_name comp_kind comp_req comp_vcmd comp_file comp_sha comp_prov; do
|
||||
[ -z "$comp_name" ] && continue
|
||||
echo ""
|
||||
echo "--- ${comp_name} (${comp_kind}) ---"
|
||||
|
||||
# Embedded and docker components are not downloaded here — they're verified
|
||||
# at checkoff time.
|
||||
if [ "$comp_kind" = "embedded" ]; then
|
||||
checkoff "$comp_name" "ok" "embedded (verified server-side)"
|
||||
continue
|
||||
fi
|
||||
echo "Checksum verified: $ACTUAL_CHECKSUM"
|
||||
else
|
||||
echo "WARNING: Server did not provide checksum header. Proceeding without verification."
|
||||
fi
|
||||
|
||||
# ISSUE-002: Save signature and public key for agent verification
|
||||
EXPECTED_SIGNATURE=$(grep -i "x-content-signature" "$TMP_HEADERS" | awk '{print $2}' | tr -d '\r\n')
|
||||
if [ -n "$EXPECTED_SIGNATURE" ]; then
|
||||
echo "Signature received, saving for agent verification"
|
||||
echo "$EXPECTED_SIGNATURE" | sudo tee "${SERVER_KEY_DIR}/initial_binary.sig" > /dev/null
|
||||
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/initial_binary.sig"
|
||||
else
|
||||
echo "WARNING: Server did not provide signature header"
|
||||
fi
|
||||
|
||||
# Save server public key for TOFU model (hex-encoded; verify script reads it as hex)
|
||||
if [ -n "{{.ServerPublicKey}}" ]; then
|
||||
echo -n "{{.ServerPublicKey}}" | sudo tee "${SERVER_KEY_DIR}/server_public_key" > /dev/null
|
||||
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/server_public_key"
|
||||
echo "Server public key saved for TOFU verification"
|
||||
fi
|
||||
|
||||
rm -f "$TMP_HEADERS"
|
||||
|
||||
# ISSUE-002: Verify binary signature before installation (TOFU model)
|
||||
if [ -n "$EXPECTED_SIGNATURE" ] && [ -n "{{.ServerPublicKey}}" ]; then
|
||||
echo "Verifying binary signature..."
|
||||
|
||||
# Ensure Ed25519 verification library is available.
|
||||
# The ancient 'ed25519' PyPI package is incompatible with Python >=3.12
|
||||
# (removed SafeConfigParser). Use 'cryptography' which is the modern,
|
||||
# actively maintained alternative.
|
||||
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
|
||||
echo "Installing python3-cryptography for Ed25519 verification..."
|
||||
case "$PM" in
|
||||
apt)
|
||||
apt-get install -y python3-cryptography 2>/dev/null
|
||||
;;
|
||||
dnf|yum)
|
||||
dnf install -y python3-cryptography 2>/dev/null
|
||||
;;
|
||||
pacman)
|
||||
pacman -S --noconfirm python-cryptography 2>/dev/null
|
||||
;;
|
||||
*)
|
||||
pip3 install cryptography 2>/dev/null
|
||||
;;
|
||||
esac
|
||||
# Verify the install succeeded
|
||||
if ! python3 -c "from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey" 2>/dev/null; then
|
||||
echo "ERROR: Failed to install python3-cryptography. Cannot verify binary signature."
|
||||
echo "Install manually: pip3 install cryptography"
|
||||
rm -f "$TMP_BINARY" "$TMP_HEADERS" "${SERVER_KEY_DIR}/initial_binary.sig"
|
||||
exit 1
|
||||
fi
|
||||
echo "✓ python3-cryptography installed"
|
||||
fi
|
||||
|
||||
# Create temporary verification script
|
||||
VERIFY_SCRIPT=$(mktemp)
|
||||
cat <<'VERIFY_EOF' > "$VERIFY_SCRIPT"
|
||||
#!/usr/bin/env python3
|
||||
"""Verify an Ed25519 signature over a binary using the cryptography library."""
|
||||
import sys
|
||||
|
||||
try:
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
except ImportError:
|
||||
print("Ed25519 verification requires 'cryptography' library", file=sys.stderr)
|
||||
print("Install: pip3 install cryptography", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
# Read public key (raw hex-encoded Ed25519 public key)
|
||||
with open(sys.argv[1], 'r') as f:
|
||||
pubkey_hex = f.read().strip()
|
||||
pubkey = bytes.fromhex(pubkey_hex)
|
||||
|
||||
# Read signature (raw hex-encoded Ed25519 signature)
|
||||
with open(sys.argv[2], 'r') as f:
|
||||
sig_hex = f.read().strip()
|
||||
signature = bytes.fromhex(sig_hex)
|
||||
|
||||
# Read binary
|
||||
with open(sys.argv[3], 'rb') as f:
|
||||
binary = f.read()
|
||||
|
||||
# Verify
|
||||
verifying_key = Ed25519PublicKey.from_public_bytes(pubkey)
|
||||
verifying_key.verify(signature, binary)
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"Signature verification failed: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
VERIFY_EOF
|
||||
chmod +x "$VERIFY_SCRIPT"
|
||||
|
||||
if python3 "$VERIFY_SCRIPT" "${SERVER_KEY_DIR}/server_public_key" "${SERVER_KEY_DIR}/initial_binary.sig" "$TMP_BINARY"; then
|
||||
echo "✓ Binary signature verified (Ed25519)"
|
||||
rm -f "$VERIFY_SCRIPT"
|
||||
else
|
||||
echo "ERROR: Binary signature verification failed - possible tampering"
|
||||
echo "Expected: Trust on First Use (TOFU) verification failed"
|
||||
rm -f "$TMP_BINARY" "$VERIFY_SCRIPT" "${SERVER_KEY_DIR}/initial_binary.sig"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "WARNING: Cannot verify signature - missing public key or signature"
|
||||
echo "This is a security risk. Ensure server is trusted."
|
||||
fi
|
||||
|
||||
sudo mv "$TMP_BINARY" "${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
sudo chmod +x "${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
|
||||
# Fix SELinux context if restorecon is available (RHEL/Fedora/CentOS)
|
||||
if command -v restorecon &>/dev/null; then
|
||||
sudo restorecon "${INSTALL_DIR}/${SERVICE_NAME}" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Grant CAP_SYS_PTRACE so the agent can read /proc/[pid]/environ from the
|
||||
# logged-in user's session processes. Required for display/Wayland discovery
|
||||
# (screenshot capture) and any future per-process telemetry. Without this the
|
||||
# agent user cannot read environment variables of processes it does not own.
|
||||
if command -v setcap &>/dev/null; then
|
||||
sudo setcap cap_sys_ptrace=eip "${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
echo "✓ CAP_SYS_PTRACE granted (display/process discovery)"
|
||||
else
|
||||
echo "[WARN] [installer] [capabilities] setcap not found — install libcap-provides and run:"
|
||||
echo " sudo setcap cap_sys_ptrace=eip ${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
fi
|
||||
|
||||
# Step 7b: Install the capability-gate executor (redflag-helper)
|
||||
# The helper runs as root via systemd-run and changes package state only with a
|
||||
# server-signed capability token. It is tamper-verified at install exactly like
|
||||
# the agent: download, verify the server's Ed25519 signature over the binary
|
||||
# against the TOFU-pinned public key, then install root-owned and NOT writable
|
||||
# by the agent user — the agent must never be able to replace its own privileged
|
||||
# executor. The whole block is gated on signing being enabled (no server key →
|
||||
# no tokens are ever minted → the helper is never invoked), so a non-signing
|
||||
# install degrades cleanly to Tier-1 update management. Fail-closed otherwise.
|
||||
if [ -n "{{.ServerPublicKey}}" ]; then
|
||||
HELPER_BIN="${INSTALL_DIR}/redflag-helper"
|
||||
HELPER_URL="{{.ServerURL}}/api/v1/helper/${ARCH_TAG}?version=${VERSION}"
|
||||
echo "Installing capability-gate executor (redflag-helper)..."
|
||||
TMP_HELPER=$(mktemp)
|
||||
TMP_HELPER_HDR=$(mktemp)
|
||||
if ! curl -fsSL -o "$TMP_HELPER" -D "$TMP_HELPER_HDR" "$HELPER_URL"; then
|
||||
echo "ERROR: Failed to download redflag-helper from ${HELPER_URL}"
|
||||
rm -f "$TMP_HELPER" "$TMP_HELPER_HDR"
|
||||
exit 1
|
||||
fi
|
||||
HELPER_SIG=$(grep -i "x-content-signature" "$TMP_HELPER_HDR" | awk '{print $2}' | tr -d '\r\n')
|
||||
HELPER_SHA=$(grep -i "x-content-sha256" "$TMP_HELPER_HDR" | awk '{print $2}' | tr -d '\r\n')
|
||||
rm -f "$TMP_HELPER_HDR"
|
||||
|
||||
if [ -z "$HELPER_SIG" ] || [ ! -s "${SERVER_KEY_DIR}/server_public_key" ]; then
|
||||
echo "ERROR: redflag-helper is unsigned or no pinned server key — refusing to install."
|
||||
rm -f "$TMP_HELPER"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify the per-binary Ed25519 signature against the TOFU-pinned key (hex).
|
||||
HELPER_VERIFY=$(mktemp)
|
||||
cat <<'HVERIFY_EOF' > "$HELPER_VERIFY"
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
with open(sys.argv[1]) as f: pub = bytes.fromhex(f.read().strip())
|
||||
with open(sys.argv[2]) as f: sig = bytes.fromhex(f.read().strip())
|
||||
with open(sys.argv[3], 'rb') as f: body = f.read()
|
||||
Ed25519PublicKey.from_public_bytes(pub).verify(sig, body)
|
||||
HVERIFY_EOF
|
||||
echo "$HELPER_SIG" > "${TMP_HELPER}.sig"
|
||||
if python3 "$HELPER_VERIFY" "${SERVER_KEY_DIR}/server_public_key" "${TMP_HELPER}.sig" "$TMP_HELPER"; then
|
||||
echo "✓ redflag-helper signature verified (Ed25519)"
|
||||
else
|
||||
echo "ERROR: redflag-helper signature verification failed — possible tampering."
|
||||
rm -f "$TMP_HELPER" "$HELPER_VERIFY" "${TMP_HELPER}.sig"
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$HELPER_VERIFY" "${TMP_HELPER}.sig"
|
||||
|
||||
if [ -n "$HELPER_SHA" ]; then
|
||||
ACTUAL_HELPER_SHA=$(sha256sum "$TMP_HELPER" | awk '{print $1}')
|
||||
if [ "$HELPER_SHA" != "$ACTUAL_HELPER_SHA" ]; then
|
||||
echo "ERROR: redflag-helper hash mismatch (expected $HELPER_SHA, got $ACTUAL_HELPER_SHA)."
|
||||
rm -f "$TMP_HELPER"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
sudo mv "$TMP_HELPER" "$HELPER_BIN"
|
||||
sudo chown root:root "$HELPER_BIN"
|
||||
sudo chmod 755 "$HELPER_BIN"
|
||||
if command -v restorecon &>/dev/null; then
|
||||
sudo restorecon "$HELPER_BIN" 2>/dev/null || true
|
||||
fi
|
||||
echo "✓ redflag-helper installed at $HELPER_BIN"
|
||||
|
||||
# Helper state + staging dir, root-only. The helper copies the agent-supplied
|
||||
# upgrade binary here, hashes it, then installs from this copy — so the dir
|
||||
# MUST NOT be writable by the agent user, or a compromised agent could swap
|
||||
# the verified bytes between hash-check and install (TOCTOU) and get an
|
||||
# arbitrary binary installed as root. 0700 root:root closes that window; the
|
||||
# helper otherwise creates it at runtime with an inherited umask, which is not
|
||||
# a guarantee. Holds upgrade-staging.bin* and consumed-tokens (replay state).
|
||||
sudo mkdir -p "${BASE_DIR}/helper"
|
||||
sudo chown root:root "${BASE_DIR}/helper"
|
||||
sudo chmod 700 "${BASE_DIR}/helper"
|
||||
echo "✓ helper state dir secured at ${BASE_DIR}/helper (root:root 0700)"
|
||||
|
||||
# Trusted-keys keyring: the helper verifies capability-token signatures
|
||||
# against the server's Ed25519 signing key, resolved by key_id from *.pub
|
||||
# hex files here. Same key the agent pinned for TOFU; the helper reads it
|
||||
# independently as root (its systemd-run unit has a clean environment).
|
||||
HELPER_KEYRING_DIR="${CONFIG_DIR}/trusted-keys"
|
||||
sudo mkdir -p "$HELPER_KEYRING_DIR"
|
||||
echo -n "{{.ServerPublicKey}}" | sudo tee "${HELPER_KEYRING_DIR}/server.pub" > /dev/null
|
||||
sudo chown -R root:root "$HELPER_KEYRING_DIR"
|
||||
sudo chmod 755 "$HELPER_KEYRING_DIR"
|
||||
sudo chmod 644 "${HELPER_KEYRING_DIR}/server.pub"
|
||||
echo "✓ Helper trusted-keys keyring provisioned"
|
||||
|
||||
# Replay-guard state dir (consumed token ids). Root-only.
|
||||
HELPER_STATE_DIR="${BASE_DIR}/helper"
|
||||
sudo mkdir -p "$HELPER_STATE_DIR"
|
||||
sudo chown root:root "$HELPER_STATE_DIR"
|
||||
sudo chmod 700 "$HELPER_STATE_DIR"
|
||||
echo "✓ Helper replay-guard state directory created"
|
||||
fi
|
||||
|
||||
# Step 7c: Install the desktop app (system tray + local UI shell)
|
||||
# The desktop app is a Tauri binary that connects to the agent's local API
|
||||
# socket and provides a system tray icon with a local dashboard. It runs in
|
||||
# the user's desktop session, not as a system service.
|
||||
DESKTOP_BIN="${INSTALL_DIR}/redflag-desktop"
|
||||
DESKTOP_URL="{{.ServerURL}}/api/v1/downloads/desktop-${ARCH_TAG}?version=${VERSION}"
|
||||
echo "Installing desktop app (redflag-desktop)..."
|
||||
TMP_DESKTOP=$(mktemp)
|
||||
TMP_DESKTOP_HDR=$(mktemp)
|
||||
if curl -fsSL -o "$TMP_DESKTOP" -D "$TMP_DESKTOP_HDR" "$DESKTOP_URL" 2>/dev/null; then
|
||||
DESKTOP_SIG=$(grep -i "x-content-signature" "$TMP_DESKTOP_HDR" | awk '{print $2}' | tr -d '\r\n')
|
||||
DESKTOP_SHA=$(grep -i "x-content-sha256" "$TMP_DESKTOP_HDR" | awk '{print $2}' | tr -d '\r\n')
|
||||
rm -f "$TMP_DESKTOP_HDR"
|
||||
|
||||
# Verify signature if available
|
||||
if [ -n "$DESKTOP_SIG" ] && [ -s "${SERVER_KEY_DIR}/server_public_key" ]; then
|
||||
DESKTOP_VERIFY=$(mktemp)
|
||||
cat <<'DVERIFY_EOF' > "$DESKTOP_VERIFY"
|
||||
#!/usr/bin/env python3
|
||||
import sys
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
with open(sys.argv[1]) as f: pub = bytes.fromhex(f.read().strip())
|
||||
with open(sys.argv[2]) as f: sig = bytes.fromhex(f.read().strip())
|
||||
with open(sys.argv[3], 'rb') as f: body = f.read()
|
||||
Ed25519PublicKey.from_public_bytes(pub).verify(sig, body)
|
||||
DVERIFY_EOF
|
||||
echo "$DESKTOP_SIG" > "${TMP_DESKTOP}.sig"
|
||||
if python3 "$DESKTOP_VERIFY" "${SERVER_KEY_DIR}/server_public_key" "${TMP_DESKTOP}.sig" "$TMP_DESKTOP" 2>/dev/null; then
|
||||
echo "✓ redflag-desktop signature verified (Ed25519)"
|
||||
if [ "$comp_kind" = "docker" ]; then
|
||||
if command -v docker >/dev/null 2>&1 && docker ps --format '{{.Names}}' 2>/dev/null | grep -q redflag; then
|
||||
DOCKER_VER=$(docker run --rm 10.10.20.120:4455/fimeg/redflag:${VERSION} ./redflag-server --version 2>/dev/null || echo "unknown")
|
||||
if echo "$DOCKER_VER" | grep -q "v${VERSION}"; then
|
||||
checkoff "$comp_name" "ok" "docker image v${VERSION} present"
|
||||
else
|
||||
checkoff "$comp_name" "fail" "docker image reports ${DOCKER_VER}, expected v${VERSION}"
|
||||
HAD_FAILURES=true
|
||||
fi
|
||||
else
|
||||
echo "WARNING: redflag-desktop signature verification failed — skipping desktop install"
|
||||
rm -f "$TMP_DESKTOP" "$DESKTOP_VERIFY" "${TMP_DESKTOP}.sig"
|
||||
TMP_DESKTOP=""
|
||||
checkoff "$comp_name" "skip" "docker not available or redflag container not running"
|
||||
fi
|
||||
rm -f "$DESKTOP_VERIFY" "${TMP_DESKTOP}.sig"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -n "$DESKTOP_SHA" ] && [ -f "$TMP_DESKTOP" ]; then
|
||||
ACTUAL_DESKTOP_SHA=$(sha256sum "$TMP_DESKTOP" | awk '{print $1}')
|
||||
if [ "$DESKTOP_SHA" != "$ACTUAL_DESKTOP_SHA" ]; then
|
||||
echo "WARNING: redflag-desktop hash mismatch — skipping desktop install"
|
||||
rm -f "$TMP_DESKTOP"
|
||||
TMP_DESKTOP=""
|
||||
# Binary component.
|
||||
if [ -z "$comp_file" ] || [ -z "$comp_sha" ]; then
|
||||
if [ "$comp_req" = "true" ]; then
|
||||
echo "ERROR: Required component ${comp_name} has no artifact for ${PLATFORM_TAG}/${ARCH_TAG}"
|
||||
checkoff "$comp_name" "fail" "no artifact for ${PLATFORM_TAG}/${ARCH_TAG}"
|
||||
HAD_FAILURES=true
|
||||
else
|
||||
echo "INFO: Optional component ${comp_name} not available for ${PLATFORM_TAG}/${ARCH_TAG} — skipping"
|
||||
checkoff "$comp_name" "skip" "not available for ${PLATFORM_TAG}/${ARCH_TAG}"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -f "$TMP_DESKTOP" ]; then
|
||||
sudo mv "$TMP_DESKTOP" "$DESKTOP_BIN"
|
||||
sudo chmod 755 "$DESKTOP_BIN"
|
||||
if command -v restorecon &>/dev/null; then
|
||||
sudo restorecon "$DESKTOP_BIN" 2>/dev/null || true
|
||||
fi
|
||||
echo "✓ redflag-desktop installed at $DESKTOP_BIN"
|
||||
COMP_URL="{{.ServerURL}}/api/v1/downloads/${comp_name}-${ARCH_TAG}?version=${VERSION}"
|
||||
# The agent binary uses the bare platform tag (linux), while helper/desktop
|
||||
# use <name>-<platform>. Adjust the URL pattern to match download endpoints.
|
||||
case "$comp_name" in
|
||||
agent) COMP_URL="{{.ServerURL}}/api/v1/downloads/${PLATFORM_TAG}-${ARCH_TAG}?version=${VERSION}" ;;
|
||||
helper) COMP_URL="{{.ServerURL}}/api/v1/helper/${ARCH_TAG}?version=${VERSION}" ;;
|
||||
desktop) COMP_URL="{{.ServerURL}}/api/v1/downloads/desktop-${ARCH_TAG}?version=${VERSION}" ;;
|
||||
esac
|
||||
|
||||
# Autostart the tray in desktop sessions. /etc/xdg/autostart applies to
|
||||
# every desktop user; the binary exits cleanly on headless sessions.
|
||||
XDG_AUTOSTART_DIR="/etc/xdg/autostart"
|
||||
if [ -d "$XDG_AUTOSTART_DIR" ] || sudo mkdir -p "$XDG_AUTOSTART_DIR"; then
|
||||
cat <<EOF | sudo tee "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop" > /dev/null
|
||||
# Checkoff-only: skip download, verify installed binary + provisioning.
|
||||
if $CHECKOFF_ONLY; then
|
||||
BIN_PATH=""
|
||||
case "$comp_name" in
|
||||
agent) BIN_PATH="${INSTALL_DIR}/${SERVICE_NAME}" ;;
|
||||
helper) BIN_PATH="${INSTALL_DIR}/redflag-helper" ;;
|
||||
desktop) BIN_PATH="${INSTALL_DIR}/redflag-desktop" ;;
|
||||
esac
|
||||
if [ -x "$BIN_PATH" ]; then
|
||||
BIN_VER=$("$BIN_PATH" "$comp_vcmd" 2>/dev/null || echo "unknown")
|
||||
if echo "$BIN_VER" | grep -q "v${VERSION}"; then
|
||||
checkoff "$comp_name" "ok" "version ${BIN_VER}"
|
||||
else
|
||||
checkoff "$comp_name" "fail" "version ${BIN_VER}, expected v${VERSION}"
|
||||
HAD_FAILURES=true
|
||||
fi
|
||||
# Provisioning checks (desktop only).
|
||||
if [ -n "$comp_prov" ]; then
|
||||
IFS=',' read -ra PROV_CHECKS <<< "$comp_prov"
|
||||
for check in "${PROV_CHECKS[@]}"; do
|
||||
case "$check" in
|
||||
autostart_entry)
|
||||
if [ -f /etc/xdg/autostart/redflag-desktop.desktop ]; then
|
||||
checkoff "desktop:autostart" "ok" "autostart entry present"
|
||||
else
|
||||
checkoff "desktop:autostart" "fail" "autostart entry missing"
|
||||
HAD_FAILURES=true
|
||||
fi
|
||||
;;
|
||||
redflag-local_group)
|
||||
if getent group "$LOCAL_API_GROUP" >/dev/null 2>&1; then
|
||||
checkoff "desktop:group" "ok" "group ${LOCAL_API_GROUP} exists"
|
||||
else
|
||||
checkoff "desktop:group" "fail" "group ${LOCAL_API_GROUP} missing"
|
||||
HAD_FAILURES=true
|
||||
fi
|
||||
;;
|
||||
desktop_user_membership)
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
|
||||
if id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; then
|
||||
checkoff "desktop:membership" "ok" "${SUDO_USER} in ${LOCAL_API_GROUP}"
|
||||
else
|
||||
checkoff "desktop:membership" "fail" "${SUDO_USER} not in ${LOCAL_API_GROUP}"
|
||||
HAD_FAILURES=true
|
||||
fi
|
||||
else
|
||||
checkoff "desktop:membership" "skip" "no desktop user detected"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
fi
|
||||
else
|
||||
if [ "$comp_req" = "true" ]; then
|
||||
checkoff "$comp_name" "fail" "binary not found at ${BIN_PATH}"
|
||||
HAD_FAILURES=true
|
||||
else
|
||||
checkoff "$comp_name" "skip" "binary not installed"
|
||||
fi
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Download and verify.
|
||||
echo "Downloading ${comp_name}..."
|
||||
TMP_COMP=$(mktemp)
|
||||
TMP_COMP_HDR=$(mktemp)
|
||||
if ! curl -fsSL -o "$TMP_COMP" -D "$TMP_COMP_HDR" "$COMP_URL"; then
|
||||
if [ "$comp_req" = "true" ]; then
|
||||
echo "ERROR: Failed to download ${comp_name} from ${COMP_URL}"
|
||||
checkoff "$comp_name" "fail" "download failed"
|
||||
HAD_FAILURES=true
|
||||
rm -f "$TMP_COMP" "$TMP_COMP_HDR"
|
||||
continue
|
||||
else
|
||||
echo "INFO: ${comp_name} download failed (optional — skipping)"
|
||||
checkoff "$comp_name" "skip" "download failed (optional)"
|
||||
rm -f "$TMP_COMP" "$TMP_COMP_HDR"
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
rm -f "$TMP_COMP_HDR"
|
||||
|
||||
# Verify hash against manifest.
|
||||
ACTUAL_SHA=$(sha256sum "$TMP_COMP" | awk '{print $1}')
|
||||
if [ "$comp_sha" != "$ACTUAL_SHA" ]; then
|
||||
echo "ERROR: ${comp_name} hash mismatch"
|
||||
echo " expected (manifest): $comp_sha"
|
||||
echo " actual: $ACTUAL_SHA"
|
||||
checkoff "$comp_name" "fail" "hash mismatch"
|
||||
HAD_FAILURES=true
|
||||
rm -f "$TMP_COMP"
|
||||
continue
|
||||
fi
|
||||
echo "✓ ${comp_name} hash verified"
|
||||
|
||||
# Install the binary.
|
||||
case "$comp_name" in
|
||||
agent)
|
||||
# Save server public key for TOFU (already done above conceptually,
|
||||
# but we do it here since the agent binary is now verified).
|
||||
if [ -n "$SERVER_PUBKEY" ]; then
|
||||
echo -n "$SERVER_PUBKEY" | sudo tee "${SERVER_KEY_DIR}/server_public_key" > /dev/null
|
||||
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/server_public_key"
|
||||
fi
|
||||
# Per-binary signature is covered by the manifest (already verified).
|
||||
# The agent trusts the manifest-signed hash chain.
|
||||
sudo mv "$TMP_COMP" "${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
sudo chmod +x "${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
if command -v restorecon &>/dev/null; then
|
||||
sudo restorecon "${INSTALL_DIR}/${SERVICE_NAME}" 2>/dev/null || true
|
||||
fi
|
||||
if command -v setcap &>/dev/null; then
|
||||
sudo setcap cap_sys_ptrace=eip "${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
echo "✓ CAP_SYS_PTRACE granted (display/process discovery)"
|
||||
fi
|
||||
checkoff "$comp_name" "ok" "installed at ${INSTALL_DIR}/${SERVICE_NAME}"
|
||||
;;
|
||||
helper)
|
||||
sudo mv "$TMP_COMP" "${INSTALL_DIR}/redflag-helper"
|
||||
sudo chown root:root "${INSTALL_DIR}/redflag-helper"
|
||||
sudo chmod 755 "${INSTALL_DIR}/redflag-helper"
|
||||
if command -v restorecon &>/dev/null; then
|
||||
sudo restorecon "${INSTALL_DIR}/redflag-helper" 2>/dev/null || true
|
||||
fi
|
||||
# Helper state dir, root-only.
|
||||
sudo mkdir -p "${BASE_DIR}/helper"
|
||||
sudo chown root:root "${BASE_DIR}/helper"
|
||||
sudo chmod 700 "${BASE_DIR}/helper"
|
||||
# Trusted-keys keyring for the helper.
|
||||
HELPER_KEYRING_DIR="${CONFIG_DIR}/trusted-keys"
|
||||
sudo mkdir -p "$HELPER_KEYRING_DIR"
|
||||
echo -n "$SERVER_PUBKEY" | sudo tee "${HELPER_KEYRING_DIR}/server.pub" > /dev/null
|
||||
sudo chown -R root:root "$HELPER_KEYRING_DIR"
|
||||
sudo chmod 755 "$HELPER_KEYRING_DIR"
|
||||
sudo chmod 644 "${HELPER_KEYRING_DIR}/server.pub"
|
||||
checkoff "$comp_name" "ok" "installed at ${INSTALL_DIR}/redflag-helper"
|
||||
;;
|
||||
desktop)
|
||||
sudo mv "$TMP_COMP" "${INSTALL_DIR}/redflag-desktop"
|
||||
sudo chmod 755 "${INSTALL_DIR}/redflag-desktop"
|
||||
if command -v restorecon &>/dev/null; then
|
||||
sudo restorecon "${INSTALL_DIR}/redflag-desktop" 2>/dev/null || true
|
||||
fi
|
||||
# Autostart entry (provisioning check: autostart_entry).
|
||||
XDG_AUTOSTART_DIR="/etc/xdg/autostart"
|
||||
if [ -d "$XDG_AUTOSTART_DIR" ] || sudo mkdir -p "$XDG_AUTOSTART_DIR"; then
|
||||
cat <<EOF | sudo tee "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop" > /dev/null
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=RedFlag
|
||||
Comment=RedFlag system tray (local agent dashboard)
|
||||
Exec=${DESKTOP_BIN}
|
||||
Exec=${INSTALL_DIR}/redflag-desktop
|
||||
Terminal=false
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
sudo chmod 644 "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop"
|
||||
echo "✓ Tray autostart entry installed (${XDG_AUTOSTART_DIR}/redflag-desktop.desktop)"
|
||||
fi
|
||||
|
||||
# Stock GNOME ships no tray host — without the AppIndicator extension
|
||||
# the icon never appears and the tray looks broken. Other desktops
|
||||
# (KDE, Hyprland/waybar, XFCE) support StatusNotifier out of the box.
|
||||
if command -v gnome-shell >/dev/null && ! compgen -G "/usr/share/gnome-shell/extensions/*appindicator*" >/dev/null; then
|
||||
echo "[INFO] [installer] [desktop] GNOME detected without the AppIndicator extension — install gnome-shell-extension-appindicator or the tray icon will not be visible"
|
||||
fi
|
||||
|
||||
# The local API socket lives under /var/lib/redflag (0710, group
|
||||
# ${LOCAL_API_GROUP}) — the tray cannot reach it unless its desktop
|
||||
# user is in the group. Add the invoking user; takes effect at next
|
||||
# login. Membership grants read + local actions only; mutation still
|
||||
# requires the capability-token path.
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
|
||||
if id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; then
|
||||
echo "✓ $SUDO_USER already in $LOCAL_API_GROUP group"
|
||||
else
|
||||
sudo usermod -aG "$LOCAL_API_GROUP" "$SUDO_USER"
|
||||
echo "✓ Added $SUDO_USER to $LOCAL_API_GROUP (tray socket access — re-login to take effect)"
|
||||
sudo chmod 644 "${XDG_AUTOSTART_DIR}/redflag-desktop.desktop"
|
||||
fi
|
||||
else
|
||||
echo "[INFO] [installer] [desktop] No desktop user detected — add tray users to $LOCAL_API_GROUP manually"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
echo "INFO: Desktop app not available for this platform/arch — skipping"
|
||||
rm -f "$TMP_DESKTOP" "$TMP_DESKTOP_HDR"
|
||||
# GNOME AppIndicator note.
|
||||
if command -v gnome-shell >/dev/null && ! compgen -G "/usr/share/gnome-shell/extensions/*appindicator*" >/dev/null; then
|
||||
echo "[INFO] [installer] [desktop] GNOME detected without AppIndicator extension — install gnome-shell-extension-appindicator"
|
||||
fi
|
||||
# Add invoking user to redflag-local group (provisioning: desktop_user_membership).
|
||||
if [ -n "${SUDO_USER:-}" ] && [ "${SUDO_USER}" != "root" ]; then
|
||||
if id -nG "$SUDO_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$LOCAL_API_GROUP"; then
|
||||
echo "✓ $SUDO_USER already in $LOCAL_API_GROUP group"
|
||||
else
|
||||
sudo usermod -aG "$LOCAL_API_GROUP" "$SUDO_USER"
|
||||
echo "✓ Added $SUDO_USER to $LOCAL_API_GROUP (tray socket access — re-login to take effect)"
|
||||
fi
|
||||
fi
|
||||
checkoff "$comp_name" "ok" "installed at ${INSTALL_DIR}/redflag-desktop"
|
||||
;;
|
||||
esac
|
||||
done < "$MANIFEST_LINES"
|
||||
rm -f "$MANIFEST_LINES"
|
||||
|
||||
# Fail the install if any required component failed.
|
||||
if $HAD_FAILURES; then
|
||||
echo ""
|
||||
echo "ERROR: One or more required components failed to install."
|
||||
emit_checkoff_report
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Save server public key for TOFU model.
|
||||
if [ -n "$SERVER_PUBKEY" ]; then
|
||||
echo -n "$SERVER_PUBKEY" | sudo tee "${SERVER_KEY_DIR}/server_public_key" > /dev/null
|
||||
sudo chown {{.AgentUser}}:{{.AgentUser}} "${SERVER_KEY_DIR}/server_public_key"
|
||||
echo "Server public key saved for TOFU verification"
|
||||
fi
|
||||
|
||||
# Step 8: Handle configuration
|
||||
|
|
@ -953,7 +991,13 @@ if [ -n "{{.ServerPublicKey}}" ] && [ -f "${AGENT_CONFIG_DIR}/config.json" ]; th
|
|||
fi
|
||||
fi
|
||||
|
||||
# Step 10e: Emit checkoff report (install-time verification).
|
||||
# The manifest was already verified; this records what was actually installed
|
||||
# on this host and posts it as a security event.
|
||||
emit_checkoff_report
|
||||
|
||||
# Step 11: Enable and start service
|
||||
echo ""
|
||||
echo "Enabling and starting service..."
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable ${SERVICE_NAME}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Settings, Database, User, Shield, Eye, EyeOff, CheckCircle, Key } from 'lucide-react';
|
||||
import { Database, Settings, Shield, User, Eye, EyeOff, CheckCircle, Key } from 'lucide-react';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { setupApi } from '@/lib/api';
|
||||
import { useAuthStore } from '@/lib/store';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SetupFormData {
|
||||
adminUser: string;
|
||||
|
|
@ -26,6 +27,63 @@ interface SigningKeys {
|
|||
algorithm: string;
|
||||
}
|
||||
|
||||
// ---- local primitives (reused within Setup) ----
|
||||
|
||||
/** Section header with icon — repeated 4× in the form. */
|
||||
const FormSection: React.FC<{ icon: React.ElementType; title: string; children: React.ReactNode }> = ({
|
||||
icon: Icon, title, children,
|
||||
}) => (
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Icon className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">{title}</h3>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
type AlertKind = 'error' | 'info' | 'success' | 'warning';
|
||||
|
||||
const ALERT_STYLES: Record<AlertKind, string> = {
|
||||
error: 'bg-red-50 border-red-200 text-red-800',
|
||||
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||
success: 'bg-green-50 border-green-200 text-green-800',
|
||||
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
|
||||
};
|
||||
|
||||
const Alert: React.FC<{ kind: AlertKind; children: React.ReactNode; className?: string }> = ({
|
||||
kind, children, className,
|
||||
}) => (
|
||||
<div className={cn('border rounded-md p-3 text-sm', ALERT_STYLES[kind], className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** Standard text input with label — 15+ instances in the form. */
|
||||
const TextField: React.FC<{
|
||||
id: string; label: string; name: string; value: string;
|
||||
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
type?: string; placeholder?: string; required?: boolean; readOnly?: boolean;
|
||||
rightButton?: React.ReactNode;
|
||||
}> = ({ id, label, name, value, onChange, type = 'text', placeholder, required, readOnly, rightButton }) => (
|
||||
<div>
|
||||
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">{label}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={type} id={id} name={name} value={value} onChange={onChange}
|
||||
placeholder={placeholder} required={required} readOnly={readOnly}
|
||||
className={cn(
|
||||
'block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm sm:text-sm',
|
||||
'placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500',
|
||||
readOnly && 'bg-gray-100',
|
||||
rightButton && 'pr-10',
|
||||
)}
|
||||
/>
|
||||
{rightButton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Setup: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { logout } = useAuthStore();
|
||||
|
|
@ -33,11 +91,18 @@ const Setup: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
const [envContent, setEnvContent] = useState<string | null>(null);
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showDbPassword, setShowDbPassword] = useState(false);
|
||||
const [visiblePasswords, setVisiblePasswords] = useState<Set<string>>(new Set());
|
||||
const [signingKeys, setSigningKeys] = useState<SigningKeys | null>(null);
|
||||
const [generatingKeys, setGeneratingKeys] = useState(false);
|
||||
|
||||
const togglePassword = useCallback((field: string) => {
|
||||
setVisiblePasswords(prev => {
|
||||
const next = new Set(prev);
|
||||
next.has(field) ? next.delete(field) : next.add(field);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const [formData, setFormData] = useState<SetupFormData>({
|
||||
adminUser: 'admin',
|
||||
adminPassword: '',
|
||||
|
|
@ -337,299 +402,97 @@ const Setup: React.FC = () => {
|
|||
{/* Setup Form */}
|
||||
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-8">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className="alert alert-danger rounded-md">
|
||||
<div className="text-sm text-red-800">{error}</div>
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert kind="error">{error}</Alert>}
|
||||
|
||||
{/* Administrator Account */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<User className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Administrator Account</h3>
|
||||
</div>
|
||||
<FormSection icon={User} title="Administrator Account">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="adminUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="adminUser"
|
||||
name="adminUser"
|
||||
value={formData.adminUser}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="admin"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="adminPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Admin Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
id="adminPassword"
|
||||
name="adminPassword"
|
||||
value={formData.adminPassword}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter secure password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<TextField id="adminUser" label="Admin Username" name="adminUser"
|
||||
value={formData.adminUser} onChange={handleInputChange} placeholder="admin" required />
|
||||
<TextField id="adminPassword" label="Admin Password" name="adminPassword"
|
||||
value={formData.adminPassword} onChange={handleInputChange}
|
||||
type={visiblePasswords.has('admin') ? 'text' : 'password'}
|
||||
placeholder="Enter secure password" required
|
||||
rightButton={
|
||||
<button type="button" className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => togglePassword('admin')}>
|
||||
{visiblePasswords.has('admin')
|
||||
? <EyeOff className="h-4 w-4 text-gray-400" />
|
||||
: <Eye className="h-4 w-4 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Keys Section */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Key className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Security Keys</h3>
|
||||
</div>
|
||||
<div className="alert alert-info rounded-md mb-4">
|
||||
<p className="text-sm text-blue-800">
|
||||
Generate Ed25519 signing keys for secure agent updates.
|
||||
<strong> Save the private key securely</strong> - it will be included in your configuration.
|
||||
</p>
|
||||
} />
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
<FormSection icon={Key} title="Security Keys">
|
||||
<Alert kind="info" className="mb-4">
|
||||
Generate Ed25519 signing keys for secure agent updates.
|
||||
<strong> Save the private key securely</strong> — it will be included in your configuration.
|
||||
</Alert>
|
||||
{!signingKeys ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateKeys}
|
||||
disabled={generatingKeys}
|
||||
className="w-full py-2 px-4 border border-indigo-600 text-indigo-600 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
|
||||
>
|
||||
<button type="button" onClick={handleGenerateKeys} disabled={generatingKeys}
|
||||
className="w-full py-2 px-4 border border-indigo-600 text-indigo-600 rounded-md hover:bg-indigo-50 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center">
|
||||
{generatingKeys ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2"></div>
|
||||
Generating Keys...
|
||||
</>
|
||||
<><div className="animate-spin rounded-full h-4 w-4 border-b-2 border-indigo-600 mr-2" />Generating Keys...</>
|
||||
) : (
|
||||
<>
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Generate Signing Keys
|
||||
</>
|
||||
<><Key className="h-4 w-4 mr-2" />Generate Signing Keys</>
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Public Key Fingerprint
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={signingKeys.fingerprint}
|
||||
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Algorithm
|
||||
</label>
|
||||
<input
|
||||
readOnly
|
||||
value={signingKeys.algorithm.toUpperCase()}
|
||||
className="block w-full px-3 py-2 bg-gray-100 border border-gray-300 rounded-md text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="alert alert-success rounded-md p-3">
|
||||
<p className="text-sm text-green-800">
|
||||
✓ Keys generated! Private key will be securely included in your configuration file.
|
||||
</p>
|
||||
</div>
|
||||
<TextField id="keyFingerprint" label="Public Key Fingerprint" name="keyFingerprint"
|
||||
value={signingKeys.fingerprint} onChange={() => {}} readOnly />
|
||||
<TextField id="keyAlgorithm" label="Algorithm" name="keyAlgorithm"
|
||||
value={signingKeys.algorithm.toUpperCase()} onChange={() => {}} readOnly />
|
||||
<Alert kind="success">
|
||||
Keys generated! Private key will be securely included in your configuration file.
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Database className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Database Configuration</h3>
|
||||
</div>
|
||||
<FormSection icon={Database} title="Database Configuration">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div>
|
||||
<label htmlFor="dbHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbHost"
|
||||
name="dbHost"
|
||||
value={formData.dbHost}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="postgres"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="dbPort"
|
||||
name="dbPort"
|
||||
value={formData.dbPort}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbName" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbName"
|
||||
name="dbName"
|
||||
value={formData.dbName}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbUser" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database User
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="dbUser"
|
||||
name="dbUser"
|
||||
value={formData.dbUser}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="redflag"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="dbPassword" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Database Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showDbPassword ? 'text' : 'password'}
|
||||
id="dbPassword"
|
||||
name="dbPassword"
|
||||
value={formData.dbPassword}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 pr-10 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="Enter database password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowDbPassword(!showDbPassword)}
|
||||
>
|
||||
{showDbPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
<TextField id="dbHost" label="Database Host" name="dbHost"
|
||||
value={formData.dbHost} onChange={handleInputChange} placeholder="postgres" required />
|
||||
<TextField id="dbPort" label="Database Port" name="dbPort"
|
||||
value={formData.dbPort} onChange={handleInputChange} type="number" required />
|
||||
<TextField id="dbName" label="Database Name" name="dbName"
|
||||
value={formData.dbName} onChange={handleInputChange} placeholder="redflag" required />
|
||||
<TextField id="dbUser" label="Database User" name="dbUser"
|
||||
value={formData.dbUser} onChange={handleInputChange} placeholder="redflag" required />
|
||||
<TextField id="dbPassword" label="Database Password" name="dbPassword"
|
||||
value={formData.dbPassword} onChange={handleInputChange}
|
||||
type={visiblePasswords.has('db') ? 'text' : 'password'}
|
||||
placeholder="Enter database password" required
|
||||
rightButton={
|
||||
<button type="button" className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => togglePassword('db')}>
|
||||
{visiblePasswords.has('db')
|
||||
? <EyeOff className="h-4 w-4 text-gray-400" />
|
||||
: <Eye className="h-4 w-4 text-gray-400" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
} />
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Server Configuration */}
|
||||
<div>
|
||||
<div className="flex items-center mb-4">
|
||||
<Settings className="h-5 w-5 text-indigo-600 mr-2" />
|
||||
<h3 className="text-lg font-semibold text-gray-900">Server Configuration</h3>
|
||||
</div>
|
||||
<FormSection icon={Settings} title="Server Configuration">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label htmlFor="serverHost" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Host
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="serverHost"
|
||||
name="serverHost"
|
||||
value={formData.serverHost}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="0.0.0.0"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="serverPort" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Server Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="serverPort"
|
||||
name="serverPort"
|
||||
value={formData.serverPort}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="8080"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="maxSeats" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Maximum Agent Seats
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="maxSeats"
|
||||
name="maxSeats"
|
||||
value={formData.maxSeats}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
min="1"
|
||||
max="1000"
|
||||
placeholder="50"
|
||||
required
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
|
||||
</div>
|
||||
<TextField id="serverHost" label="Server Host" name="serverHost"
|
||||
value={formData.serverHost} onChange={handleInputChange} placeholder="0.0.0.0" required />
|
||||
<TextField id="serverPort" label="Server Port" name="serverPort"
|
||||
value={formData.serverPort} onChange={handleInputChange} type="number" placeholder="8080" required />
|
||||
<TextField id="maxSeats" label="Maximum Agent Seats" name="maxSeats"
|
||||
value={formData.maxSeats} onChange={handleInputChange} type="number" required
|
||||
placeholder="50" />
|
||||
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="publicURL" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Agent-facing Server URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="publicURL"
|
||||
name="publicURL"
|
||||
value={formData.publicURL}
|
||||
onChange={handleInputChange}
|
||||
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||
placeholder="http://redflag.example.com:8080"
|
||||
required
|
||||
/>
|
||||
<TextField id="publicURL" label="Agent-facing Server URL" name="publicURL"
|
||||
value={formData.publicURL} onChange={handleInputChange} type="url"
|
||||
placeholder="http://redflag.example.com:8080" required />
|
||||
<p className="mt-1 text-xs text-gray-500">Used in generated install commands and agent callbacks</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormSection>
|
||||
|
||||
{/* Submit Button */}
|
||||
<div className="pt-6 border-t border-gray-200">
|
||||
|
|
|
|||
Loading…
Reference in a new issue