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.
 
 
 
 
 
 

253 lines
7.2 KiB

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