source: advance the checked projection
Source-Sha: 194a59adfd6c901635da7a9c18253bb2210e2426 Policy-Sha: b9b0be0ee74e55f561a803b8aef6be3c2134f5a83b46816a069a372f2eb04f4d Tree-Digest: 4697f2e281c1c6ecafff17cf6eeb391a0a1ac0fa6e5fc429f5b473f4579c252c
This commit is contained in:
parent
0401c76c79
commit
ffdccba10f
42 changed files with 2516 additions and 554 deletions
|
|
@ -18,6 +18,7 @@ type CLI struct {
|
|||
Scan bool
|
||||
Status bool
|
||||
LocalStatus bool
|
||||
VerifyEnvelope string
|
||||
InitStandalone bool
|
||||
ListUpdates bool
|
||||
Version bool
|
||||
|
|
@ -49,6 +50,7 @@ func ParseFlags() *CLI {
|
|||
flag.BoolVar(&cli.Scan, "scan", false, "Scan for updates and display locally")
|
||||
flag.BoolVar(&cli.Status, "status", false, "Show agent status")
|
||||
flag.BoolVar(&cli.LocalStatus, "local-status", false, "Show live local agent status over local IPC")
|
||||
flag.StringVar(&cli.VerifyEnvelope, "verify-envelope", "", "Verify a signed mutation envelope through the installed privileged Helper and print its receipt")
|
||||
flag.BoolVar(&cli.InitStandalone, "init-standalone", false, "Create or print this host's standalone Agent identity")
|
||||
flag.BoolVar(&cli.ListUpdates, "list-updates", false, "List detailed update information")
|
||||
flag.BoolVar(&cli.Version, "version", false, "Show version information")
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
|
@ -48,6 +49,16 @@ func main() {
|
|||
return
|
||||
}
|
||||
|
||||
// Read-only proof of the privileged Helper transport (SEC-030). It runs
|
||||
// before config load for the same reason -local-status does: driving the
|
||||
// fixed task needs the Agent's own rights, not its protected config.
|
||||
if cli.VerifyEnvelope != "" {
|
||||
if err := HandleVerifyEnvelopeCommand(cli.VerifyEnvelope); err != nil {
|
||||
log.Fatal("Envelope verification failed: ", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Windows service management commands
|
||||
if HandleWindowsServiceCommands(cli) {
|
||||
return
|
||||
|
|
@ -91,8 +102,8 @@ func main() {
|
|||
|
||||
// Handle registration command
|
||||
if cli.Register {
|
||||
if cfg.IsStandalone() {
|
||||
log.Fatal("Registration refused: standalone fleet join is not implemented; do not add fleet credentials beside local authority")
|
||||
if cfg.IsStandalone() || cfg.PendingFleetJoin != nil {
|
||||
log.Fatal("Registration refused: use RedFlag Desktop to complete the standalone fleet join")
|
||||
}
|
||||
if err := handleRegistration(cfg, cli.ServerURL); err != nil {
|
||||
log.Fatal("Registration failed:", err)
|
||||
|
|
@ -133,6 +144,17 @@ func main() {
|
|||
}
|
||||
defer unlock()
|
||||
|
||||
// A Server may have accepted this identity before the process stopped
|
||||
// during local authority replacement. Resume that exact transaction before
|
||||
// evaluating mode; the helper is idempotent for the same Server key.
|
||||
if cfg.PendingFleetJoin != nil {
|
||||
receipt, err := handlers.CompletePendingFleetJoin(context.Background(), cfg, configPath)
|
||||
if err != nil {
|
||||
log.Fatalf("[FATAL] [agent] [fleet-join] recovery_failed request_id=%s error=%v", cfg.PendingFleetJoin.RequestID, err)
|
||||
}
|
||||
log.Printf("[INFO] [agent] [fleet-join] recovery_completed request_id=%s key_id=%s", receipt.RequestID, receipt.SigningKeyID)
|
||||
}
|
||||
|
||||
// Check if registered
|
||||
if !cfg.IsRegistered() && !cfg.IsStandalone() {
|
||||
log.Fatal("Agent has no complete identity. Register with a fleet or run the standalone provisioning script.")
|
||||
|
|
@ -147,7 +169,7 @@ func main() {
|
|||
}
|
||||
|
||||
// Start agent service (console mode)
|
||||
if err := agent.RunAgentLoop(cfg); err != nil {
|
||||
if err := agent.RunAgentLoop(cfg, configPath); err != nil {
|
||||
log.Fatal("Agent failed:", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ func newCircuitBreaker(name string, cfg config.CircuitBreakerConfig) *circuitbre
|
|||
}
|
||||
|
||||
// RunAgentLoop runs the main agent polling loop
|
||||
func RunAgentLoop(cfg *config.Config) error {
|
||||
func RunAgentLoop(cfg *config.Config, configPath string) error {
|
||||
// Panic recovery for the main agent loop [TD-002]
|
||||
defer recovery.Recover("agent_main_loop")
|
||||
|
||||
|
|
@ -65,6 +66,7 @@ func RunAgentLoop(cfg *config.Config) error {
|
|||
loopCtx, err := NewLoopContext(cfg, LoopContextOptions{
|
||||
Ctx: context.Background(),
|
||||
EnableDesktop: true,
|
||||
ConfigPath: configPath,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -89,6 +91,7 @@ type LoopContextOptions struct {
|
|||
Ctx context.Context
|
||||
StopCh <-chan struct{}
|
||||
EnableDesktop bool
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
// NewLoopContext builds the canonical dependency graph for the agent polling
|
||||
|
|
@ -98,6 +101,9 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
|
|||
if opts.Ctx == nil {
|
||||
opts.Ctx = context.Background()
|
||||
}
|
||||
if opts.ConfigPath == "" {
|
||||
opts.ConfigPath = constants.GetAgentConfigPath()
|
||||
}
|
||||
|
||||
apiClient := client.NewClient(cfg.ServerURL, cfg.Token)
|
||||
|
||||
|
|
@ -284,6 +290,7 @@ func NewLoopContext(cfg *config.Config, opts LoopContextOptions) (*LoopContext,
|
|||
TeeLogger: teeLogger,
|
||||
EventBuffer: teeLogger.Buffer(),
|
||||
StopCh: opts.StopCh,
|
||||
ConfigPath: opts.ConfigPath,
|
||||
CircuitBreakers: map[string]*circuitbreaker.CircuitBreaker{
|
||||
"apt": aptCB,
|
||||
"dnf": dnfCB,
|
||||
|
|
@ -314,9 +321,10 @@ type LoopContext struct {
|
|||
EventBuffer *event.Buffer
|
||||
Ctx context.Context
|
||||
StopCh <-chan struct{} // non-nil causes loop to exit cleanly when closed
|
||||
ConfigPath string
|
||||
}
|
||||
|
||||
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
|
||||
func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error, modeChanged <-chan struct{}) error {
|
||||
recordLocalAgentStatus(ctx.Cfg, "standalone", false)
|
||||
log.Printf("[INFO] [agent] [standalone] local_mode_started agent_id=%s", ctx.Cfg.AgentID)
|
||||
if err := triggerScan("standalone-startup"); err != nil {
|
||||
|
|
@ -335,6 +343,9 @@ func runStandaloneLoop(ctx *LoopContext, triggerScan func(string) error) error {
|
|||
return nil
|
||||
case <-ctx.StopCh:
|
||||
return nil
|
||||
case <-modeChanged:
|
||||
log.Printf("[INFO] [agent] [fleet-join] standalone_loop_stopped restart_required=true")
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := triggerScan("standalone-interval"); err != nil && !errors.Is(err, localapi.ErrScanInFlight) {
|
||||
ctx.TeeLogger.Warning("agent", "standalone", "scan", "periodic_scan_not_started", map[string]interface{}{"error": err.Error()})
|
||||
|
|
@ -351,6 +362,15 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
defer recovery.Recover("agent_polling_loop")
|
||||
|
||||
ctx := loopCtx
|
||||
modeChanged := make(chan struct{})
|
||||
var modeChangeOnce sync.Once
|
||||
var fleetJoinInFlight atomic.Bool
|
||||
var fleetJoinAccepted atomic.Bool
|
||||
fleetJoinRequestID := ctx.Cfg.FleetJoinRequestID
|
||||
stopStandaloneForFleet := func() {
|
||||
fleetJoinAccepted.Store(true)
|
||||
time.AfterFunc(750*time.Millisecond, func() { modeChangeOnce.Do(func() { close(modeChanged) }) })
|
||||
}
|
||||
|
||||
// FEAT-002 write path: single-flight scan trigger for the local API.
|
||||
// Authorization is the socket/pipe group ACL; the scan itself runs through
|
||||
|
|
@ -379,6 +399,9 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
// typed failures into localapi sentinels for honest HTTP codes.
|
||||
var approveInFlight atomic.Bool
|
||||
approveUpdate := func(body []byte) (interface{}, error) {
|
||||
if fleetJoinInFlight.Load() || fleetJoinAccepted.Load() {
|
||||
return nil, fmt.Errorf("%w: fleet authority transition is in progress", localapi.ErrApprovalConflict)
|
||||
}
|
||||
if !approveInFlight.CompareAndSwap(false, true) {
|
||||
return nil, fmt.Errorf("%w: approval already in flight", localapi.ErrApprovalConflict)
|
||||
}
|
||||
|
|
@ -415,6 +438,53 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}
|
||||
}
|
||||
|
||||
prepareFleetJoin := func() (interface{}, error) {
|
||||
if fleetJoinInFlight.Load() || fleetJoinAccepted.Load() {
|
||||
return nil, fmt.Errorf("%w: fleet join is already in progress", localapi.ErrFleetJoinConflict)
|
||||
}
|
||||
result, err := handlers.PrepareLocalFleetJoin(ctx.Cfg)
|
||||
switch {
|
||||
case err == nil:
|
||||
return result, nil
|
||||
case errors.Is(err, handlers.ErrFleetJoinMode):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
|
||||
case errors.Is(err, handlers.ErrFleetJoinUnavailable):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinUnavailable, err)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
joinFleet := func(body []byte) (interface{}, error) {
|
||||
if fleetJoinAccepted.Load() || !fleetJoinInFlight.CompareAndSwap(false, true) {
|
||||
return nil, fmt.Errorf("%w: fleet join is already in progress", localapi.ErrFleetJoinConflict)
|
||||
}
|
||||
defer fleetJoinInFlight.Store(false)
|
||||
var req handlers.LocalFleetJoinRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return nil, fmt.Errorf("invalid fleet join request: %w", err)
|
||||
}
|
||||
next := *ctx.Cfg
|
||||
next.FleetJoinRequestID = fleetJoinRequestID
|
||||
result, err := handlers.HandleLocalFleetJoin(ctx.Ctx, &next, ctx.ConfigPath, req)
|
||||
fleetJoinRequestID = next.FleetJoinRequestID
|
||||
switch {
|
||||
case err == nil:
|
||||
stopStandaloneForFleet()
|
||||
return result, nil
|
||||
case errors.Is(err, handlers.ErrFleetJoinAccepted):
|
||||
stopStandaloneForFleet()
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
|
||||
case errors.Is(err, handlers.ErrFleetJoinMode):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinConflict, err)
|
||||
case errors.Is(err, handlers.ErrFleetJoinUnavailable),
|
||||
errors.Is(err, supplychain.ErrFleetAuthorityUnavailable):
|
||||
return nil, fmt.Errorf("%w: %v", localapi.ErrFleetJoinUnavailable, err)
|
||||
default:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var onDesktopHealth func(version string, windowOpen bool)
|
||||
if ctx.DesktopManager != nil {
|
||||
onDesktopHealth = ctx.DesktopManager.RecordHealth
|
||||
|
|
@ -447,14 +517,16 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
return response, nil
|
||||
}
|
||||
localAPIServer, err := localapi.Start(localapi.Options{
|
||||
Config: ctx.Cfg,
|
||||
DesktopProvider: ctx.DesktopManager,
|
||||
TriggerScan: triggerScan,
|
||||
ScanRunning: scanInFlight.Load,
|
||||
ApproveUpdate: approveUpdate,
|
||||
OnDesktopHealth: onDesktopHealth,
|
||||
DockerProvider: dockerProvider,
|
||||
EventsProvider: ctx.EventBuffer.ReadHistory,
|
||||
Config: ctx.Cfg,
|
||||
DesktopProvider: ctx.DesktopManager,
|
||||
TriggerScan: triggerScan,
|
||||
ScanRunning: scanInFlight.Load,
|
||||
ApproveUpdate: approveUpdate,
|
||||
PrepareFleetJoin: prepareFleetJoin,
|
||||
JoinFleet: joinFleet,
|
||||
OnDesktopHealth: onDesktopHealth,
|
||||
DockerProvider: dockerProvider,
|
||||
EventsProvider: ctx.EventBuffer.ReadHistory,
|
||||
})
|
||||
if err != nil {
|
||||
ctx.TeeLogger.Error("agent", "localapi", "localapi", fmt.Sprintf("start_failed error=%v", err), map[string]interface{}{"error": err.Error()})
|
||||
|
|
@ -483,7 +555,7 @@ func RunPollingLoop(loopCtx *LoopContext) error {
|
|||
}
|
||||
|
||||
if ctx.Cfg.IsStandalone() {
|
||||
return runStandaloneLoop(ctx, triggerScan)
|
||||
return runStandaloneLoop(ctx, triggerScan, modeChanged)
|
||||
}
|
||||
|
||||
consecutiveFailures := 0
|
||||
|
|
|
|||
|
|
@ -338,6 +338,38 @@ type RegisterResponse struct {
|
|||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// FleetJoinRequest preserves a standalone Agent's UUID while registering that
|
||||
// same machine with a Server. The seed stays on the host; only its current TOTP
|
||||
// code crosses this endpoint.
|
||||
type FleetJoinRequest struct {
|
||||
RequestID uuid.UUID `json:"request_id"`
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
RegistrationToken string `json:"registration_token"`
|
||||
TOTPCode string `json:"totp_code"`
|
||||
Hostname string `json:"hostname"`
|
||||
OSType string `json:"os_type"`
|
||||
OSVersion string `json:"os_version"`
|
||||
OSArchitecture string `json:"os_architecture"`
|
||||
AgentVersion string `json:"agent_version"`
|
||||
MachineID string `json:"machine_id"`
|
||||
PublicKeyFingerprint string `json:"public_key_fingerprint"`
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
AvailableScanners []string `json:"available_scanners"`
|
||||
DeviceType string `json:"device_type"`
|
||||
DeviceModel string `json:"device_model"`
|
||||
OSDistro string `json:"os_distro"`
|
||||
}
|
||||
|
||||
type FleetJoinResponse struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
SigningPublicKey string `json:"signing_public_key"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Config map[string]interface{} `json:"config"`
|
||||
}
|
||||
|
||||
// Register registers the agent with the server
|
||||
func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/agents/register", c.baseURL)
|
||||
|
|
@ -388,6 +420,36 @@ func (c *Client) Register(req RegisterRequest) (*RegisterResponse, error) {
|
|||
return &result, nil
|
||||
}
|
||||
|
||||
// JoinFleet calls the dedicated one-way standalone transition endpoint. It
|
||||
// does not mutate this Client's token because the caller must first persist the
|
||||
// returned credentials and replace local helper authority.
|
||||
func (c *Client) JoinFleet(req FleetJoinRequest) (*FleetJoinResponse, error) {
|
||||
endpoint := fmt.Sprintf("%s/api/v1/fleet-join", c.baseURL)
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode fleet join request: %w", err)
|
||||
}
|
||||
httpReq, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create fleet join request: %w", err)
|
||||
}
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := c.http.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fleet join request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusCreated {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
|
||||
return nil, fmt.Errorf("fleet join failed: %s - %s", resp.Status, string(bodyBytes))
|
||||
}
|
||||
var result FleetJoinResponse
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&result); err != nil {
|
||||
return nil, fmt.Errorf("decode fleet join response: %w", err)
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// TokenRenewalRequest is the payload for token renewal using refresh token
|
||||
type TokenRenewalRequest struct {
|
||||
AgentID uuid.UUID `json:"agent_id"`
|
||||
|
|
@ -1072,7 +1134,7 @@ type SystemInfoReport struct {
|
|||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Processes int `json:"processes,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
DeviceType string `json:"device_type,omitempty"` // Re-reported so reinstalls reclassify (DEVICE-001)
|
||||
DeviceType string `json:"device_type,omitempty"` // Re-reported so reinstalls reclassify (DEVICE-001)
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
OSDistro string `json:"os_distro,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
|
|
|
|||
|
|
@ -182,6 +182,13 @@ type Config struct {
|
|||
// Desktop App Configuration
|
||||
Desktop DesktopConfig `json:"desktop,omitempty"`
|
||||
|
||||
// Standalone to fleet transition. Non-nil means the Server has accepted
|
||||
// this identity but local authority replacement has not fully converged.
|
||||
PendingFleetJoin *PendingFleetJoin `json:"pending_fleet_join,omitempty"`
|
||||
// Stable across retries so a lost Server response cannot turn one join into
|
||||
// a second credential-minting request.
|
||||
FleetJoinRequestID string `json:"fleet_join_request_id,omitempty"`
|
||||
|
||||
// Process Explorer Configuration
|
||||
ProcessExplorer ProcessExplorerConfig `json:"process_explorer,omitempty"`
|
||||
|
||||
|
|
@ -199,6 +206,20 @@ type DesktopConfig struct {
|
|||
RestartDelaySec int `json:"restart_delay_sec"` // Seconds between restart attempts
|
||||
}
|
||||
|
||||
// PendingFleetJoin is persisted only after the fleet server has accepted this
|
||||
// standalone identity. It is the crash-recovery bridge between remote
|
||||
// registration and the privileged helper replacing local authority.
|
||||
type PendingFleetJoin struct {
|
||||
RequestID string `json:"request_id"`
|
||||
ServerURL string `json:"server_url"`
|
||||
Token string `json:"token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
SigningPublicKey string `json:"signing_public_key"`
|
||||
SigningKeyID string `json:"signing_key_id"`
|
||||
CheckInInterval int `json:"check_in_interval,omitempty"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
}
|
||||
|
||||
// Load reads configuration from multiple sources with priority order:
|
||||
// 1. CLI flags
|
||||
// 2. Environment variables
|
||||
|
|
@ -776,21 +797,21 @@ func (c *Config) SetDegradedMode(enabled bool) error {
|
|||
|
||||
// IsRegistered checks if the agent is registered
|
||||
func (c *Config) IsRegistered() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token != ""
|
||||
return c.AgentID != uuid.Nil && c.Token != "" && c.RefreshToken != "" && c.PendingFleetJoin == nil
|
||||
}
|
||||
|
||||
// IsStandalone reports whether this config has a stable local identity and no
|
||||
// fleet credential. A partial fleet enrollment is not standalone: refresh or
|
||||
// registration material must never silently become local mutation authority.
|
||||
func (c *Config) IsStandalone() bool {
|
||||
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == ""
|
||||
return c.AgentID != uuid.Nil && c.Token == "" && c.RefreshToken == "" && c.RegistrationToken == "" && c.PendingFleetJoin == nil
|
||||
}
|
||||
|
||||
// InitializeStandalone gives a local-only Agent one durable UUID. It is
|
||||
// idempotent, but refuses any fleet credential so provisioning cannot convert a
|
||||
// fleet host into local authority by accident.
|
||||
func (c *Config) InitializeStandalone() error {
|
||||
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" {
|
||||
if c.IsRegistered() || c.Token != "" || c.RefreshToken != "" || c.RegistrationToken != "" || c.PendingFleetJoin != nil {
|
||||
return fmt.Errorf("standalone identity refused: fleet enrollment material is present")
|
||||
}
|
||||
if c.AgentID != uuid.Nil {
|
||||
|
|
@ -930,6 +951,13 @@ func mergeConfigPreservingDefaults(target, source *Config) {
|
|||
if source.Desktop != (DesktopConfig{}) {
|
||||
target.Desktop = source.Desktop
|
||||
}
|
||||
if source.PendingFleetJoin != nil {
|
||||
pending := *source.PendingFleetJoin
|
||||
target.PendingFleetJoin = &pending
|
||||
}
|
||||
if source.FleetJoinRequestID != "" {
|
||||
target.FleetJoinRequestID = source.FleetJoinRequestID
|
||||
}
|
||||
|
||||
// Version info
|
||||
if source.Version != "" {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,16 @@ package crypto
|
|||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
|
|
@ -156,6 +159,60 @@ func saveCacheMetadata(meta *CacheMetadata) error {
|
|||
return os.WriteFile(getPrimaryMetaPath(), data, 0644)
|
||||
}
|
||||
|
||||
// CacheFleetServerPublicKey installs the exact key returned by the fleet-join
|
||||
// transaction. This bypasses TOFU cache reuse: a standalone host may have an
|
||||
// unrelated stale cache, and that must not outrank the authority just adopted
|
||||
// by the privileged helper.
|
||||
func CacheFleetServerPublicKey(publicKeyHex, keyID string) error {
|
||||
raw, err := hex.DecodeString(publicKeyHex)
|
||||
if err != nil {
|
||||
return fmt.Errorf("decode joined Server public key: %w", err)
|
||||
}
|
||||
if len(raw) != ed25519.PublicKeySize {
|
||||
return fmt.Errorf("joined Server public key is %d bytes, want %d", len(raw), ed25519.PublicKeySize)
|
||||
}
|
||||
digest := sha256.Sum256(raw)
|
||||
computedID := hex.EncodeToString(digest[:16])
|
||||
if keyID == "" || keyID != computedID {
|
||||
return fmt.Errorf("joined Server key id mismatch: got %q want %q", keyID, computedID)
|
||||
}
|
||||
if err := removeCachedKeysExcept(keyID); err != nil {
|
||||
return fmt.Errorf("retire pre-join Server key cache: %w", err)
|
||||
}
|
||||
key := ed25519.PublicKey(raw)
|
||||
if err := cachePublicKey(key); err != nil {
|
||||
return fmt.Errorf("cache joined Server primary key: %w", err)
|
||||
}
|
||||
if err := CachePublicKeyByID(keyID, key); err != nil {
|
||||
return fmt.Errorf("cache joined Server key id: %w", err)
|
||||
}
|
||||
return saveCacheMetadata(&CacheMetadata{
|
||||
KeyID: keyID, Version: 1, CachedAt: time.Now().UTC(), TTLHours: defaultCacheTTLHours,
|
||||
})
|
||||
}
|
||||
|
||||
func removeCachedKeysExcept(keyID string) error {
|
||||
dir := getPublicKeyDir()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keep := filepath.Base(getKeyPathByID(keyID))
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
if !strings.HasPrefix(name, "server_public_key_") || name == keep {
|
||||
continue
|
||||
}
|
||||
if err := os.Remove(filepath.Join(dir, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FetchAndCacheServerPublicKey fetches the server's Ed25519 primary public key.
|
||||
// Uses a TTL+key_id cache: skips the fetch only if both TTL is valid AND key_id matches.
|
||||
// Implements Trust-On-First-Use (TOFU) with rotation awareness.
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func TestLocalApprovalRefusesFleetModeBeforeResolution(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet"}, LocalApproveRequest{
|
||||
_, err = HandleLocalApprove(context.Background(), &config.Config{AgentID: id, Token: "fleet", RefreshToken: "refresh"}, LocalApproveRequest{
|
||||
PackageType: "pacman", PackageName: "linux", Operator: "operator", OverrideReason: "accepted",
|
||||
})
|
||||
if !errors.Is(err, ErrApprovalFleetMode) {
|
||||
|
|
|
|||
|
|
@ -71,11 +71,15 @@ func RunPostUpgradeHealthcheck(cfg *config.Config) int {
|
|||
"desktop binary not found at %s — local operations console will not appear", desktopPath)
|
||||
}
|
||||
|
||||
// --- Autostart entry (Linux only) ---
|
||||
// --- Desktop session launchers (Linux only) ---
|
||||
if cfg.Desktop.Enabled && runtime.GOOS == "linux" {
|
||||
autostartPath := "/etc/xdg/autostart/redflag-desktop.desktop"
|
||||
check(fileExists(autostartPath),
|
||||
"desktop autostart entry not found at %s — RedFlag Desktop will not start on next login", autostartPath)
|
||||
|
||||
compositorUnitPath := "/etc/systemd/user/redflag-desktop-compositor.service"
|
||||
check(fileExists(compositorUnitPath),
|
||||
"desktop compositor session launcher not found at %s — RedFlag Desktop will not start in Hyprland/Sway/River sessions", compositorUnitPath)
|
||||
}
|
||||
|
||||
// --- Socket directory permissions ---
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ var (
|
|||
ErrApprovalConflict = errors.New("localapi: approval conflict")
|
||||
// ErrApprovalUnavailable → 503: no local authority on this host.
|
||||
ErrApprovalUnavailable = errors.New("localapi: approval unavailable")
|
||||
// ErrFleetJoinConflict maps mode, validation, and in-flight joins to 409.
|
||||
ErrFleetJoinConflict = errors.New("localapi: fleet join conflict")
|
||||
// ErrFleetJoinUnavailable maps a missing platform/helper path to 503.
|
||||
ErrFleetJoinUnavailable = errors.New("localapi: fleet join unavailable")
|
||||
)
|
||||
|
||||
// Options configures the local read-only API. Group, socket, and pipe defaults
|
||||
|
|
@ -75,6 +79,11 @@ type Options struct {
|
|||
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
|
||||
// ErrApprovalUnavailable to control the HTTP status.
|
||||
ApproveUpdate func(body []byte) (interface{}, error)
|
||||
// PrepareFleetJoin creates the host-held proof seed. JoinFleet consumes the
|
||||
// Server URL, one-seat join token, and that seed through the one-way helper
|
||||
// transition. Both are disabled outside standalone mode.
|
||||
PrepareFleetJoin func() (interface{}, error)
|
||||
JoinFleet func(body []byte) (interface{}, error)
|
||||
// OnDesktopHealth receives each Desktop self-report (POST /v1/desktop) so the
|
||||
// agent can track app liveness/version — on Linux Desktop is autostart-
|
||||
// launched and this is the only signal. Nil means reports are logged only.
|
||||
|
|
@ -159,24 +168,26 @@ func (s *Server) Stop() {
|
|||
}
|
||||
|
||||
type handler struct {
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
desktop DesktopStatusProvider
|
||||
triggerScan func(source string) error
|
||||
approveUpdate func(body []byte) (interface{}, error)
|
||||
onDesktopHealth func(version string, windowOpen bool)
|
||||
systemInfo func() (*system.SystemInfo, error)
|
||||
topProcesses func(limit int) ([]system.TopProcess, error)
|
||||
monitorSnapshot func() (*system.ResourceSnapshot, error)
|
||||
processes func() (*system.FullProcessSnapshot, error)
|
||||
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
software func() (*system.SoftwareSnapshot, error)
|
||||
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
|
||||
connections func() (*system.ConnectionSnapshot, error)
|
||||
services func() (*system.ServiceSnapshot, error)
|
||||
docker func() (*DockerResponse, error)
|
||||
events func() ([]*models.SystemEvent, error)
|
||||
scanRunning func() bool
|
||||
cfg *config.Config
|
||||
loadCache func() (*cache.LocalCache, error)
|
||||
desktop DesktopStatusProvider
|
||||
triggerScan func(source string) error
|
||||
approveUpdate func(body []byte) (interface{}, error)
|
||||
prepareFleetJoin func() (interface{}, error)
|
||||
joinFleet func(body []byte) (interface{}, error)
|
||||
onDesktopHealth func(version string, windowOpen bool)
|
||||
systemInfo func() (*system.SystemInfo, error)
|
||||
topProcesses func(limit int) ([]system.TopProcess, error)
|
||||
monitorSnapshot func() (*system.ResourceSnapshot, error)
|
||||
processes func() (*system.FullProcessSnapshot, error)
|
||||
processDetail func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
software func() (*system.SoftwareSnapshot, error)
|
||||
packageDetail func(packageType, identity string) (*system.PackageDetail, error)
|
||||
connections func() (*system.ConnectionSnapshot, error)
|
||||
services func() (*system.ServiceSnapshot, error)
|
||||
docker func() (*DockerResponse, error)
|
||||
events func() ([]*models.SystemEvent, error)
|
||||
scanRunning func() bool
|
||||
}
|
||||
|
||||
// DesktopStatusProvider allows the desktop manager to report its status.
|
||||
|
|
@ -196,6 +207,7 @@ type IdentityResponse struct {
|
|||
ConfigVersion string `json:"config_version,omitempty"`
|
||||
CheckInInterval int `json:"check_in_interval"`
|
||||
Registered bool `json:"registered"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type StatusResponse struct {
|
||||
|
|
@ -277,24 +289,26 @@ type SecurityResponse struct {
|
|||
|
||||
func newHandler(opts Options) http.Handler {
|
||||
h := &handler{
|
||||
cfg: opts.Config,
|
||||
loadCache: opts.LoadCache,
|
||||
desktop: opts.DesktopProvider,
|
||||
triggerScan: opts.TriggerScan,
|
||||
approveUpdate: opts.ApproveUpdate,
|
||||
onDesktopHealth: opts.OnDesktopHealth,
|
||||
systemInfo: opts.SystemProvider,
|
||||
topProcesses: opts.ProcessProvider,
|
||||
monitorSnapshot: opts.MonitorProvider,
|
||||
processes: opts.ProcessesProvider,
|
||||
processDetail: opts.ProcessDetailProvider,
|
||||
software: opts.SoftwareProvider,
|
||||
packageDetail: opts.PackageDetailProvider,
|
||||
connections: opts.ConnectionsProvider,
|
||||
services: opts.ServicesProvider,
|
||||
docker: opts.DockerProvider,
|
||||
events: opts.EventsProvider,
|
||||
scanRunning: opts.ScanRunning,
|
||||
cfg: opts.Config,
|
||||
loadCache: opts.LoadCache,
|
||||
desktop: opts.DesktopProvider,
|
||||
triggerScan: opts.TriggerScan,
|
||||
approveUpdate: opts.ApproveUpdate,
|
||||
prepareFleetJoin: opts.PrepareFleetJoin,
|
||||
joinFleet: opts.JoinFleet,
|
||||
onDesktopHealth: opts.OnDesktopHealth,
|
||||
systemInfo: opts.SystemProvider,
|
||||
topProcesses: opts.ProcessProvider,
|
||||
monitorSnapshot: opts.MonitorProvider,
|
||||
processes: opts.ProcessesProvider,
|
||||
processDetail: opts.ProcessDetailProvider,
|
||||
software: opts.SoftwareProvider,
|
||||
packageDetail: opts.PackageDetailProvider,
|
||||
connections: opts.ConnectionsProvider,
|
||||
services: opts.ServicesProvider,
|
||||
docker: opts.DockerProvider,
|
||||
events: opts.EventsProvider,
|
||||
scanRunning: opts.ScanRunning,
|
||||
}
|
||||
if h.systemInfo == nil {
|
||||
h.systemInfo = func() (*system.SystemInfo, error) {
|
||||
|
|
@ -345,6 +359,8 @@ func newHandler(opts Options) http.Handler {
|
|||
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
||||
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
||||
mux.HandleFunc("/v1/actions/approve-update", h.approveUpdateAction)
|
||||
mux.HandleFunc("/v1/actions/prepare-fleet-join", h.prepareFleetJoinAction)
|
||||
mux.HandleFunc("/v1/actions/join-fleet", h.joinFleetAction)
|
||||
return mux
|
||||
}
|
||||
|
||||
|
|
@ -566,6 +582,14 @@ func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
|
|||
log.Printf("[WARNING] [agent] [localapi] hostname_failed error=%v", err)
|
||||
}
|
||||
|
||||
mode := "incomplete"
|
||||
if h.cfg.IsRegistered() {
|
||||
mode = "fleet"
|
||||
} else if h.cfg.IsStandalone() {
|
||||
mode = "standalone"
|
||||
} else if h.cfg.PendingFleetJoin != nil {
|
||||
mode = "joining"
|
||||
}
|
||||
resp := IdentityResponse{
|
||||
AgentID: h.cfg.AgentID.String(),
|
||||
ServerURL: h.cfg.ServerURL,
|
||||
|
|
@ -578,6 +602,7 @@ func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
|
|||
ConfigVersion: h.cfg.Version,
|
||||
CheckInInterval: h.cfg.CheckInInterval,
|
||||
Registered: h.cfg.IsRegistered(),
|
||||
Mode: mode,
|
||||
}
|
||||
writeJSON(w, resp)
|
||||
}
|
||||
|
|
@ -762,6 +787,69 @@ func (h *handler) approveUpdateAction(w http.ResponseWriter, r *http.Request) {
|
|||
}
|
||||
}
|
||||
|
||||
func (h *handler) prepareFleetJoinAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.prepareFleetJoin == nil {
|
||||
http.Error(w, "fleet join unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
result, err := h.prepareFleetJoin()
|
||||
switch {
|
||||
case err == nil:
|
||||
writeJSON(w, result)
|
||||
case errors.Is(err, ErrFleetJoinConflict):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
case errors.Is(err, ErrFleetJoinUnavailable):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("[ERROR] [agent] [localapi] fleet_join_prepare_failed error=%v", err)
|
||||
http.Error(w, "fleet join preparation failed", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) joinFleetAction(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.Header().Set("Allow", http.MethodPost)
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
if h.joinFleet == nil {
|
||||
http.Error(w, "fleet join unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
|
||||
if err != nil {
|
||||
http.Error(w, "request read failed", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
result, err := h.joinFleet(body)
|
||||
switch {
|
||||
case err == nil:
|
||||
writeJSON(w, result)
|
||||
case errors.Is(err, ErrFleetJoinConflict):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
case errors.Is(err, ErrFleetJoinUnavailable):
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
default:
|
||||
log.Printf("[ERROR] [agent] [localapi] fleet_join_failed error=%v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
|
||||
localCache, err := h.loadCache()
|
||||
if err != nil {
|
||||
|
|
|
|||
|
|
@ -13,93 +13,18 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/scanner"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/version"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// RegisterAgent registers the agent with the server
|
||||
func RegisterAgent(cfg *config.Config, serverURL string) error {
|
||||
// Get detailed system information
|
||||
sysInfo, err := system.GetSystemInfo(version.Version)
|
||||
req, err := collectRegistrationRequest()
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
|
||||
// Fall back to basic detection
|
||||
hostname, _ := os.Hostname()
|
||||
osType, osVersion, osArch := client.DetectSystem()
|
||||
sysInfo = &system.SystemInfo{
|
||||
Hostname: hostname,
|
||||
OSType: osType,
|
||||
OSVersion: osVersion,
|
||||
OSArchitecture: osArch,
|
||||
AgentVersion: version.Version,
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Use registration token from config if available
|
||||
apiClient := client.NewClient(serverURL, cfg.RegistrationToken)
|
||||
|
||||
// Create metadata with system information
|
||||
metadata := map[string]string{
|
||||
"installation_time": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Add system info to metadata
|
||||
if sysInfo.CPUInfo.ModelName != "" {
|
||||
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
|
||||
}
|
||||
if sysInfo.CPUInfo.Cores > 0 {
|
||||
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
|
||||
}
|
||||
if sysInfo.MemoryInfo.Total > 0 {
|
||||
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
|
||||
}
|
||||
if sysInfo.RunningProcesses > 0 {
|
||||
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
|
||||
}
|
||||
if sysInfo.Uptime != "" {
|
||||
metadata["uptime"] = sysInfo.Uptime
|
||||
}
|
||||
|
||||
// Add disk information
|
||||
for i, disk := range sysInfo.DiskInfo {
|
||||
if i == 0 {
|
||||
metadata["disk_mount"] = disk.Mountpoint
|
||||
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
|
||||
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
|
||||
break // Only add primary disk info
|
||||
}
|
||||
}
|
||||
|
||||
// Get machine ID for binding
|
||||
machineID, err := system.GetMachineID()
|
||||
if err != nil {
|
||||
return fmt.Errorf("machine_id_unavailable: %w - cannot register without consistent machine ID", err)
|
||||
}
|
||||
|
||||
// Get embedded public key fingerprint
|
||||
publicKeyFingerprint := system.GetPublicKeyFingerprint()
|
||||
if publicKeyFingerprint == "" {
|
||||
log.Printf("Warning: No embedded public key fingerprint found")
|
||||
}
|
||||
|
||||
// Detect available scanners for platform-specific subsystem creation
|
||||
availableScanners := scanner.DetectAvailable()
|
||||
log.Printf("[INFO] [agent] [registration] detected_scanners=%v", availableScanners)
|
||||
|
||||
req := client.RegisterRequest{
|
||||
Hostname: sysInfo.Hostname,
|
||||
OSType: sysInfo.OSType,
|
||||
OSVersion: sysInfo.OSVersion,
|
||||
OSArchitecture: sysInfo.OSArchitecture,
|
||||
AgentVersion: sysInfo.AgentVersion,
|
||||
MachineID: machineID,
|
||||
PublicKeyFingerprint: publicKeyFingerprint,
|
||||
Metadata: metadata,
|
||||
AvailableScanners: availableScanners,
|
||||
DeviceType: sysInfo.DeviceType,
|
||||
DeviceModel: sysInfo.DeviceModel,
|
||||
OSDistro: sysInfo.OSDistro,
|
||||
}
|
||||
|
||||
resp, err := apiClient.Register(req)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -136,9 +61,121 @@ func RegisterAgent(cfg *config.Config, serverURL string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func collectRegistrationRequest() (client.RegisterRequest, error) {
|
||||
// Get detailed system information
|
||||
sysInfo, err := system.GetSystemInfo(version.Version)
|
||||
if err != nil {
|
||||
log.Printf("Warning: Failed to get detailed system info: %v\n", err)
|
||||
// Fall back to basic detection
|
||||
hostname, _ := os.Hostname()
|
||||
osType, osVersion, osArch := client.DetectSystem()
|
||||
sysInfo = &system.SystemInfo{
|
||||
Hostname: hostname,
|
||||
OSType: osType,
|
||||
OSVersion: osVersion,
|
||||
OSArchitecture: osArch,
|
||||
AgentVersion: version.Version,
|
||||
Metadata: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
// Create metadata with system information
|
||||
metadata := map[string]string{
|
||||
"installation_time": time.Now().Format(time.RFC3339),
|
||||
}
|
||||
|
||||
// Add system info to metadata
|
||||
if sysInfo.CPUInfo.ModelName != "" {
|
||||
metadata["cpu_model"] = sysInfo.CPUInfo.ModelName
|
||||
}
|
||||
if sysInfo.CPUInfo.Cores > 0 {
|
||||
metadata["cpu_cores"] = fmt.Sprintf("%d", sysInfo.CPUInfo.Cores)
|
||||
}
|
||||
if sysInfo.MemoryInfo.Total > 0 {
|
||||
metadata["memory_total"] = fmt.Sprintf("%d", sysInfo.MemoryInfo.Total)
|
||||
}
|
||||
if sysInfo.RunningProcesses > 0 {
|
||||
metadata["processes"] = fmt.Sprintf("%d", sysInfo.RunningProcesses)
|
||||
}
|
||||
if sysInfo.Uptime != "" {
|
||||
metadata["uptime"] = sysInfo.Uptime
|
||||
}
|
||||
|
||||
// Add disk information
|
||||
for i, disk := range sysInfo.DiskInfo {
|
||||
if i == 0 {
|
||||
metadata["disk_mount"] = disk.Mountpoint
|
||||
metadata["disk_total"] = fmt.Sprintf("%d", disk.Total)
|
||||
metadata["disk_used"] = fmt.Sprintf("%d", disk.Used)
|
||||
break // Only add primary disk info
|
||||
}
|
||||
}
|
||||
|
||||
// Get machine ID for binding
|
||||
machineID, err := system.GetMachineID()
|
||||
if err != nil {
|
||||
return client.RegisterRequest{}, fmt.Errorf("machine_id_unavailable: %w - cannot register without consistent machine ID", err)
|
||||
}
|
||||
|
||||
// Get embedded public key fingerprint
|
||||
publicKeyFingerprint := system.GetPublicKeyFingerprint()
|
||||
if publicKeyFingerprint == "" {
|
||||
log.Printf("Warning: No embedded public key fingerprint found")
|
||||
}
|
||||
|
||||
// Detect available scanners for platform-specific subsystem creation
|
||||
availableScanners := scanner.DetectAvailable()
|
||||
log.Printf("[INFO] [agent] [registration] detected_scanners=%v", availableScanners)
|
||||
|
||||
return client.RegisterRequest{
|
||||
Hostname: sysInfo.Hostname,
|
||||
OSType: sysInfo.OSType,
|
||||
OSVersion: sysInfo.OSVersion,
|
||||
OSArchitecture: sysInfo.OSArchitecture,
|
||||
AgentVersion: sysInfo.AgentVersion,
|
||||
MachineID: machineID,
|
||||
PublicKeyFingerprint: publicKeyFingerprint,
|
||||
Metadata: metadata,
|
||||
AvailableScanners: availableScanners,
|
||||
DeviceType: sysInfo.DeviceType,
|
||||
DeviceModel: sysInfo.DeviceModel,
|
||||
OSDistro: sysInfo.OSDistro,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// JoinFleet registers an existing standalone identity with the fleet Server.
|
||||
// It returns credentials without persisting them; the caller must first finish
|
||||
// the privileged authority replacement transaction.
|
||||
func JoinFleet(cfg *config.Config, requestID, serverURL, registrationToken, seed string) (*client.FleetJoinResponse, error) {
|
||||
if cfg == nil || !cfg.IsStandalone() {
|
||||
return nil, fmt.Errorf("fleet join requires a standalone Agent identity")
|
||||
}
|
||||
requestUUID, err := uuid.FromString(requestID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fleet join request id is invalid: %w", err)
|
||||
}
|
||||
base, err := collectRegistrationRequest()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
code, err := fleetJoinTOTPCode(seed, time.Now().UTC())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req := client.FleetJoinRequest{
|
||||
RequestID: requestUUID, AgentID: cfg.AgentID,
|
||||
RegistrationToken: registrationToken, TOTPCode: code,
|
||||
Hostname: base.Hostname, OSType: base.OSType, OSVersion: base.OSVersion,
|
||||
OSArchitecture: base.OSArchitecture, AgentVersion: base.AgentVersion,
|
||||
MachineID: base.MachineID, PublicKeyFingerprint: base.PublicKeyFingerprint,
|
||||
Metadata: base.Metadata, AvailableScanners: base.AvailableScanners,
|
||||
DeviceType: base.DeviceType, DeviceModel: base.DeviceModel, OSDistro: base.OSDistro,
|
||||
}
|
||||
return client.NewClient(serverURL, "").JoinFleet(req)
|
||||
}
|
||||
|
||||
// FetchAndCachePublicKey fetches the server's Ed25519 public key and caches it locally
|
||||
func FetchAndCachePublicKey(serverURL string) error {
|
||||
_, err := crypto.FetchAndCacheServerPublicKey(serverURL)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/Fimeg/RedFlag/agent/internal/agent"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/config"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/recovery"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/svc"
|
||||
"golang.org/x/sys/windows/svc/debug"
|
||||
"golang.org/x/sys/windows/svc/eventlog"
|
||||
|
|
@ -188,10 +189,12 @@ func InstallService() error {
|
|||
|
||||
// Create service with proper configuration
|
||||
s, err = m.CreateService(serviceName, exePath, mgr.Config{
|
||||
DisplayName: "RedFlag Update Agent",
|
||||
Description: "RedFlag agent for automated system updates and monitoring",
|
||||
StartType: mgr.StartAutomatic,
|
||||
Dependencies: []string{"Tcpip", "Dnscache"},
|
||||
DisplayName: "RedFlag Update Agent",
|
||||
Description: "RedFlag agent for system observation and authorized mutation requests",
|
||||
StartType: mgr.StartAutomatic,
|
||||
Dependencies: []string{"Tcpip", "Dnscache"},
|
||||
ServiceStartName: `NT SERVICE\RedFlagAgent`,
|
||||
SidType: windows.SERVICE_SID_TYPE_UNRESTRICTED,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create service: %w", err)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/capability"
|
||||
|
|
@ -68,6 +69,9 @@ func (e *Executor) MintEnvelope(
|
|||
manifest capability.MutationManifest,
|
||||
gateEvidence GateEvidence,
|
||||
) (*capability.MutationEnvelope, string, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil, "", fmt.Errorf("Windows local mint requires authenticated StepUp; mint authority is unavailable")
|
||||
}
|
||||
requestID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("generate envelope request id: %w", err)
|
||||
|
|
@ -144,6 +148,24 @@ func (e *Executor) MintEnvelope(
|
|||
return &envelope, request.RequestID, nil
|
||||
}
|
||||
|
||||
// VerifyEnvelope drives the installed privileged verifier over the platform's
|
||||
// own transport and returns its joined receipt. Verification only: it mints
|
||||
// nothing, runs no backend, and consumes no execution authorization. SEC-030
|
||||
// requires read-only proof of the Windows transport before a backend can be
|
||||
// admitted, and this is the Agent's one way to ask for it.
|
||||
func (e *Executor) VerifyEnvelope(
|
||||
ctx context.Context,
|
||||
envelope *capability.MutationEnvelope,
|
||||
) (*capability.MutationReceipt, error) {
|
||||
if envelope == nil {
|
||||
return nil, fmt.Errorf("mutation envelope is nil")
|
||||
}
|
||||
if runtime.GOOS != "windows" {
|
||||
return nil, fmt.Errorf("envelope verification transport is implemented on Windows only")
|
||||
}
|
||||
return verifyWindowsEnvelope(ctx, envelope)
|
||||
}
|
||||
|
||||
// ExecuteEnvelope hands one signed envelope to the privileged verifier and
|
||||
// returns its joined receipt, including denials and failed package execution.
|
||||
func (e *Executor) ExecuteEnvelope(
|
||||
|
|
@ -153,6 +175,9 @@ func (e *Executor) ExecuteEnvelope(
|
|||
if envelope == nil {
|
||||
return nil, fmt.Errorf("mutation envelope is nil")
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
return executeWindowsEnvelope(ctx, envelope)
|
||||
}
|
||||
payload, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal mutation envelope: %w", err)
|
||||
|
|
|
|||
Loading…
Reference in a new issue