You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

172 lines
5.4 KiB

// 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
}