Watch
1
0
Fork
You've already forked RedFlag
0

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:
Fimeg 2026-07-06 18:21:23 -04:00
commit ff2f30f47a
58 changed files with 2989 additions and 429 deletions

View file

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

View file

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

View file

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

View file

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

View 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")
}
}

View file

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

View file

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

View file

@ -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 := `

View file

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

View file

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

View file

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