Watch
1
0
Fork
You've already forked RedFlag
0
RedFlag/agent/internal/localapi/server.go
Fimeg 1241c1ef01 feat: embed web UI in server binary + local trigger-scan write endpoint
Server becomes self-contained: web/dist embedded via go:embed
(server/internal/webui), SPA served from the binary with JSON-404 guard on
/api paths, nginx web container removed from compose (31336 now maps to the
server). Clean checkouts without the UI copy build API-only.

Agent local API gains its first write endpoint, POST /v1/actions/trigger-scan
(FEAT-002 write path): group-ACL authorized, single-flight, 202/409/503
semantics. Registered agents run the same HandleScanUpdates path as a signed
scan command (empty command_id, no ack tracking); standalone agents scan
through the orchestrator into the local read model only. Also repairs
localapi tests left uncompilable by the desktop-provider parameter.
2026-06-10 08:38:18 -04:00

408 lines
12 KiB
Go

package localapi
import (
"encoding/json"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"sort"
"time"
"github.com/Fimeg/RedFlag/agent/internal/cache"
"github.com/Fimeg/RedFlag/agent/internal/client"
"github.com/Fimeg/RedFlag/agent/internal/config"
"github.com/Fimeg/RedFlag/agent/internal/version"
)
const (
DefaultUnixGroupName = "redflag-local"
DefaultWindowsGroupName = "RedFlagLocal"
DefaultWindowsPipeName = `\\.\pipe\RedFlagAgentLocal`
)
// ErrScanInFlight is returned by a TriggerScan callback while a previously
// triggered scan is still running. The handler maps it to 409 Conflict.
var ErrScanInFlight = errors.New("localapi: scan already in flight")
// Options configures the local read-only API. Group, socket, and pipe defaults
// are platform-specific and enforced by the listener implementation.
type Options struct {
Config *config.Config
GroupName string
UnixSocketPath string
WindowsPipeName string
LoadCache func() (*cache.LocalCache, error)
RequestLog func(format string, args ...interface{})
ListenerOverride net.Listener
DesktopProvider DesktopStatusProvider
// TriggerScan enqueues a package-update scan through the agent's existing
// scanner primitives (FEAT-002 write path). Authorization is the OS-local
// group boundary on the socket/pipe — anyone who can connect may trigger.
// Nil disables the endpoint (503). Return ErrScanInFlight to signal 409.
TriggerScan func(source string) error
}
// Server owns the local API listener and HTTP server.
type Server struct {
httpServer *http.Server
listener net.Listener
address string
logf func(format string, args ...interface{})
}
// Start creates a platform-native local listener and serves the read-only local
// API. It returns an error before serving if the OS-local ACL cannot be applied.
func Start(opts Options) (*Server, error) {
if opts.Config == nil {
return nil, errors.New("localapi: config is required")
}
if opts.LoadCache == nil {
opts.LoadCache = cache.Load
}
if opts.RequestLog == nil {
opts.RequestLog = log.Printf
}
handler := newHandler(opts.Config, opts.LoadCache, opts.DesktopProvider, opts.TriggerScan)
httpServer := &http.Server{Handler: handler}
listener := opts.ListenerOverride
address := ""
var err error
if listener == nil {
listener, address, err = listen(opts)
if err != nil {
return nil, err
}
} else {
address = listener.Addr().String()
}
srv := &Server{
httpServer: httpServer,
listener: listener,
address: address,
logf: opts.RequestLog,
}
go func() {
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
opts.RequestLog("[ERROR] [agent] [localapi] serve_failed address=%s error=%v", address, err)
}
}()
opts.RequestLog("[INFO] [agent] [localapi] started address=%s", address)
return srv, nil
}
// Stop shuts down the local API listener.
func (s *Server) Stop() {
if s == nil || s.httpServer == nil {
return
}
if err := s.httpServer.Close(); err != nil && s.logf != nil {
s.logf("[WARNING] [agent] [localapi] stop_failed address=%s error=%v", s.address, err)
}
}
type handler struct {
cfg *config.Config
loadCache func() (*cache.LocalCache, error)
desktop DesktopStatusProvider
triggerScan func(source string) error
}
// DesktopStatusProvider allows the desktop manager to report its status.
type DesktopStatusProvider interface {
Status() (running bool, pid int)
}
type IdentityResponse struct {
AgentID string `json:"agent_id"`
ServerURL string `json:"server_url"`
Hostname string `json:"hostname,omitempty"`
OSType string `json:"os_type,omitempty"`
DisplayName string `json:"display_name,omitempty"`
Organization string `json:"organization,omitempty"`
Tags []string `json:"tags,omitempty"`
AgentVersion string `json:"agent_version"`
ConfigVersion string `json:"config_version,omitempty"`
CheckInInterval int `json:"check_in_interval"`
Registered bool `json:"registered"`
}
type StatusResponse struct {
AgentStatus string `json:"agent_status"`
LastCheckIn time.Time `json:"last_check_in,omitempty"`
LastUpdated time.Time `json:"last_updated,omitempty"`
LastScan time.Time `json:"last_scan_time,omitempty"`
UpdateCount int `json:"update_count"`
Summary cache.UpdateSummary `json:"summary"`
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
Registered bool `json:"registered"`
Desktop *DesktopStatus `json:"desktop,omitempty"`
}
type DesktopStatus struct {
Running bool `json:"running"`
PID int `json:"pid,omitempty"`
Enabled bool `json:"enabled"`
}
// DesktopHealthRequest is sent by the desktop app to report its health.
type DesktopHealthRequest struct {
Version string `json:"version"`
Uptime int64 `json:"uptime_seconds"`
WindowOpen bool `json:"window_open"`
}
type ScanResponse struct {
LastScanTime time.Time `json:"last_scan_time,omitempty"`
LastUpdated time.Time `json:"last_updated,omitempty"`
UpdateCount int `json:"update_count"`
Summary cache.UpdateSummary `json:"summary"`
Scanners map[string]cache.ScannerState `json:"scanners,omitempty"`
Updates []client.UpdateReportItem `json:"updates"`
}
type TokenResponse struct {
Capabilities cache.CapabilityTokenState `json:"capabilities"`
}
func newHandler(cfg *config.Config, loadCache func() (*cache.LocalCache, error), desktop DesktopStatusProvider, triggerScan func(source string) error) http.Handler {
h := &handler{cfg: cfg, loadCache: loadCache, desktop: desktop, triggerScan: triggerScan}
mux := http.NewServeMux()
mux.HandleFunc("/v1/identity", h.identity)
mux.HandleFunc("/v1/status", h.status)
mux.HandleFunc("/v1/scans/latest", h.scansLatest)
mux.HandleFunc("/v1/packages", h.scansLatest)
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
mux.HandleFunc("/v1/desktop", h.desktopHealth)
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
return mux
}
func (h *handler) identity(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
hostname, err := os.Hostname()
if err != nil {
log.Printf("[WARNING] [agent] [localapi] hostname_failed error=%v", err)
}
resp := IdentityResponse{
AgentID: h.cfg.AgentID.String(),
ServerURL: h.cfg.ServerURL,
Hostname: hostname,
OSType: h.cfg.OSType,
DisplayName: h.cfg.DisplayName,
Organization: h.cfg.Organization,
Tags: sortedCopy(h.cfg.Tags),
AgentVersion: version.Version,
ConfigVersion: h.cfg.Version,
CheckInInterval: h.cfg.CheckInInterval,
Registered: h.cfg.IsRegistered(),
}
writeJSON(w, resp)
}
func (h *handler) status(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
resp := StatusResponse{
AgentStatus: localCache.AgentStatus,
LastCheckIn: localCache.LastCheckIn,
LastUpdated: localCache.LastUpdated,
LastScan: localCache.LastScanTime,
UpdateCount: localCache.UpdateCount,
Summary: localCache.Summary,
Scanners: localCache.Scanners,
Registered: h.cfg.IsRegistered(),
}
if h.desktop != nil {
running, pid := h.desktop.Status()
resp.Desktop = &DesktopStatus{
Running: running,
PID: pid,
Enabled: h.cfg.Desktop.Enabled,
}
}
writeJSON(w, resp)
}
func (h *handler) scansLatest(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
writeJSON(w, ScanResponse{
LastScanTime: localCache.LastScanTime,
LastUpdated: localCache.LastUpdated,
UpdateCount: localCache.UpdateCount,
Summary: localCache.Summary,
Scanners: localCache.Scanners,
Updates: localCache.Updates,
})
}
func (h *handler) tokensActive(w http.ResponseWriter, r *http.Request) {
if !requireGet(w, r) {
return
}
localCache, ok := h.load(w)
if !ok {
return
}
writeJSON(w, TokenResponse{Capabilities: localCache.Capabilities})
}
// desktopHealth handles POST /v1/desktop — the desktop app reports its health.
func (h *handler) desktopHealth(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
}
var req DesktopHealthRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid request body", http.StatusBadRequest)
return
}
// Log the desktop health report.
log.Printf("[INFO] [agent] [localapi] desktop_health version=%s uptime=%ds window_open=%v",
req.Version, req.Uptime, req.WindowOpen)
// Respond with agent status so the desktop app can display it.
desktopStatus := DesktopStatus{Enabled: h.cfg.Desktop.Enabled}
if h.desktop != nil {
running, pid := h.desktop.Status()
desktopStatus.Running = running
desktopStatus.PID = pid
}
writeJSON(w, map[string]interface{}{
"agent_version": version.Version,
"desktop": desktopStatus,
})
}
// triggerScanAction handles POST /v1/actions/trigger-scan — the first local
// write endpoint (FEAT-002). The OS-local group ACL on the socket/pipe is the
// authorization boundary. The scan runs through the agent's existing scanner
// primitives; this never bypasses server command-signing or package
// authorization paths because it cannot install anything.
func (h *handler) triggerScanAction(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.triggerScan == nil {
log.Printf("[WARNING] [agent] [localapi] scan_trigger_unavailable")
http.Error(w, "scan trigger unavailable", http.StatusServiceUnavailable)
return
}
err := h.triggerScan("localapi")
if errors.Is(err, ErrScanInFlight) {
log.Printf("[INFO] [agent] [localapi] scan_trigger_rejected reason=in_flight")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusConflict)
writeJSONBody(w, map[string]interface{}{"accepted": false, "error": "scan already in flight"})
return
}
if err != nil {
log.Printf("[ERROR] [agent] [localapi] scan_trigger_failed error=%v", err)
http.Error(w, "scan trigger failed", http.StatusInternalServerError)
return
}
log.Printf("[INFO] [agent] [localapi] scan_trigger_accepted source=localapi")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSONBody(w, map[string]interface{}{"accepted": true})
}
func (h *handler) load(w http.ResponseWriter) (*cache.LocalCache, bool) {
localCache, err := h.loadCache()
if err != nil {
log.Printf("[ERROR] [agent] [localapi] cache_load_failed error=%v", err)
http.Error(w, "local state unavailable", http.StatusServiceUnavailable)
return nil, false
}
return localCache, true
}
func requireGet(w http.ResponseWriter, r *http.Request) bool {
if r.Method == http.MethodGet {
return true
}
w.Header().Set("Allow", http.MethodGet)
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return false
}
func writeJSON(w http.ResponseWriter, value interface{}) {
w.Header().Set("Content-Type", "application/json")
writeJSONBody(w, value)
}
// writeJSONBody encodes without touching headers — for callers that already
// wrote a non-200 status code.
func writeJSONBody(w http.ResponseWriter, value interface{}) {
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("[WARNING] [agent] [localapi] response_encode_failed error=%v", err)
}
}
func sortedCopy(values []string) []string {
if len(values) == 0 {
return nil
}
copied := append([]string(nil), values...)
sort.Strings(copied)
return copied
}
func groupName(opts Options) string {
if opts.GroupName != "" {
return opts.GroupName
}
return defaultGroupName()
}
func unixSocketPath(opts Options) string {
if opts.UnixSocketPath != "" {
return opts.UnixSocketPath
}
return defaultUnixSocketPath()
}
func windowsPipeName(opts Options) string {
if opts.WindowsPipeName != "" {
return opts.WindowsPipeName
}
return DefaultWindowsPipeName
}
func formatGroupMissing(name string, err error) error {
return fmt.Errorf("localapi: local access group %q unavailable: %w", name, err)
}