projection: begin the exact-path public epoch
The public tree and its history contain only the listed paths. Earlier projection history remains preserved internally. Source-Sha: 913fde029b935671833254797f0f20f1eb9fabba Policy-Sha: 913fde029b935671833254797f0f20f1eb9fabba Tree-Digest: 180ae530c1058a2a5c89837bdce2d323ae83e669e38590ca72e75b8d92b7262f
This commit is contained in:
commit
67e26be2d9
838 changed files with 157302 additions and 0 deletions
100
agent/internal/localapi/approve_test.go
Normal file
100
agent/internal/localapi/approve_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
)
|
||||
|
||||
func approveHandler(t *testing.T, approve func(body []byte) (interface{}, error)) http.Handler {
|
||||
t.Helper()
|
||||
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}, ApproveUpdate: approve})
|
||||
}
|
||||
|
||||
func postApprove(t *testing.T, handler http.Handler, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/approve-update", strings.NewReader(body)))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestApproveUpdateSuccess(t *testing.T) {
|
||||
var gotBody string
|
||||
handler := approveHandler(t, func(body []byte) (interface{}, error) {
|
||||
gotBody = string(body)
|
||||
return map[string]interface{}{"request_id": "req-9", "osv_status": "clear"}, nil
|
||||
})
|
||||
|
||||
rec := postApprove(t, handler, `{"package_type":"dnf","package_name":"hyprutils"}`)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if !strings.Contains(gotBody, "hyprutils") {
|
||||
t.Fatalf("callback body = %q, want raw request body", gotBody)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["request_id"] != "req-9" {
|
||||
t.Fatalf("request_id = %v, want req-9", resp["request_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateConflictIs409(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) {
|
||||
return nil, fmt.Errorf("%w: gate refused", ErrApprovalConflict)
|
||||
})
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "gate refused") {
|
||||
t.Fatalf("body = %s, want gate refusal detail", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateNoAuthorityIs503(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) {
|
||||
return nil, fmt.Errorf("%w: no local authority", ErrApprovalUnavailable)
|
||||
})
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateNilCallbackIs503(t *testing.T) {
|
||||
handler := approveHandler(t, nil)
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want 503", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateInternalErrorIs500(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) {
|
||||
return nil, errors.New("dry run exploded")
|
||||
})
|
||||
rec := postApprove(t, handler, `{}`)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want 500", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApproveUpdateRejectsGet(t *testing.T) {
|
||||
handler := approveHandler(t, func([]byte) (interface{}, error) { return nil, nil })
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/approve-update", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want 405", rec.Code)
|
||||
}
|
||||
}
|
||||
86
agent/internal/localapi/client.go
Normal file
86
agent/internal/localapi/client.go
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
const localHTTPBaseURL = "http://redflag.local"
|
||||
|
||||
// ClientOptions configures local IPC client access. Defaults mirror the agent
|
||||
// listener and remain platform-specific.
|
||||
type ClientOptions struct {
|
||||
UnixSocketPath string
|
||||
WindowsPipeName string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// Snapshot is the compact local state needed by status probes and Desktop.
|
||||
type Snapshot struct {
|
||||
Identity IdentityResponse `json:"identity"`
|
||||
Status StatusResponse `json:"status"`
|
||||
}
|
||||
|
||||
// FetchSnapshot reads the local agent API over the platform IPC transport.
|
||||
func FetchSnapshot(ctx context.Context, opts ClientOptions) (*Snapshot, error) {
|
||||
httpClient := newLocalHTTPClient(opts)
|
||||
|
||||
identity, err := fetchJSON[IdentityResponse](ctx, httpClient, "/v1/identity")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch identity: %w", err)
|
||||
}
|
||||
status, err := fetchJSON[StatusResponse](ctx, httpClient, "/v1/status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch status: %w", err)
|
||||
}
|
||||
|
||||
return &Snapshot{
|
||||
Identity: identity,
|
||||
Status: status,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func newLocalHTTPClient(opts ClientOptions) *http.Client {
|
||||
timeout := opts.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
return &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
DialContext: dialLocalContext(opts),
|
||||
DisableKeepAlives: true,
|
||||
Proxy: nil,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fetchJSON[T any](ctx context.Context, httpClient *http.Client, path string) (T, error) {
|
||||
var value T
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, localHTTPBaseURL+path, nil)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return value, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return value, fmt.Errorf("local API returned %s", resp.Status)
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&value); err != nil {
|
||||
return value, err
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func dialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
|
||||
return platformDialLocalContext(opts)
|
||||
}
|
||||
20
agent/internal/localapi/client_unix.go
Normal file
20
agent/internal/localapi/client_unix.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
//go:build !windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
)
|
||||
|
||||
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
|
||||
path := opts.UnixSocketPath
|
||||
if path == "" {
|
||||
path = defaultUnixSocketPath()
|
||||
}
|
||||
|
||||
return func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
var dialer net.Dialer
|
||||
return dialer.DialContext(ctx, "unix", path)
|
||||
}
|
||||
}
|
||||
63
agent/internal/localapi/client_unix_test.go
Normal file
63
agent/internal/localapi/client_unix_test.go
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
//go:build !windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
)
|
||||
|
||||
func TestFetchSnapshotOverUnixSocket(t *testing.T) {
|
||||
socketPath := filepath.Join(t.TempDir(), "redflag-agent.sock")
|
||||
ln, err := net.Listen("unix", socketPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, syscall.EPERM) {
|
||||
t.Skipf("unix sockets are not permitted in this sandbox: %v", err)
|
||||
}
|
||||
t.Fatalf("listen unix socket: %v", err)
|
||||
}
|
||||
|
||||
cfg := testConfig(t)
|
||||
server, err := Start(Options{
|
||||
Config: cfg,
|
||||
LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{
|
||||
AgentStatus: "online",
|
||||
UpdateCount: 7,
|
||||
Summary: cache.UpdateSummary{
|
||||
Total: 7,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
RequestLog: t.Logf,
|
||||
ListenerOverride: ln,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start local API: %v", err)
|
||||
}
|
||||
defer server.Stop()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
snapshot, err := FetchSnapshot(ctx, ClientOptions{UnixSocketPath: socketPath})
|
||||
if err != nil {
|
||||
t.Fatalf("fetch snapshot: %v", err)
|
||||
}
|
||||
|
||||
if snapshot.Identity.AgentID != cfg.AgentID.String() {
|
||||
t.Fatalf("agent_id = %q, want %q", snapshot.Identity.AgentID, cfg.AgentID.String())
|
||||
}
|
||||
if snapshot.Status.AgentStatus != "online" {
|
||||
t.Fatalf("agent_status = %q, want online", snapshot.Status.AgentStatus)
|
||||
}
|
||||
if snapshot.Status.UpdateCount != 7 {
|
||||
t.Fatalf("update_count = %d, want 7", snapshot.Status.UpdateCount)
|
||||
}
|
||||
}
|
||||
21
agent/internal/localapi/client_windows.go
Normal file
21
agent/internal/localapi/client_windows.go
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
//go:build windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
|
||||
winio "github.com/Microsoft/go-winio"
|
||||
)
|
||||
|
||||
func platformDialLocalContext(opts ClientOptions) func(context.Context, string, string) (net.Conn, error) {
|
||||
pipeName := opts.WindowsPipeName
|
||||
if pipeName == "" {
|
||||
pipeName = DefaultWindowsPipeName
|
||||
}
|
||||
|
||||
return func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return winio.DialPipeContext(ctx, pipeName)
|
||||
}
|
||||
}
|
||||
109
agent/internal/localapi/listener_unix.go
Normal file
109
agent/internal/localapi/listener_unix.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
//go:build !windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/constants"
|
||||
)
|
||||
|
||||
const unixSocketFile = "redflag-agent.sock"
|
||||
|
||||
func defaultGroupName() string {
|
||||
return DefaultUnixGroupName
|
||||
}
|
||||
|
||||
func defaultUnixSocketPath() string {
|
||||
return filepath.Join(constants.GetBaseDir(), constants.AgentDir, "localapi", unixSocketFile)
|
||||
}
|
||||
|
||||
func listen(opts Options) (net.Listener, string, error) {
|
||||
path := unixSocketPath(opts)
|
||||
group := groupName(opts)
|
||||
|
||||
gid, err := lookupGroupID(group)
|
||||
if err != nil {
|
||||
return nil, "", formatGroupMissing(group, err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: create socket directory %q: %w", dir, err)
|
||||
}
|
||||
if err := os.Chown(dir, -1, gid); err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: chown socket directory %q to group %q: %w", dir, group, err)
|
||||
}
|
||||
if err := os.Chmod(dir, 0o750); err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: chmod socket directory %q: %w", dir, err)
|
||||
}
|
||||
|
||||
if err := removeStaleSocket(path); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: listen on unix socket %q: %w", path, err)
|
||||
}
|
||||
|
||||
if err := os.Chown(path, -1, gid); err != nil {
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, "", fmt.Errorf("localapi: chown socket %q to group %q: %w", path, group, err)
|
||||
}
|
||||
if err := os.Chmod(path, 0o660); err != nil {
|
||||
_ = ln.Close()
|
||||
_ = os.Remove(path)
|
||||
return nil, "", fmt.Errorf("localapi: chmod socket %q: %w", path, err)
|
||||
}
|
||||
|
||||
return &cleanupListener{Listener: ln, path: path}, path, nil
|
||||
}
|
||||
|
||||
func lookupGroupID(groupName string) (int, error) {
|
||||
group, err := user.LookupGroup(groupName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
gid, err := strconv.Atoi(group.Gid)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse gid %q: %w", group.Gid, err)
|
||||
}
|
||||
return gid, nil
|
||||
}
|
||||
|
||||
func removeStaleSocket(path string) error {
|
||||
info, err := os.Lstat(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("localapi: stat socket path %q: %w", path, err)
|
||||
}
|
||||
if info.Mode()&os.ModeSocket == 0 {
|
||||
return fmt.Errorf("localapi: refusing to replace non-socket path %q", path)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("localapi: remove stale socket %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type cleanupListener struct {
|
||||
net.Listener
|
||||
path string
|
||||
}
|
||||
|
||||
func (l *cleanupListener) Close() error {
|
||||
err := l.Listener.Close()
|
||||
if removeErr := os.Remove(l.path); err == nil && removeErr != nil && !os.IsNotExist(removeErr) {
|
||||
err = removeErr
|
||||
}
|
||||
return err
|
||||
}
|
||||
54
agent/internal/localapi/listener_windows.go
Normal file
54
agent/internal/localapi/listener_windows.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
//go:build windows
|
||||
|
||||
package localapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
winio "github.com/Microsoft/go-winio"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
func defaultGroupName() string {
|
||||
return DefaultWindowsGroupName
|
||||
}
|
||||
|
||||
func defaultUnixSocketPath() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func listen(opts Options) (net.Listener, string, error) {
|
||||
pipeName := windowsPipeName(opts)
|
||||
group := groupName(opts)
|
||||
|
||||
groupSID, err := lookupGroupSID(group)
|
||||
if err != nil {
|
||||
return nil, "", formatGroupMissing(group, err)
|
||||
}
|
||||
|
||||
cfg := &winio.PipeConfig{
|
||||
// LocalSystem and Administrators get full access; the RedFlag local UI
|
||||
// group gets read/write transport rights so it can issue HTTP GETs and
|
||||
// receive responses over the pipe.
|
||||
SecurityDescriptor: fmt.Sprintf("D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;%s)", groupSID),
|
||||
}
|
||||
ln, err := winio.ListenPipe(pipeName, cfg)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("localapi: listen on named pipe %q: %w", pipeName, err)
|
||||
}
|
||||
return ln, pipeName, nil
|
||||
}
|
||||
|
||||
func lookupGroupSID(groupName string) (string, error) {
|
||||
sid, _, accountType, err := windows.LookupSID("", groupName)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch accountType {
|
||||
case windows.SidTypeGroup, windows.SidTypeAlias, windows.SidTypeWellKnownGroup:
|
||||
return sid.String(), nil
|
||||
default:
|
||||
return "", fmt.Errorf("account %q has unexpected SID type %d", groupName, accountType)
|
||||
}
|
||||
}
|
||||
829
agent/internal/localapi/server.go
Normal file
829
agent/internal/localapi/server.go
Normal file
|
|
@ -0,0 +1,829 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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/models"
|
||||
"github.com/Fimeg/RedFlag/agent/internal/system"
|
||||
"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")
|
||||
|
||||
// Approval callback sentinels. The loop wiring translates handler/supplychain
|
||||
// errors into these so this package stays decoupled from the approval stack.
|
||||
var (
|
||||
// ErrApprovalConflict → 409: gate refused, fleet mode, duplicate, or an
|
||||
// approval already in flight. The wrapped message carries the specifics.
|
||||
ErrApprovalConflict = errors.New("localapi: approval conflict")
|
||||
// ErrApprovalUnavailable → 503: no local authority on this host.
|
||||
ErrApprovalUnavailable = errors.New("localapi: approval unavailable")
|
||||
)
|
||||
|
||||
// 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
|
||||
SystemProvider func() (*system.SystemInfo, error)
|
||||
ProcessProvider func(limit int) ([]system.TopProcess, error)
|
||||
MonitorProvider func() (*system.ResourceSnapshot, error)
|
||||
ProcessesProvider func() (*system.FullProcessSnapshot, error)
|
||||
ProcessDetailProvider func(pid int, caps system.ProcessCaps) (*system.FullProcess, error)
|
||||
SoftwareProvider func() (*system.SoftwareSnapshot, error)
|
||||
PackageDetailProvider func(packageType, identity string) (*system.PackageDetail, error)
|
||||
ConnectionsProvider func() (*system.ConnectionSnapshot, error)
|
||||
ServicesProvider func() (*system.ServiceSnapshot, error)
|
||||
DockerProvider func() (*DockerResponse, error)
|
||||
EventsProvider func() ([]*models.SystemEvent, error)
|
||||
ScanRunning func() bool
|
||||
// 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
|
||||
// ApproveUpdate runs the standalone approval flow (FEAT-003): gates →
|
||||
// mint → execute. Synchronous; the response carries the full verdict.
|
||||
// Nil disables the endpoint (503). Wrap errors in ErrApprovalConflict /
|
||||
// ErrApprovalUnavailable to control the HTTP status.
|
||||
ApproveUpdate 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.
|
||||
OnDesktopHealth func(version string, windowOpen bool)
|
||||
}
|
||||
|
||||
// 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{})
|
||||
monitor *system.ResourceMonitor
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var monitor *system.ResourceMonitor
|
||||
if opts.MonitorProvider == nil {
|
||||
monitor = system.NewResourceMonitor(time.Second, 300)
|
||||
monitor.Start()
|
||||
opts.MonitorProvider = monitor.Snapshot
|
||||
}
|
||||
handler := newHandler(opts)
|
||||
httpServer := &http.Server{Handler: handler}
|
||||
|
||||
listener := opts.ListenerOverride
|
||||
address := ""
|
||||
var err error
|
||||
if listener == nil {
|
||||
listener, address, err = listen(opts)
|
||||
if err != nil {
|
||||
if monitor != nil {
|
||||
monitor.Stop()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
address = listener.Addr().String()
|
||||
}
|
||||
|
||||
srv := &Server{
|
||||
httpServer: httpServer,
|
||||
listener: listener,
|
||||
address: address,
|
||||
logf: opts.RequestLog,
|
||||
monitor: monitor,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if s.monitor != nil {
|
||||
s.monitor.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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ScanRunning bool `json:"scan_running"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type SystemResponse struct {
|
||||
System *system.SystemInfo `json:"system"`
|
||||
TopProcesses []system.TopProcess `json:"top_processes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type DockerResponse struct {
|
||||
Available bool `json:"available"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Containers []client.DockerReportContainer `json:"containers"`
|
||||
Stacks []client.DockerReportStack `json:"stacks"`
|
||||
Count int `json:"count"`
|
||||
Running int `json:"running"`
|
||||
Unhealthy int `json:"unhealthy"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type EventsResponse struct {
|
||||
Events []*models.SystemEvent `json:"events"`
|
||||
Count int `json:"count"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
type SecurityResponse struct {
|
||||
CommandSigningEnabled bool `json:"command_signing_enabled"`
|
||||
CommandEnforcement string `json:"command_enforcement"`
|
||||
TLSVerification bool `json:"tls_verification"`
|
||||
SecurityLogging bool `json:"security_logging"`
|
||||
KernelEnforcement bool `json:"kernel_enforcement"`
|
||||
KernelFailClosed bool `json:"kernel_fail_closed"`
|
||||
DegradedMode bool `json:"degraded_mode"`
|
||||
Registered bool `json:"registered"`
|
||||
CriticalUpdates int `json:"critical_updates"`
|
||||
HighUpdates int `json:"high_updates"`
|
||||
Capabilities cache.CapabilityTokenState `json:"capabilities"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
if h.systemInfo == nil {
|
||||
h.systemInfo = func() (*system.SystemInfo, error) {
|
||||
return system.GetSystemInfo(version.Version)
|
||||
}
|
||||
}
|
||||
if h.topProcesses == nil {
|
||||
h.topProcesses = system.GetTopProcesses
|
||||
}
|
||||
if h.processes == nil {
|
||||
h.processes = system.GetFullProcessSnapshot
|
||||
}
|
||||
if h.processDetail == nil {
|
||||
h.processDetail = system.GetProcessDetail
|
||||
}
|
||||
if h.software == nil {
|
||||
h.software = system.GetSoftwareSnapshot
|
||||
}
|
||||
if h.packageDetail == nil {
|
||||
h.packageDetail = system.GetPackageDetail
|
||||
}
|
||||
if h.connections == nil {
|
||||
h.connections = system.GetConnectionsSnapshot
|
||||
}
|
||||
if h.services == nil {
|
||||
h.services = system.GetServicesSnapshot
|
||||
}
|
||||
if h.loadCache == nil {
|
||||
h.loadCache = cache.Load
|
||||
}
|
||||
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/system", h.system)
|
||||
mux.HandleFunc("/v1/monitor", h.monitor)
|
||||
mux.HandleFunc("/v1/processes", h.processList)
|
||||
mux.HandleFunc("/v1/processes/", h.processByPID)
|
||||
mux.HandleFunc("/v1/software", h.softwareList)
|
||||
mux.HandleFunc("/v1/software/detail", h.softwareDetail)
|
||||
mux.HandleFunc("/v1/connections", h.connectionList)
|
||||
mux.HandleFunc("/v1/services", h.serviceList)
|
||||
mux.HandleFunc("/v1/containers", h.containerList)
|
||||
mux.HandleFunc("/v1/events", h.eventList)
|
||||
mux.HandleFunc("/v1/security", h.security)
|
||||
mux.HandleFunc("/v1/tokens/active", h.tokensActive)
|
||||
mux.HandleFunc("/v1/desktop", h.desktopHealth)
|
||||
mux.HandleFunc("/v1/actions/trigger-scan", h.triggerScanAction)
|
||||
mux.HandleFunc("/v1/actions/approve-update", h.approveUpdateAction)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (h *handler) containerList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.docker == nil {
|
||||
writeJSON(w, DockerResponse{Available: false, CollectedAt: time.Now().UTC()})
|
||||
return
|
||||
}
|
||||
snapshot, err := h.docker()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] container_inventory_failed error=%v", err)
|
||||
http.Error(w, "container inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) eventList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.events == nil {
|
||||
writeJSON(w, EventsResponse{CollectedAt: time.Now().UTC()})
|
||||
return
|
||||
}
|
||||
events, err := h.events()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] event_history_failed error=%v", err)
|
||||
http.Error(w, "local event history unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
sort.Slice(events, func(i, j int) bool { return events[i].CreatedAt.After(events[j].CreatedAt) })
|
||||
writeJSON(w, EventsResponse{Events: events, Count: len(events), CollectedAt: time.Now().UTC()})
|
||||
}
|
||||
|
||||
func (h *handler) security(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
localCache, ok := h.load(w)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
response := SecurityResponse{
|
||||
CommandSigningEnabled: h.cfg.CommandSigning.Enabled,
|
||||
CommandEnforcement: h.cfg.CommandSigning.EnforcementMode,
|
||||
TLSVerification: !h.cfg.TLS.InsecureSkipVerify,
|
||||
SecurityLogging: h.cfg.SecurityLogging.Enabled,
|
||||
KernelEnforcement: h.cfg.KernelEnforcement.Enabled,
|
||||
KernelFailClosed: h.cfg.KernelEnforcement.FailClosed,
|
||||
DegradedMode: h.cfg.DegradedMode,
|
||||
Registered: h.cfg.IsRegistered(),
|
||||
CriticalUpdates: localCache.Summary.BySeverity["critical"],
|
||||
HighUpdates: localCache.Summary.BySeverity["high"] + localCache.Summary.BySeverity["important"],
|
||||
Capabilities: localCache.Capabilities,
|
||||
CollectedAt: time.Now().UTC(),
|
||||
}
|
||||
writeJSON(w, response)
|
||||
}
|
||||
|
||||
func (h *handler) connectionList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.connections()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] connection_inventory_failed error=%v", err)
|
||||
http.Error(w, "connection inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) serviceList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.services()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] service_inventory_failed error=%v", err)
|
||||
http.Error(w, "service inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) monitor(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
if h.monitorSnapshot == nil {
|
||||
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
snapshot, err := h.monitorSnapshot()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] resource_monitor_failed error=%v", err)
|
||||
http.Error(w, "resource monitor unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) processList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.processes()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] process_inventory_failed error=%v", err)
|
||||
http.Error(w, "process inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) processByPID(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
rawPID := strings.TrimPrefix(r.URL.Path, "/v1/processes/")
|
||||
pid, err := strconv.Atoi(rawPID)
|
||||
if err != nil || pid <= 0 || strings.Contains(rawPID, "/") {
|
||||
http.Error(w, "invalid process id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
caps := processCaps(h.cfg.ProcessExplorer)
|
||||
process, err := h.processDetail(pid, caps)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [agent] [localapi] process_detail_unavailable pid=%d error=%v", pid, err)
|
||||
http.Error(w, "process unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, process)
|
||||
}
|
||||
|
||||
func (h *handler) softwareList(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
snapshot, err := h.software()
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] software_inventory_failed error=%v", err)
|
||||
http.Error(w, "software inventory unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
writeJSON(w, snapshot)
|
||||
}
|
||||
|
||||
func (h *handler) softwareDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
packageType := strings.TrimSpace(r.URL.Query().Get("manager"))
|
||||
identity := strings.TrimSpace(r.URL.Query().Get("identity"))
|
||||
if packageType == "" || identity == "" {
|
||||
http.Error(w, "manager and identity are required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
detail, err := h.packageDetail(packageType, identity)
|
||||
if err != nil {
|
||||
log.Printf("[INFO] [agent] [localapi] software_detail_unavailable manager=%s identity=%s error=%v", packageType, identity, err)
|
||||
http.Error(w, "software detail unavailable", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
writeJSON(w, detail)
|
||||
}
|
||||
|
||||
func processCaps(cfg config.ProcessExplorerConfig) system.ProcessCaps {
|
||||
capOr := func(value, fallback int) int {
|
||||
if value > 0 {
|
||||
return value
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
return system.ProcessCaps{
|
||||
MaxOpenFiles: capOr(cfg.MaxOpenFiles, 2000),
|
||||
MaxSockets: capOr(cfg.MaxSockets, 500),
|
||||
MaxPipes: capOr(cfg.MaxPipes, 500),
|
||||
MaxMemoryMap: capOr(cfg.MaxMemoryMap, 2000),
|
||||
MaxNamespaces: capOr(cfg.MaxNamespaces, 50),
|
||||
MaxEnvKeys: capOr(cfg.MaxEnvKeys, 200),
|
||||
MaxListeningPorts: capOr(cfg.MaxListeningPorts, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *handler) system(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireGet(w, r) {
|
||||
return
|
||||
}
|
||||
info, err := h.systemInfo()
|
||||
if err != nil {
|
||||
log.Printf("[ERROR] [agent] [localapi] system_info_failed error=%v", err)
|
||||
http.Error(w, "system health unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
processes, err := h.topProcesses(8)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] top_processes_failed error=%v", err)
|
||||
processes = nil
|
||||
}
|
||||
writeJSON(w, SystemResponse{
|
||||
System: info,
|
||||
TopProcesses: processes,
|
||||
CollectedAt: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
|
||||
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{
|
||||
ScanRunning: h.scanRunning != nil && h.scanRunning(),
|
||||
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)
|
||||
|
||||
if h.onDesktopHealth != nil {
|
||||
h.onDesktopHealth(req.Version, 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})
|
||||
}
|
||||
|
||||
// approveUpdateAction handles POST /v1/actions/approve-update — the standalone
|
||||
// approval flow (FEAT-003). Authorization is the OS-local group ACL on the
|
||||
// socket/pipe; the real judgment lives in the gates and the root-owned mint
|
||||
// key. Synchronous: the connection is held until verdict + install complete.
|
||||
func (h *handler) approveUpdateAction(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.approveUpdate == nil {
|
||||
log.Printf("[WARNING] [agent] [localapi] approve_unavailable")
|
||||
http.Error(w, "local approval 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.approveUpdate(body)
|
||||
switch {
|
||||
case err == nil:
|
||||
log.Printf("[INFO] [agent] [localapi] approve_completed")
|
||||
writeJSON(w, result)
|
||||
case errors.Is(err, ErrApprovalConflict):
|
||||
log.Printf("[SECURITY] [agent] [localapi] approve_conflict error=%v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
writeJSONBody(w, map[string]interface{}{"error": err.Error()})
|
||||
case errors.Is(err, ErrApprovalUnavailable):
|
||||
log.Printf("[WARNING] [agent] [localapi] approve_unavailable error=%v", err)
|
||||
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] approve_failed error=%v", err)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
334
agent/internal/localapi/server_test.go
Normal file
334
agent/internal/localapi/server_test.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"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/system"
|
||||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
func TestIdentityRedactsSecrets(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
handler := newHandler(Options{Config: cfg, LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/identity", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
body := rec.Body.String()
|
||||
for _, forbidden := range []string{"secret-access-token", "secret-refresh-token", "registration-token", "refresh_token", "token"} {
|
||||
if strings.Contains(body, forbidden) {
|
||||
t.Fatalf("identity response leaked %q: %s", forbidden, body)
|
||||
}
|
||||
}
|
||||
|
||||
var resp IdentityResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.AgentID != cfg.AgentID.String() {
|
||||
t.Fatalf("agent_id = %q, want %q", resp.AgentID, cfg.AgentID.String())
|
||||
}
|
||||
if resp.Registered != true {
|
||||
t.Fatalf("registered = false, want true")
|
||||
}
|
||||
if got := strings.Join(resp.Tags, ","); got != "alpha,zeta" {
|
||||
t.Fatalf("tags = %q, want sorted alpha,zeta", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusAndPackagesReadLocalCache(t *testing.T) {
|
||||
now := time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC)
|
||||
localCache := &cache.LocalCache{
|
||||
LastScanTime: now.Add(-2 * time.Minute),
|
||||
LastCheckIn: now.Add(-1 * time.Minute),
|
||||
LastUpdated: now,
|
||||
UpdateCount: 1,
|
||||
AgentStatus: "online",
|
||||
Summary: cache.UpdateSummary{
|
||||
Total: 1,
|
||||
ByEcosystem: map[string]int{"windows": 1},
|
||||
BySeverity: map[string]int{"critical": 1},
|
||||
},
|
||||
Scanners: map[string]cache.ScannerState{
|
||||
"windows": {
|
||||
Name: "windows",
|
||||
Status: "success",
|
||||
UpdateCount: 1,
|
||||
},
|
||||
},
|
||||
Updates: []client.UpdateReportItem{
|
||||
{
|
||||
PackageType: "windows",
|
||||
PackageName: "KB5000001",
|
||||
CurrentVersion: "1",
|
||||
AvailableVersion: "2",
|
||||
Severity: "critical",
|
||||
},
|
||||
},
|
||||
Capabilities: cache.CapabilityTokenState{
|
||||
PendingCount: 2,
|
||||
LastFetchedCount: 3,
|
||||
LastProcessedCount: 1,
|
||||
},
|
||||
}
|
||||
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return localCache, nil
|
||||
}})
|
||||
|
||||
var status StatusResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/status", &status)
|
||||
if status.AgentStatus != "online" {
|
||||
t.Fatalf("agent_status = %q, want online", status.AgentStatus)
|
||||
}
|
||||
if status.Summary.Total != 1 {
|
||||
t.Fatalf("summary.total = %d, want 1", status.Summary.Total)
|
||||
}
|
||||
|
||||
var packages ScanResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/packages", &packages)
|
||||
if len(packages.Updates) != 1 {
|
||||
t.Fatalf("updates len = %d, want 1", len(packages.Updates))
|
||||
}
|
||||
if packages.Updates[0].PackageName != "KB5000001" {
|
||||
t.Fatalf("package name = %q, want KB5000001", packages.Updates[0].PackageName)
|
||||
}
|
||||
|
||||
var tokens TokenResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/tokens/active", &tokens)
|
||||
if tokens.Capabilities.PendingCount != 2 {
|
||||
t.Fatalf("pending tokens = %d, want 2", tokens.Capabilities.PendingCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemHealthUsesAgentCollectors(t *testing.T) {
|
||||
handler := newHandler(Options{
|
||||
Config: testConfig(t),
|
||||
LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
},
|
||||
SystemProvider: func() (*system.SystemInfo, error) {
|
||||
return &system.SystemInfo{
|
||||
Hostname: "workstation-01",
|
||||
OSVersion: "Arch Linux",
|
||||
OSArchitecture: "amd64",
|
||||
RunningProcesses: 173,
|
||||
Uptime: "4 days",
|
||||
MemoryInfo: system.MemoryInfo{
|
||||
Total: 32 << 30,
|
||||
Used: 12 << 30,
|
||||
Available: 20 << 30,
|
||||
UsedPercent: 37.5,
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
ProcessProvider: func(limit int) ([]system.TopProcess, error) {
|
||||
if limit != 8 {
|
||||
t.Fatalf("process limit = %d, want 8", limit)
|
||||
}
|
||||
return []system.TopProcess{{Name: "redflag-agent", PID: 42, CPU: 1.5, Mem: 0.8}}, nil
|
||||
},
|
||||
})
|
||||
|
||||
var response SystemResponse
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/system", &response)
|
||||
if response.System.Hostname != "workstation-01" {
|
||||
t.Fatalf("hostname = %q, want workstation-01", response.System.Hostname)
|
||||
}
|
||||
if response.System.MemoryInfo.UsedPercent != 37.5 {
|
||||
t.Fatalf("memory used = %.1f, want 37.5", response.System.MemoryInfo.UsedPercent)
|
||||
}
|
||||
if len(response.TopProcesses) != 1 || response.TopProcesses[0].Name != "redflag-agent" {
|
||||
t.Fatalf("top processes = %#v", response.TopProcesses)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMonitorAndProcessInventoryUseAgentCollectors(t *testing.T) {
|
||||
now := time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC)
|
||||
handler := newHandler(Options{
|
||||
Config: testConfig(t),
|
||||
LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
},
|
||||
MonitorProvider: func() (*system.ResourceSnapshot, error) {
|
||||
return &system.ResourceSnapshot{
|
||||
CollectedAt: now,
|
||||
CPU: system.CPUMetrics{UsagePercent: 27.5, Load1: 1.2},
|
||||
Memory: system.MemoryMetrics{UsedPercent: 42},
|
||||
History: []system.ResourcePoint{{Timestamp: now, CPUPercent: 27.5}},
|
||||
}, nil
|
||||
},
|
||||
ProcessesProvider: func() (*system.FullProcessSnapshot, error) {
|
||||
return &system.FullProcessSnapshot{
|
||||
Processes: []system.FullProcess{{PID: 42, Name: "redflag-agent", User: "root"}},
|
||||
ProcessCount: 1,
|
||||
ScannedAt: now,
|
||||
}, nil
|
||||
},
|
||||
ProcessDetailProvider: func(pid int, caps system.ProcessCaps) (*system.FullProcess, error) {
|
||||
if pid != 42 {
|
||||
t.Fatalf("pid = %d, want 42", pid)
|
||||
}
|
||||
if caps.MaxSockets != 500 || caps.MaxOpenFiles != 2000 {
|
||||
t.Fatalf("default process caps = %#v", caps)
|
||||
}
|
||||
return &system.FullProcess{PID: pid, Name: "redflag-agent", Path: "/usr/bin/redflag-agent"}, nil
|
||||
},
|
||||
SoftwareProvider: func() (*system.SoftwareSnapshot, error) {
|
||||
return &system.SoftwareSnapshot{
|
||||
Supported: true,
|
||||
Packages: []system.InstalledPackage{{PackageType: "pacman", Identity: "redflag-agent", Name: "redflag-agent", Version: "0.3.0", InstallReason: "explicit", Origin: "repository"}},
|
||||
Count: 1,
|
||||
}, nil
|
||||
},
|
||||
PackageDetailProvider: func(packageType, identity string) (*system.PackageDetail, error) {
|
||||
if packageType != "pacman" || identity != "redflag-agent" {
|
||||
t.Fatalf("package detail request = %s/%s", packageType, identity)
|
||||
}
|
||||
return &system.PackageDetail{InstalledPackage: system.InstalledPackage{PackageType: packageType, Identity: identity, Name: identity, Version: "0.3.0"}, DependsOn: []string{"glibc"}}, nil
|
||||
},
|
||||
ConnectionsProvider: func() (*system.ConnectionSnapshot, error) {
|
||||
return &system.ConnectionSnapshot{Connections: []system.Connection{{PID: 42, Process: "redflag-agent", Protocol: "TCP", LocalPort: 443, State: "LISTEN"}}, Count: 1, CollectedAt: now}, nil
|
||||
},
|
||||
ServicesProvider: func() (*system.ServiceSnapshot, error) {
|
||||
return &system.ServiceSnapshot{Manager: "systemd", Services: []system.Service{{Name: "redflag-agent", ActiveState: "active", SubState: "running"}}, Count: 1, Running: 1, CollectedAt: now}, nil
|
||||
},
|
||||
})
|
||||
|
||||
var monitor system.ResourceSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/monitor", &monitor)
|
||||
if monitor.CPU.UsagePercent != 27.5 || len(monitor.History) != 1 {
|
||||
t.Fatalf("monitor = %#v", monitor)
|
||||
}
|
||||
|
||||
var processes system.FullProcessSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/processes", &processes)
|
||||
if processes.ProcessCount != 1 || processes.Processes[0].Name != "redflag-agent" {
|
||||
t.Fatalf("processes = %#v", processes)
|
||||
}
|
||||
|
||||
var process system.FullProcess
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/processes/42", &process)
|
||||
if process.Path != "/usr/bin/redflag-agent" {
|
||||
t.Fatalf("process detail = %#v", process)
|
||||
}
|
||||
|
||||
var software system.SoftwareSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/software", &software)
|
||||
if software.Count != 1 || software.Packages[0].Version != "0.3.0" {
|
||||
t.Fatalf("software = %#v", software)
|
||||
}
|
||||
|
||||
var packageDetail system.PackageDetail
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/software/detail?manager=pacman&identity=redflag-agent", &packageDetail)
|
||||
if packageDetail.Name != "redflag-agent" || len(packageDetail.DependsOn) != 1 {
|
||||
t.Fatalf("package detail = %#v", packageDetail)
|
||||
}
|
||||
|
||||
var connections system.ConnectionSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/connections", &connections)
|
||||
if connections.Count != 1 || connections.Connections[0].State != "LISTEN" {
|
||||
t.Fatalf("connections = %#v", connections)
|
||||
}
|
||||
|
||||
var services system.ServiceSnapshot
|
||||
requestJSON(t, handler, http.MethodGet, "/v1/services", &services)
|
||||
if services.Running != 1 || services.Services[0].Name != "redflag-agent" {
|
||||
t.Fatalf("services = %#v", services)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailRejectsInvalidPID(t *testing.T) {
|
||||
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}})
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/processes/not-a-pid", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCacheLoadFailureReturnsServiceUnavailable(t *testing.T) {
|
||||
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return nil, errors.New("cannot read cache")
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/v1/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOnlyGetMethodsAllowed(t *testing.T) {
|
||||
handler := newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/status", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
if rec.Header().Get("Allow") != http.MethodGet {
|
||||
t.Fatalf("Allow = %q, want GET", rec.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
|
||||
func requestJSON(t *testing.T, handler http.Handler, method, path string, target interface{}) {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s %s status = %d, want %d; body=%s", method, path, rec.Code, http.StatusOK, rec.Body.String())
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), target); err != nil {
|
||||
t.Fatalf("decode %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testConfig(t *testing.T) *config.Config {
|
||||
t.Helper()
|
||||
|
||||
agentID, err := uuid.NewV4()
|
||||
if err != nil {
|
||||
t.Fatalf("new uuid: %v", err)
|
||||
}
|
||||
return &config.Config{
|
||||
Version: "5",
|
||||
ServerURL: "https://redflag.example",
|
||||
RegistrationToken: "registration-token",
|
||||
AgentID: agentID,
|
||||
Token: "secret-access-token",
|
||||
RefreshToken: "secret-refresh-token",
|
||||
CheckInInterval: 300,
|
||||
Tags: []string{"zeta", "alpha"},
|
||||
DisplayName: "workstation-01",
|
||||
Organization: "lab",
|
||||
OSType: "windows",
|
||||
}
|
||||
}
|
||||
88
agent/internal/localapi/trigger_test.go
Normal file
88
agent/internal/localapi/trigger_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package localapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Fimeg/RedFlag/agent/internal/cache"
|
||||
)
|
||||
|
||||
func triggerHandler(t *testing.T, trigger func(source string) error) http.Handler {
|
||||
t.Helper()
|
||||
return newHandler(Options{Config: testConfig(t), LoadCache: func() (*cache.LocalCache, error) {
|
||||
return &cache.LocalCache{}, nil
|
||||
}, TriggerScan: trigger})
|
||||
}
|
||||
|
||||
func postTrigger(t *testing.T, handler http.Handler) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/v1/actions/trigger-scan", nil))
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestTriggerScanAccepted(t *testing.T) {
|
||||
var gotSource string
|
||||
handler := triggerHandler(t, func(source string) error {
|
||||
gotSource = source
|
||||
return nil
|
||||
})
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusAccepted {
|
||||
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusAccepted, rec.Body.String())
|
||||
}
|
||||
if gotSource != "localapi" {
|
||||
t.Fatalf("source = %q, want localapi", gotSource)
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp["accepted"] != true {
|
||||
t.Fatalf("accepted = %v, want true", resp["accepted"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanInFlightIsConflict(t *testing.T) {
|
||||
handler := triggerHandler(t, func(string) error { return ErrScanInFlight })
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanUnavailableWithoutCallback(t *testing.T) {
|
||||
handler := triggerHandler(t, nil)
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusServiceUnavailable)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanFailureIs500(t *testing.T) {
|
||||
handler := triggerHandler(t, func(string) error { return errors.New("boom") })
|
||||
|
||||
rec := postTrigger(t, handler)
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerScanRejectsGet(t *testing.T) {
|
||||
handler := triggerHandler(t, func(string) error { return nil })
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/actions/trigger-scan", nil))
|
||||
if rec.Code != http.StatusMethodNotAllowed {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusMethodNotAllowed)
|
||||
}
|
||||
if rec.Header().Get("Allow") != http.MethodPost {
|
||||
t.Fatalf("Allow = %q, want POST", rec.Header().Get("Allow"))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue