From 13cd41d20208a6f67550072844505d1c3a801e0b Mon Sep 17 00:00:00 2001 From: mrhid6 Date: Tue, 4 Aug 2026 13:30:06 +0100 Subject: [PATCH] feat: validate server tags and add the model field --- server/internal/models/server.go | 41 ++++++++++++------------ server/internal/services/tags.go | 55 ++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 20 deletions(-) create mode 100644 server/internal/services/tags.go diff --git a/server/internal/models/server.go b/server/internal/models/server.go index 4fc2feb..239c39d 100644 --- a/server/internal/models/server.go +++ b/server/internal/models/server.go @@ -44,24 +44,25 @@ type Inventory struct { } type Server struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - InstanceID string `bson:"instance_id" json:"instance_id"` - ServerID string `bson:"server_id" json:"server_id"` - Hostname string `bson:"hostname" json:"hostname"` - IPAddress string `bson:"ip_address" json:"ip_address"` - OSInfo string `bson:"os_info" json:"os_info"` - OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"` - ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"` - SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"` - RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"` - PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"` - PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"` - AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"` - Status string `bson:"status" json:"status"` - AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"` - LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"` - AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"` - UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"` - Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"` - CreatedAt time.Time `bson:"created_at" json:"created_at"` + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + ServerID string `bson:"server_id" json:"server_id"` + Hostname string `bson:"hostname" json:"hostname"` + IPAddress string `bson:"ip_address" json:"ip_address"` + OSInfo string `bson:"os_info" json:"os_info"` + OSType string `bson:"os_type,omitempty" json:"os_type,omitempty"` + ConsoleProtocols []string `bson:"console_protocols,omitempty" json:"console_protocols,omitempty"` + SSHPort int `bson:"ssh_port,omitempty" json:"ssh_port,omitempty"` + RDPPort int `bson:"rdp_port,omitempty" json:"rdp_port,omitempty"` + PreRegToken string `bson:"pre_reg_token,omitempty" json:"pre_reg_token,omitempty"` + PreRegExpires *time.Time `bson:"pre_reg_expires,omitempty" json:"pre_reg_expires,omitempty"` + AgentTokenHash string `bson:"agent_token_hash,omitempty" json:"-"` + Status string `bson:"status" json:"status"` + AgentVersion string `bson:"agent_version,omitempty" json:"agent_version,omitempty"` + LastSeen *time.Time `bson:"last_seen,omitempty" json:"last_seen,omitempty"` + AvailableUpdates []PackageUpdate `bson:"available_updates,omitempty" json:"available_updates,omitempty"` + UpdatesCheckedAt *time.Time `bson:"updates_checked_at,omitempty" json:"updates_checked_at,omitempty"` + Inventory *Inventory `bson:"inventory,omitempty" json:"inventory,omitempty"` + Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } diff --git a/server/internal/services/tags.go b/server/internal/services/tags.go new file mode 100644 index 0000000..67f87b1 --- /dev/null +++ b/server/internal/services/tags.go @@ -0,0 +1,55 @@ +package services + +import ( + "errors" + "fmt" + "strings" +) + +// ErrInvalidTag is returned for any tag the rules below reject. Handlers map +// it to 400 — a malformed tag is the caller's mistake, not a server fault. +var ErrInvalidTag = errors.New("invalid tag") + +const ( + maxTagKeyLen = 32 + maxTagValueLen = 64 + maxTagsPerHost = 20 + // Reserved for tags the agent may derive from inventory later. Refusing + // it now means a user tag written today can never collide with a system + // tag invented tomorrow. + sysTagPrefix = "sys:" +) + +func validTagRunes(s string) bool { + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '-' || r == '_': + default: + return false + } + } + return true +} + +// ValidateTags enforces the shape of a whole tag map. It lives in the service +// layer rather than a handler so that every write path — the tags endpoint, +// server create, anything added later — agrees on what a valid tag is. +func ValidateTags(tags map[string]string) error { + if len(tags) > maxTagsPerHost { + return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost) + } + for k, v := range tags { + if strings.HasPrefix(k, sysTagPrefix) { + return fmt.Errorf("%w: keys beginning %q are reserved", ErrInvalidTag, sysTagPrefix) + } + if k == "" || len(k) > maxTagKeyLen || !validTagRunes(k) { + return fmt.Errorf("%w: key %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagKeyLen) + } + if v == "" || len(v) > maxTagValueLen || !validTagRunes(v) { + return fmt.Errorf("%w: value for %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagValueLen) + } + } + return nil +}