v0.2.9.3: device classification + ARM support — Pixel 3 lands
DEVICE-002: ARM machine-ID fallback — device-tree model + /etc/machine-id combo, then /proc/cpuinfo Serial (all-zero rejected), before the weak hostname fallback. Hardware-bound IDs on DMI-less devices. DEVICE-001: agent detects device_type (server/desktop/phone/tablet) from /sys signals — system battery (scope=Device peripherals excluded, UPS excluded), DRM connector state, framebuffer min-dimension for phone/tablet split. Reports device_type/device_model/os_distro in registration and system-info paths. SERVER-001: migration 061 — device_type, device_type_manual (operator override, never agent-written), device_model, os_distro on agents. effective_device_type computed into every serialized agent. SERVER-002: PUT /admin/agents/:id/device-type — set/clear override, enum-validated, journaled. WEB-001: device-type icons + fleet filter, device model in list, detail header badge with reclassify dropdown, os_distro surfaced. INSTALL-003: arm64 install path unblocked — helper (required manifest component) now cross-built aarch64-unknown-linux-musl via rust-lld in the server image, signed at boot (helperArches += arm64), listed in the release manifest. Install template already handled uname -m and pacman. Plus in-flight: desktop tray wiring, enrollment page polish, CI workflow updates, RAF session-broker/pacman-scanner docs, native installer scaffold.
This commit is contained in:
parent
b3d946b84f
commit
ff2f30f47a
58 changed files with 2989 additions and 429 deletions
|
|
@ -336,6 +336,21 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
|||
pubKeyFP = &req.PublicKeyFingerprint
|
||||
}
|
||||
|
||||
// Device classification (DEVICE-001). Unrecognized values degrade to the
|
||||
// conservative default rather than failing registration on the CHECK constraint.
|
||||
deviceType := req.DeviceType
|
||||
if !models.ValidDeviceType(deviceType) {
|
||||
deviceType = "server"
|
||||
}
|
||||
var deviceModel *string
|
||||
if req.DeviceModel != "" {
|
||||
deviceModel = &req.DeviceModel
|
||||
}
|
||||
var osDistro *string
|
||||
if req.OSDistro != "" {
|
||||
osDistro = &req.OSDistro
|
||||
}
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: uuid.Must(uuid.NewV4()),
|
||||
Hostname: req.Hostname,
|
||||
|
|
@ -346,6 +361,9 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
|||
CurrentVersion: req.AgentVersion,
|
||||
MachineID: machineID,
|
||||
PublicKeyFingerprint: pubKeyFP,
|
||||
DeviceType: deviceType,
|
||||
DeviceModel: deviceModel,
|
||||
OSDistro: osDistro,
|
||||
LastSeen: time.Now().UTC(),
|
||||
Status: "online",
|
||||
Metadata: models.JSONB{},
|
||||
|
|
@ -373,10 +391,12 @@ func (h *AgentHandler) RegisterAgent(c *gin.Context) {
|
|||
INSERT INTO agents (
|
||||
id, hostname, os_type, os_version, os_architecture,
|
||||
agent_version, current_version, machine_id, public_key_fingerprint,
|
||||
device_type, device_model, os_distro,
|
||||
last_seen, status, metadata
|
||||
) VALUES (
|
||||
:id, :hostname, :os_type, :os_version, :os_architecture,
|
||||
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
||||
:device_type, :device_model, :os_distro,
|
||||
:last_seen, :status, :metadata
|
||||
)`
|
||||
if _, err := tx.NamedExec(createQuery, agent); err != nil {
|
||||
|
|
@ -1513,6 +1533,60 @@ func (h *AgentHandler) RebindMachineID(c *gin.Context) {
|
|||
})
|
||||
}
|
||||
|
||||
// ReclassifyDeviceType sets or clears the operator override for an agent's
|
||||
// device type (SERVER-002). Auto-detection is heuristic; the operator has
|
||||
// final say. null/empty device_type clears the override.
|
||||
func (h *AgentHandler) ReclassifyDeviceType(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
agentID, err := uuid.FromString(idStr)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent ID"})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
DeviceType *string `json:"device_type"` // null clears the override
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var override *string
|
||||
if req.DeviceType != nil && *req.DeviceType != "" {
|
||||
if !models.ValidDeviceType(*req.DeviceType) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "device_type must be one of: server, desktop, phone, tablet (or null to clear)"})
|
||||
return
|
||||
}
|
||||
override = req.DeviceType
|
||||
}
|
||||
|
||||
agent, err := h.agentQueries.GetAgentByID(agentID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "agent not found"})
|
||||
return
|
||||
}
|
||||
oldEffective := agent.EffectiveDeviceType()
|
||||
|
||||
if err := h.agentQueries.UpdateDeviceTypeManual(agentID, override); err != nil {
|
||||
log.Printf("[ERROR] [server] [admin] device_reclassify_failed agent_id=%s error=%q", agentID, err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to update device type"})
|
||||
return
|
||||
}
|
||||
|
||||
agent.DeviceTypeManual = override
|
||||
adminUserID := c.GetString("user_id")
|
||||
log.Printf("[INFO] [server] [admin] device_reclassified agent_id=%s from=%s to=%s admin_user=%s",
|
||||
agentID, oldEffective, agent.EffectiveDeviceType(), adminUserID)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": agent.ID,
|
||||
"device_type": agent.DeviceType,
|
||||
"device_type_manual": agent.DeviceTypeManual,
|
||||
"effective_device_type": agent.EffectiveDeviceType(),
|
||||
})
|
||||
}
|
||||
|
||||
// UnregisterAgent removes an agent from the system
|
||||
func (h *AgentHandler) UnregisterAgent(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
|
|
@ -1557,6 +1631,9 @@ func (h *AgentHandler) ReportSystemInfo(c *gin.Context) {
|
|||
IPAddress string `json:"ip_address,omitempty"`
|
||||
Processes int `json:"processes,omitempty"`
|
||||
Uptime string `json:"uptime,omitempty"`
|
||||
DeviceType string `json:"device_type,omitempty"`
|
||||
DeviceModel string `json:"device_model,omitempty"`
|
||||
OSDistro string `json:"os_distro,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -1606,6 +1683,18 @@ func (h *AgentHandler) ReportSystemInfo(c *gin.Context) {
|
|||
agent.Metadata["uptime"] = req.Uptime
|
||||
}
|
||||
|
||||
// Device classification (DEVICE-001): dedicated columns, not metadata.
|
||||
// device_type_manual is operator-owned and never touched by agent reports.
|
||||
if models.ValidDeviceType(req.DeviceType) {
|
||||
agent.DeviceType = req.DeviceType
|
||||
}
|
||||
if req.DeviceModel != "" {
|
||||
agent.DeviceModel = &req.DeviceModel
|
||||
}
|
||||
if req.OSDistro != "" {
|
||||
agent.OSDistro = &req.OSDistro
|
||||
}
|
||||
|
||||
// Store the timestamp when system info was last updated
|
||||
agent.Metadata["system_info_updated_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
|
|
|
|||
|
|
@ -481,6 +481,7 @@ func (h *DownloadHandler) buildReleaseManifest(version string) services.ReleaseM
|
|||
{"windows", "amd64"},
|
||||
{"windows", "arm64"},
|
||||
{"helper-linux", "amd64"},
|
||||
{"helper-linux", "arm64"},
|
||||
{"desktop-linux", "amd64"},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,19 @@ import (
|
|||
"github.com/gofrs/uuid/v5"
|
||||
)
|
||||
|
||||
// maxRegistrationTokenDuration is the hard ceiling on how long a registration
|
||||
// token (a bearer credential for enrolling new agents) may live. Raised from
|
||||
// the previous 168h (7d) cap 2026-06-30 — see
|
||||
// docs/tasks/UI-REGISTRATION-ENROLLMENT-UNIFY.md. 90 days matches the
|
||||
// existing precedent for a long-lived bound credential elsewhere in this
|
||||
// system (refresh tokens — RAF/security/03-refresh-tokens.md) — same
|
||||
// yardstick already in use, not a new risk category. True "never expires"
|
||||
// was deliberately not added here: expires_at is NOT NULL and load-bearing
|
||||
// in the active-token query (`expires_at > NOW()`), so making it optional is
|
||||
// a schema change, not a UX pass. Blast radius still bounded by max_seats
|
||||
// and by revocation being one click away in the UI.
|
||||
const maxRegistrationTokenDuration = 2160 * time.Hour // 90 days
|
||||
|
||||
type RegistrationTokenHandler struct {
|
||||
tokenQueries *queries.RegistrationTokenQueries
|
||||
agentQueries *queries.AgentQueries
|
||||
|
|
@ -78,8 +91,8 @@ func (h *RegistrationTokenHandler) GenerateRegistrationToken(c *gin.Context) {
|
|||
}
|
||||
|
||||
expiresAt := time.Now().Add(duration)
|
||||
if duration > 168*time.Hour { // Max 7 days
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Token expiration cannot exceed 7 days"})
|
||||
if duration > maxRegistrationTokenDuration {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "Token expiration cannot exceed 90 days"})
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -377,7 +390,7 @@ func (h *RegistrationTokenHandler) GetTokenStats(c *gin.Context) {
|
|||
},
|
||||
"security_limits": gin.H{
|
||||
"max_tokens_per_request": h.config.AgentRegistration.MaxTokens,
|
||||
"max_token_duration": "7 days",
|
||||
"max_token_duration": "90 days",
|
||||
"token_expiry_default": h.config.AgentRegistration.TokenExpiry,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
|
|
@ -191,8 +192,66 @@ func readSecretFile(secretName string) (string, error) {
|
|||
return strings.TrimSpace(string(data)), nil
|
||||
}
|
||||
|
||||
// loadEnvFile applies KEY=VALUE lines from a flat config file into the process
|
||||
// environment, without overriding anything already set — the same precedence
|
||||
// docker-compose's own `env_file:` directive gives the container. Native
|
||||
// (non-docker) installs have no compose layer to do this injection, so a
|
||||
// service-adjacent config file is the only way to hand the binary its
|
||||
// settings; this keeps that path additive and inert everywhere else.
|
||||
func loadEnvFile(path string) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimSpace(key)
|
||||
value = strings.Trim(strings.TrimSpace(value), `"'`)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if _, alreadySet := os.LookupEnv(key); !alreadySet {
|
||||
os.Setenv(key, value)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadNativeConfigFile looks for a config file for non-docker deployments.
|
||||
// REDFLAG_CONFIG_FILE takes an explicit path; otherwise it checks the
|
||||
// per-OS default install-config location and no-ops if nothing is there —
|
||||
// existing docker deployments (env vars already set by env_file:) never hit
|
||||
// either path, so this cannot change their behavior.
|
||||
func loadNativeConfigFile() {
|
||||
path := os.Getenv("REDFLAG_CONFIG_FILE")
|
||||
if path == "" {
|
||||
if runtime.GOOS == "windows" {
|
||||
path = filepath.Join(os.Getenv("ProgramData"), "RedFlag", "redflag.env")
|
||||
} else {
|
||||
path = "/etc/redflag/server.env"
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
return
|
||||
}
|
||||
if err := loadEnvFile(path); err != nil {
|
||||
fmt.Printf("[CONFIG] [WARN] found %s but failed to read it: %v\n", path, err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("[CONFIG] Loaded native config file: %s\n", path)
|
||||
}
|
||||
|
||||
// Load reads configuration from Docker secrets or environment variables
|
||||
func Load() (*Config, error) {
|
||||
loadNativeConfigFile()
|
||||
|
||||
// Check if we're in Docker secrets mode
|
||||
cfg := &Config{}
|
||||
if IsDockerSecretsMode() {
|
||||
|
|
|
|||
64
server/internal/config/config_test.go
Normal file
64
server/internal/config/config_test.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadEnvFile_DoesNotOverrideExistingEnv(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "redflag.env")
|
||||
content := "REDFLAG_TEST_A=from_file\nREDFLAG_TEST_B=from_file\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
|
||||
os.Unsetenv("REDFLAG_TEST_A")
|
||||
t.Setenv("REDFLAG_TEST_B", "already_set")
|
||||
|
||||
if err := loadEnvFile(path); err != nil {
|
||||
t.Fatalf("loadEnvFile: %v", err)
|
||||
}
|
||||
|
||||
if got := os.Getenv("REDFLAG_TEST_A"); got != "from_file" {
|
||||
t.Errorf("REDFLAG_TEST_A = %q, want %q (should be set from file)", got, "from_file")
|
||||
}
|
||||
if got := os.Getenv("REDFLAG_TEST_B"); got != "already_set" {
|
||||
t.Errorf("REDFLAG_TEST_B = %q, want %q (existing env var must win)", got, "already_set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvFile_SkipsCommentsAndBlankLines(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "redflag.env")
|
||||
content := "# a comment\n\nREDFLAG_TEST_C=\"quoted\"\n \nREDFLAG_TEST_D='single'\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
os.Unsetenv("REDFLAG_TEST_C")
|
||||
os.Unsetenv("REDFLAG_TEST_D")
|
||||
|
||||
if err := loadEnvFile(path); err != nil {
|
||||
t.Fatalf("loadEnvFile: %v", err)
|
||||
}
|
||||
|
||||
if got := os.Getenv("REDFLAG_TEST_C"); got != "quoted" {
|
||||
t.Errorf("REDFLAG_TEST_C = %q, want %q", got, "quoted")
|
||||
}
|
||||
if got := os.Getenv("REDFLAG_TEST_D"); got != "single" {
|
||||
t.Errorf("REDFLAG_TEST_D = %q, want %q", got, "single")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadNativeConfigFile_NoopWhenAbsent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("REDFLAG_CONFIG_FILE", filepath.Join(dir, "does-not-exist.env"))
|
||||
os.Unsetenv("REDFLAG_TEST_SHOULD_NOT_APPEAR")
|
||||
|
||||
loadNativeConfigFile() // must not panic or error when the file is missing
|
||||
|
||||
if _, ok := os.LookupEnv("REDFLAG_TEST_SHOULD_NOT_APPEAR"); ok {
|
||||
t.Error("expected no env var to be set when config file is absent")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
-- Migration 061 down: remove device classification columns.
|
||||
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS os_distro;
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS device_model;
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS device_type_manual;
|
||||
ALTER TABLE agents DROP COLUMN IF EXISTS device_type;
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
-- Migration 061: Device classification on agents (SERVER-001).
|
||||
-- device_type is the agent's auto-detected form factor (DEVICE-001);
|
||||
-- device_type_manual is the operator override (SERVER-002) — never written by
|
||||
-- agent reports. Effective type = COALESCE(device_type_manual, device_type).
|
||||
-- Existing agents default to 'server' (matches current behavior: every agent
|
||||
-- so far is a server/desktop-class box treated identically).
|
||||
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS device_type VARCHAR(20) NOT NULL DEFAULT 'server'
|
||||
CHECK (device_type IN ('server', 'desktop', 'phone', 'tablet'));
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS device_type_manual VARCHAR(20)
|
||||
CHECK (device_type_manual IN ('server', 'desktop', 'phone', 'tablet'));
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS device_model VARCHAR(255);
|
||||
ALTER TABLE agents ADD COLUMN IF NOT EXISTS os_distro VARCHAR(50);
|
||||
|
|
@ -31,10 +31,12 @@ func (q *AgentQueries) CreateAgent(agent *models.Agent) error {
|
|||
INSERT INTO agents (
|
||||
id, hostname, os_type, os_version, os_architecture,
|
||||
agent_version, current_version, machine_id, public_key_fingerprint,
|
||||
device_type, device_model, os_distro,
|
||||
last_seen, status, metadata
|
||||
) VALUES (
|
||||
:id, :hostname, :os_type, :os_version, :os_architecture,
|
||||
:agent_version, :current_version, :machine_id, :public_key_fingerprint,
|
||||
:device_type, :device_model, :os_distro,
|
||||
:last_seen, :status, :metadata
|
||||
)
|
||||
`
|
||||
|
|
@ -72,6 +74,9 @@ func (q *AgentQueries) UpdateAgent(agent *models.Agent) error {
|
|||
os_version = :os_version,
|
||||
os_architecture = :os_architecture,
|
||||
agent_version = :agent_version,
|
||||
device_type = :device_type,
|
||||
device_model = :device_model,
|
||||
os_distro = :os_distro,
|
||||
last_seen = :last_seen,
|
||||
status = :status,
|
||||
metadata = :metadata
|
||||
|
|
@ -122,6 +127,14 @@ func (q *AgentQueries) UpdateMachineID(agentID uuid.UUID, newMachineID string) e
|
|||
return err
|
||||
}
|
||||
|
||||
// UpdateDeviceTypeManual sets or clears the operator's device-type override
|
||||
// (SERVER-002). nil clears the override, reverting to the agent-detected type.
|
||||
func (q *AgentQueries) UpdateDeviceTypeManual(agentID uuid.UUID, deviceType *string) error {
|
||||
query := `UPDATE agents SET device_type_manual = $1 WHERE id = $2`
|
||||
_, err := q.db.Exec(query, deviceType, agentID)
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkOfflineAgents marks agents as offline if they haven't checked in recently
|
||||
func (q *AgentQueries) MarkOfflineAgents(threshold time.Duration) error {
|
||||
query := `
|
||||
|
|
|
|||
|
|
@ -31,10 +31,34 @@ type Agent struct {
|
|||
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
|
||||
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
|
||||
DockerVersion string `json:"docker_version" db:"docker_version"`
|
||||
DeviceType string `json:"device_type" db:"device_type"` // Agent-detected form factor (DEVICE-001)
|
||||
DeviceTypeManual *string `json:"device_type_manual,omitempty" db:"device_type_manual"` // Operator override, nil = use auto (SERVER-002)
|
||||
DeviceModel *string `json:"device_model,omitempty" db:"device_model"` // Hardware model string
|
||||
OSDistro *string `json:"os_distro,omitempty" db:"os_distro"` // Distro ID from /etc/os-release
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
}
|
||||
|
||||
// ValidDeviceType reports whether s is a recognized device form factor.
|
||||
// Mirrors the CHECK constraint from migration 061 — validate before writing
|
||||
// so a bad agent report degrades to the default instead of failing the row.
|
||||
func ValidDeviceType(s string) bool {
|
||||
switch s {
|
||||
case "server", "desktop", "phone", "tablet":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// EffectiveDeviceType returns the operator override when set, else the
|
||||
// agent-detected type. All display and policy decisions use this.
|
||||
func (a *Agent) EffectiveDeviceType() string {
|
||||
if a.DeviceTypeManual != nil && *a.DeviceTypeManual != "" {
|
||||
return *a.DeviceTypeManual
|
||||
}
|
||||
return a.DeviceType
|
||||
}
|
||||
|
||||
// AgentWithLastScan extends Agent with last scan information
|
||||
type AgentWithLastScan struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
|
|
@ -58,11 +82,42 @@ type AgentWithLastScan struct {
|
|||
LastRebootAt *time.Time `json:"last_reboot_at,omitempty" db:"last_reboot_at"`
|
||||
RebootReason *string `json:"reboot_reason,omitempty" db:"reboot_reason"`
|
||||
DockerVersion string `json:"docker_version" db:"docker_version"`
|
||||
DeviceType string `json:"device_type" db:"device_type"`
|
||||
DeviceTypeManual *string `json:"device_type_manual,omitempty" db:"device_type_manual"`
|
||||
DeviceModel *string `json:"device_model,omitempty" db:"device_model"`
|
||||
OSDistro *string `json:"os_distro,omitempty" db:"os_distro"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
LastScan *time.Time `json:"last_scan" db:"last_scan"`
|
||||
}
|
||||
|
||||
// EffectiveDeviceType mirrors Agent.EffectiveDeviceType.
|
||||
func (a *AgentWithLastScan) EffectiveDeviceType() string {
|
||||
if a.DeviceTypeManual != nil && *a.DeviceTypeManual != "" {
|
||||
return *a.DeviceTypeManual
|
||||
}
|
||||
return a.DeviceType
|
||||
}
|
||||
|
||||
// MarshalJSON adds the computed effective_device_type to every serialized
|
||||
// agent, so list and detail responses carry it without per-handler wiring.
|
||||
func (a Agent) MarshalJSON() ([]byte, error) {
|
||||
type alias Agent
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
EffectiveDeviceType string `json:"effective_device_type"`
|
||||
}{alias(a), a.EffectiveDeviceType()})
|
||||
}
|
||||
|
||||
// MarshalJSON mirrors Agent.MarshalJSON.
|
||||
func (a AgentWithLastScan) MarshalJSON() ([]byte, error) {
|
||||
type alias AgentWithLastScan
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
EffectiveDeviceType string `json:"effective_device_type"`
|
||||
}{alias(a), a.EffectiveDeviceType()})
|
||||
}
|
||||
|
||||
// AgentSpecs represents system specifications for an agent
|
||||
type AgentSpecs struct {
|
||||
ID uuid.UUID `json:"id" db:"id"`
|
||||
|
|
@ -91,6 +146,9 @@ type AgentRegistrationRequest struct {
|
|||
PublicKeyFingerprint string `json:"public_key_fingerprint"` // Embedded public key fingerprint
|
||||
Metadata map[string]string `json:"metadata"`
|
||||
AvailableScanners []string `json:"available_scanners"` // Platform-specific scanners (apt, dnf, winget, windows, docker)
|
||||
DeviceType string `json:"device_type"` // Auto-detected form factor (DEVICE-001)
|
||||
DeviceModel string `json:"device_model"` // Hardware model string
|
||||
OSDistro string `json:"os_distro"` // Distro ID from /etc/os-release
|
||||
}
|
||||
|
||||
// AgentRegistrationResponse is returned after successful registration
|
||||
|
|
|
|||
|
|
@ -59,11 +59,18 @@ type ReleaseManifest struct {
|
|||
// here has a built, version-self-reporting artifact.
|
||||
func ComponentCatalog() []ManifestComponent {
|
||||
return []ManifestComponent{
|
||||
{Name: "server", Kind: "docker", Required: true, VersionCmd: "--version"},
|
||||
{Name: "server", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||
{Name: "agent", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||
{Name: "helper", Kind: "binary", Required: true, VersionCmd: "--version"},
|
||||
{Name: "desktop", Kind: "binary", Required: false, VersionCmd: "--version",
|
||||
Provisioning: []string{"autostart_entry", "redflag-local_group", "desktop_user_membership"}},
|
||||
{Name: "web", Kind: "embedded", Required: true},
|
||||
// installer (INSTALL-002): release-gate presence check only, not a
|
||||
// post-install healthcheck target — it's a delivery mechanism, not a
|
||||
// running artifact (the thing it installs, "server", is already its
|
||||
// own entry above). No VersionCmd: an MSI can't self-report a
|
||||
// version by being executed. Windows-only today (RedFlagSetup.msi),
|
||||
// so Required: false until macOS/Linux installers exist too.
|
||||
{Name: "installer", Kind: "binary", Required: false},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import (
|
|||
// tag — the release gate enforces this. ldflags may override at build time;
|
||||
// the release pipeline injects the tag so binaries and source agree.
|
||||
var (
|
||||
AgentVersion = "0.2.9.1"
|
||||
ConfigVersion = "0.2.9.1"
|
||||
AgentVersion = "0.2.9.3"
|
||||
ConfigVersion = "0.2.9.3"
|
||||
MinAgentVersion = "0.1.22"
|
||||
)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue