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.
277 lines
8.1 KiB
277 lines
8.1 KiB
package snmpagent
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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) (applyErr 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)
|
|
}
|
|
// Net-SNMP writes its USM state on shutdown. Stop it before replacing
|
|
// bootstrap users, otherwise shutdown can overwrite the new credentials.
|
|
if output, err := c.runService(ctx, "stop"); err != nil {
|
|
return fmt.Errorf("stop Net-SNMP agent: %w: %s", err, boundedOutput(output))
|
|
}
|
|
defer func() {
|
|
if applyErr == nil {
|
|
return
|
|
}
|
|
recovery, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
_, stopErr := c.runService(recovery, "stop")
|
|
restoreErr := errors.Join(restore(mainBefore), restore(persistentBefore))
|
|
var restartErr error
|
|
if mainBefore.exists && strings.Contains(string(mainBefore.content), "agentAddress ") {
|
|
_, restartErr = c.runService(recovery, "start")
|
|
}
|
|
applyErr = errors.Join(applyErr, stopErr, restoreErr, restartErr)
|
|
}()
|
|
// Retain the final engine identity/boot counter, but regenerate users.
|
|
stoppedState, err := snapshot(c.options.PersistentPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
persistentBefore = stoppedState
|
|
for _, line := range strings.Split(string(persistentBefore.content), "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 2 && (fields[0] == "engineBoots" || fields[0] == "oldEngineID") {
|
|
persistentConfig = append(persistentConfig, []byte(line+"\n")...)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
if config.Enabled {
|
|
if output, err := c.runService(ctx, "start"); err != nil {
|
|
return fmt.Errorf("start Net-SNMP agent: %w: %s", err, boundedOutput(output))
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
|