// Package snmpagent defines the narrow privileged boundary used to configure // the target's Net-SNMP agent. Credentials are carried only to the local Unix // socket adapter and are never included in public configuration exports. package snmpagent import ( "context" "errors" "fmt" ) type User struct { Username string `json:"username"` Access string `json:"access"` AuthProtocol string `json:"auth_protocol"` PrivacyProtocol string `json:"privacy_protocol"` AuthKey string `json:"auth_key,omitempty"` PrivacyKey string `json:"privacy_key,omitempty"` Enabled bool `json:"enabled"` } type Config struct { Enabled bool `json:"enabled"` Location string `json:"location"` Contact string `json:"contact"` V1Enabled bool `json:"v1_enabled"` V2CEnabled bool `json:"v2c_enabled"` V3Enabled bool `json:"v3_enabled"` ReadOnlyCommunity string `json:"read_only_community,omitempty"` ReadWriteCommunity string `json:"read_write_community,omitempty"` V3Users []User `json:"v3_users,omitempty"` } type Controller interface { Apply(context.Context, Config) error } // HealthChecker is implemented by remote controller adapters that can verify // their privileged service boundary without changing configuration. type HealthChecker interface { Health(context.Context) error } type MockController struct { ApplyFunc func(context.Context, Config) error } func (c *MockController) Apply(ctx context.Context, config Config) error { if c != nil && c.ApplyFunc != nil { return c.ApplyFunc(ctx, config) } return Validate(config) } type UnavailableError struct{ Reason string } func (e *UnavailableError) Error() string { if e == nil || e.Reason == "" { return "SNMP agent control is unavailable" } return e.Reason } func IsUnavailable(err error) bool { var target *UnavailableError return errors.As(err, &target) } type UnavailableController struct{ Reason string } func (c UnavailableController) Apply(context.Context, Config) error { return &UnavailableError{Reason: c.Reason} } func Validate(config Config) error { if len(config.Location) > 128 || len(config.Contact) > 128 { return errors.New("SNMP location and contact must be 128 characters or fewer") } if containsControl(config.Location) || containsControl(config.Contact) { return errors.New("SNMP location and contact must not contain control characters") } if config.Enabled && !config.V1Enabled && !config.V2CEnabled && !config.V3Enabled { return errors.New("enabled SNMP agent requires at least one version") } if config.V1Enabled || config.V2CEnabled { if err := validateCredential("read-only community", config.ReadOnlyCommunity); err != nil { return err } if config.ReadWriteCommunity != "" { if err := validateCredential("read-write community", config.ReadWriteCommunity); err != nil { return err } } } if len(config.V3Users) > 8 { return errors.New("SNMPv3 user list must contain at most 8 entries") } seen := make(map[string]bool, len(config.V3Users)) enabledUsers := 0 for _, user := range config.V3Users { if err := validateUser(user); err != nil { return err } if seen[user.Username] { return fmt.Errorf("duplicate SNMPv3 user %q", user.Username) } seen[user.Username] = true if user.Enabled { enabledUsers++ } } if config.Enabled && config.V3Enabled && enabledUsers == 0 { return errors.New("enabled SNMPv3 agent requires at least one enabled user") } return nil } func validateUser(user User) error { if user.Username == "" || len(user.Username) > 32 || !safeToken(user.Username) { return errors.New("SNMPv3 username must contain 1 through 32 letters, digits, dot, underscore, or hyphen") } if user.Access != "read" && user.Access != "write" { return fmt.Errorf("SNMPv3 user %s access must be read or write", user.Username) } if user.AuthProtocol != "none" && user.AuthProtocol != "md5" && user.AuthProtocol != "sha1" && user.AuthProtocol != "sha256" { return fmt.Errorf("SNMPv3 user %s has unsupported authentication protocol", user.Username) } if user.PrivacyProtocol != "none" && user.PrivacyProtocol != "des" && user.PrivacyProtocol != "aes" { return fmt.Errorf("SNMPv3 user %s has unsupported privacy protocol", user.Username) } if user.PrivacyProtocol != "none" && user.AuthProtocol == "none" { return fmt.Errorf("SNMPv3 user %s privacy requires authentication", user.Username) } if user.Enabled && user.AuthProtocol != "none" { if err := validateCredential("SNMPv3 authentication key", user.AuthKey); err != nil { return fmt.Errorf("user %s: %w", user.Username, err) } } if user.Enabled && user.PrivacyProtocol != "none" { if err := validateCredential("SNMPv3 privacy key", user.PrivacyKey); err != nil { return fmt.Errorf("user %s: %w", user.Username, err) } } return nil } func validateCredential(name, value string) error { if len(value) < 8 || len(value) > 64 || containsControl(value) { return fmt.Errorf("%s must contain 8 through 64 non-control characters", name) } return nil } func safeToken(value string) bool { for _, char := range value { if char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '.' || char == '_' || char == '-' { continue } return false } return value != "" } func containsControl(value string) bool { for _, char := range value { if char < 0x20 || char == 0x7f { return true } } return false }