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
172 lines
5.4 KiB
|
2 months ago
|
// 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
|
||
|
|
}
|