package snmpagent import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "sync" "tera-clone/services/tera-api/internal/storage/atomicfile" ) const ( DefaultConfigPath = "/etc/snmp/snmpd.conf" DefaultPersistentPath = "/var/lib/net-snmp/snmpd.conf" DefaultInitScriptPath = "/etc/init.d/S59snmpd" ) type Runner interface { Run(context.Context, string, ...string) ([]byte, error) } type execRunner struct{} func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) { return exec.CommandContext(ctx, name, args...).CombinedOutput() } type OSOptions struct { ConfigPath string PersistentPath string InitScriptPath string ServiceName string Runner Runner } type OSController struct { options OSOptions mu sync.Mutex } func NewOSController(options OSOptions) (*OSController, error) { if options.ConfigPath == "" { options.ConfigPath = DefaultConfigPath } if options.PersistentPath == "" { options.PersistentPath = DefaultPersistentPath } if options.InitScriptPath == "" { options.InitScriptPath = DefaultInitScriptPath } if options.Runner == nil { options.Runner = execRunner{} } for name, path := range map[string]string{"config": options.ConfigPath, "persistent": options.PersistentPath, "init script": options.InitScriptPath} { if !filepath.IsAbs(path) { return nil, fmt.Errorf("SNMP %s path must be absolute", name) } } return &OSController{options: options}, nil } func (c *OSController) Apply(ctx context.Context, config Config) error { if err := Validate(config); err != nil { return err } mainConfig, persistentConfig := render(config) c.mu.Lock() defer c.mu.Unlock() if err := ctx.Err(); err != nil { return err } mainBefore, err := snapshot(c.options.ConfigPath) if err != nil { return err } persistentBefore, err := snapshot(c.options.PersistentPath) if err != nil { return err } if err := os.MkdirAll(filepath.Dir(c.options.ConfigPath), 0o750); err != nil { return fmt.Errorf("prepare SNMP configuration directory: %w", err) } if err := os.MkdirAll(filepath.Dir(c.options.PersistentPath), 0o700); err != nil { return fmt.Errorf("prepare SNMP persistent directory: %w", err) } if err := atomicfile.Write(c.options.ConfigPath, mainConfig, 0o600); err != nil { return fmt.Errorf("write SNMP agent configuration: %w", err) } if err := atomicfile.Write(c.options.PersistentPath, persistentConfig, 0o600); err != nil { _ = restore(mainBefore) return fmt.Errorf("write SNMPv3 persistent configuration: %w", err) } action := "stop" if config.Enabled { action = "restart" } if output, err := c.runService(ctx, action); err != nil { restoreErr := errors.Join(restore(mainBefore), restore(persistentBefore)) rollbackAction := "stop" if mainBefore.exists && strings.Contains(string(mainBefore.content), "agentAddress ") { rollbackAction = "restart" } _, restartErr := c.runService(context.Background(), rollbackAction) return errors.Join(fmt.Errorf("%s Net-SNMP agent: %w: %s", action, err, boundedOutput(output)), restoreErr, restartErr) } return nil } func (c *OSController) runService(ctx context.Context, action string) ([]byte, error) { args := []string{action} if c.options.ServiceName != "" { args = append(args, c.options.ServiceName) } return c.options.Runner.Run(ctx, c.options.InitScriptPath, args...) } type fileState struct { path string exists bool mode os.FileMode content []byte } func snapshot(path string) (fileState, error) { info, err := os.Lstat(path) if os.IsNotExist(err) { return fileState{path: path}, nil } if err != nil { return fileState{}, fmt.Errorf("inspect %s: %w", path, err) } if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { return fileState{}, fmt.Errorf("refusing non-regular SNMP configuration path %s", path) } content, err := os.ReadFile(path) if err != nil { return fileState{}, fmt.Errorf("read %s: %w", path, err) } return fileState{path: path, exists: true, mode: info.Mode().Perm(), content: content}, nil } func restore(state fileState) error { if !state.exists { if err := os.Remove(state.path); err != nil && !os.IsNotExist(err) { return err } return nil } return atomicfile.Write(state.path, state.content, state.mode) } func render(config Config) ([]byte, []byte) { var main strings.Builder main.WriteString("# Generated by tera-snmpd. Manual changes are overwritten.\n") if config.Enabled { main.WriteString("agentAddress udp:161\n") } main.WriteString("sysLocation \"") main.WriteString(quote(config.Location)) main.WriteString("\"\nsysContact \"") main.WriteString(quote(config.Contact)) main.WriteString("\"\nview tera_all included .1 80\n") if config.V1Enabled || config.V2CEnabled { writeCommunity(&main, "tera_ro", config.ReadOnlyCommunity, "read", config.V1Enabled, config.V2CEnabled) if config.ReadWriteCommunity != "" { writeCommunity(&main, "tera_rw", config.ReadWriteCommunity, "write", config.V1Enabled, config.V2CEnabled) } } users := append([]User(nil), config.V3Users...) sort.Slice(users, func(i, j int) bool { return users[i].Username < users[j].Username }) var persistent strings.Builder persistent.WriteString("# Generated by tera-snmpd. Contains protected SNMPv3 bootstrap credentials.\n") if config.V3Enabled { for _, user := range users { if !user.Enabled { continue } level := "noauth" if user.AuthProtocol != "none" { level = "auth" } if user.PrivacyProtocol != "none" { level = "priv" } directive := "rouser" if user.Access == "write" { directive = "rwuser" } fmt.Fprintf(&main, "%s %s %s .1\n", directive, user.Username, level) fmt.Fprintf(&persistent, "createUser %s", user.Username) if user.AuthProtocol != "none" { fmt.Fprintf(&persistent, " %s \"%s\"", netSNMPAuth(user.AuthProtocol), quote(user.AuthKey)) } if user.PrivacyProtocol != "none" { fmt.Fprintf(&persistent, " %s \"%s\"", netSNMPPrivacy(user.PrivacyProtocol), quote(user.PrivacyKey)) } persistent.WriteByte('\n') } } return []byte(main.String()), []byte(persistent.String()) } func writeCommunity(builder *strings.Builder, securityName, community, access string, v1, v2c bool) { fmt.Fprintf(builder, "com2sec %s default \"%s\"\n", securityName, quote(community)) if v1 { fmt.Fprintf(builder, "group %s_v1 v1 %s\naccess %s_v1 \"\" any noauth exact tera_all %s none\n", securityName, securityName, securityName, viewForAccess(access)) } if v2c { fmt.Fprintf(builder, "group %s_v2c v2c %s\naccess %s_v2c \"\" any noauth exact tera_all %s none\n", securityName, securityName, securityName, viewForAccess(access)) } } func viewForAccess(access string) string { if access == "write" { return "tera_all" } return "none" } func netSNMPAuth(value string) string { switch value { case "md5": return "MD5" case "sha256": return "SHA-256" default: return "SHA" } } func netSNMPPrivacy(value string) string { if value == "des" { return "DES" } return "AES" } func quote(value string) string { return strings.NewReplacer("\\", "\\\\", "\"", "\\\"").Replace(value) } func boundedOutput(value []byte) string { value = []byte(strings.TrimSpace(string(value))) if len(value) > 512 { value = value[:512] } return string(value) }