source: advance the checked projection

Source-Sha: 194a59adfd6c901635da7a9c18253bb2210e2426
Policy-Sha: b9b0be0ee74e55f561a803b8aef6be3c2134f5a83b46816a069a372f2eb04f4d
Tree-Digest: 4697f2e281c1c6ecafff17cf6eeb391a0a1ac0fa6e5fc429f5b473f4579c252c
This commit is contained in:
Source publisher 2026-09-21 14:39:08 +00:00
commit ffdccba10f
42 changed files with 2516 additions and 554 deletions

View file

@ -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")

View file

@ -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)
}
}

View file

@ -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

View file

@ -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"`

View file

@ -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 != "" {

View file

@ -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.

View file

@ -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) {

View file

@ -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 ---

View file

@ -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 {

View file

@ -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
}

View file

@ -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)

View file

@ -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)